diff --git a/docs/webhooks.md b/docs/webhooks.md index 443f1ffd..96bb4478 100644 --- a/docs/webhooks.md +++ b/docs/webhooks.md @@ -266,12 +266,20 @@ Triggers an agent with an input prompt. Available in all editions. "usage": { "prompt_tokens": 150, "completion_tokens": 200, - "total_tokens": 350 + "total_tokens": 350, + "cache_read_input_tokens": 120, + "cache_creation_input_tokens": 30, + "prompt_tokens_include_cached_segments": true }, "finish_reason": "stop" } ``` +> `cache_read_input_tokens` and `cache_creation_input_tokens` are present only when +> prompt caching was active (omitted otherwise). When +> `prompt_tokens_include_cached_segments` is `true`, `prompt_tokens` already counts +> the cached segments, so non-cached input = `prompt_tokens - cache_read_input_tokens`. + Sync mode times out after the configured deadline (default **600s**). On timeout: `504 Gateway Timeout` with `webhook.llm_timeout`. #### Agent-run timeouts (configurable) @@ -428,7 +436,10 @@ User-Agent: goclaw-webhook/1 "usage": { "prompt_tokens": 150, "completion_tokens": 200, - "total_tokens": 350 + "total_tokens": 350, + "cache_read_input_tokens": 120, + "cache_creation_input_tokens": 30, + "prompt_tokens_include_cached_segments": true }, "metadata": {}, "error": "" diff --git a/internal/http/webhooks_llm.go b/internal/http/webhooks_llm.go index 9ac4451c..65868d74 100644 --- a/internal/http/webhooks_llm.go +++ b/internal/http/webhooks_llm.go @@ -79,9 +79,12 @@ type webhookLLMSyncResp struct { // webhookLLMUsage mirrors providers.Usage for the response envelope. type webhookLLMUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CacheReadTokens int `json:"cache_read_input_tokens,omitempty"` + CacheCreationTokens int `json:"cache_creation_input_tokens,omitempty"` + PromptTokensIncludeCachedSegments bool `json:"prompt_tokens_include_cached_segments,omitempty"` } // webhookLLMAsyncResp is the 202 response for asynchronous LLM calls. @@ -417,9 +420,12 @@ func (h *WebhookLLMHandler) handleSync( } if out.result.Usage != nil { resp.Usage = &webhookLLMUsage{ - PromptTokens: out.result.Usage.PromptTokens, - CompletionTokens: out.result.Usage.CompletionTokens, - TotalTokens: out.result.Usage.TotalTokens, + PromptTokens: out.result.Usage.PromptTokens, + CompletionTokens: out.result.Usage.CompletionTokens, + TotalTokens: out.result.Usage.TotalTokens, + CacheReadTokens: out.result.Usage.CacheReadTokens, + CacheCreationTokens: out.result.Usage.CacheCreationTokens, + PromptTokensIncludeCachedSegments: out.result.Usage.PromptTokensIncludeCachedSegments, } } @@ -632,9 +638,12 @@ func (h *WebhookLLMHandler) RunTest(ctx context.Context, wh *store.WebhookData, } if out.result.Usage != nil { resp.Usage = &webhookLLMUsage{ - PromptTokens: out.result.Usage.PromptTokens, - CompletionTokens: out.result.Usage.CompletionTokens, - TotalTokens: out.result.Usage.TotalTokens, + PromptTokens: out.result.Usage.PromptTokens, + CompletionTokens: out.result.Usage.CompletionTokens, + TotalTokens: out.result.Usage.TotalTokens, + CacheReadTokens: out.result.Usage.CacheReadTokens, + CacheCreationTokens: out.result.Usage.CacheCreationTokens, + PromptTokensIncludeCachedSegments: out.result.Usage.PromptTokensIncludeCachedSegments, } } diff --git a/internal/http/webhooks_llm_test.go b/internal/http/webhooks_llm_test.go index 2a6700df..1890d5eb 100644 --- a/internal/http/webhooks_llm_test.go +++ b/internal/http/webhooks_llm_test.go @@ -218,7 +218,14 @@ func TestWebhookLLMHandler_SyncHappyPath(t *testing.T) { return &agent.RunResult{ Content: "42", RunID: "run-1", - Usage: &providers.Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15}, + Usage: &providers.Usage{ + PromptTokens: 10, + CompletionTokens: 5, + TotalTokens: 15, + CacheReadTokens: 8, + CacheCreationTokens: 2, + PromptTokensIncludeCachedSegments: true, + }, }, nil }, } @@ -256,6 +263,12 @@ func TestWebhookLLMHandler_SyncHappyPath(t *testing.T) { if resp.Usage == nil || resp.Usage.TotalTokens != 15 { t.Errorf("unexpected usage: %+v", resp.Usage) } + if resp.Usage.CacheReadTokens != 8 || resp.Usage.CacheCreationTokens != 2 { + t.Errorf("cache tokens not propagated: read=%d create=%d", resp.Usage.CacheReadTokens, resp.Usage.CacheCreationTokens) + } + if !resp.Usage.PromptTokensIncludeCachedSegments { + t.Errorf("prompt_tokens_include_cached_segments not propagated") + } if resp.AgentID != agentUUID.String() { t.Errorf("expected agent_id %s, got %s", agentUUID, resp.AgentID) } diff --git a/internal/webhooks/worker.go b/internal/webhooks/worker.go index 9a32adbc..d4d0116d 100644 --- a/internal/webhooks/worker.go +++ b/internal/webhooks/worker.go @@ -25,6 +25,7 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/agent" "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/security" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -104,9 +105,28 @@ type callbackPayload struct { // callbackUsage mirrors providers.Usage for the callback payload. type callbackUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` + CacheReadTokens int `json:"cache_read_input_tokens,omitempty"` + CacheCreationTokens int `json:"cache_creation_input_tokens,omitempty"` + PromptTokensIncludeCachedSegments bool `json:"prompt_tokens_include_cached_segments,omitempty"` +} + +// newCallbackUsage maps a provider usage record onto the callback envelope. +// Returns nil when u is nil (usage omitted from the payload). +func newCallbackUsage(u *providers.Usage) *callbackUsage { + if u == nil { + return nil + } + return &callbackUsage{ + PromptTokens: u.PromptTokens, + CompletionTokens: u.CompletionTokens, + TotalTokens: u.TotalTokens, + CacheReadTokens: u.CacheReadTokens, + CacheCreationTokens: u.CacheCreationTokens, + PromptTokensIncludeCachedSegments: u.PromptTokensIncludeCachedSegments, + } } // WorkerConfig holds tunable parameters for WebhookWorker. @@ -843,14 +863,7 @@ func (w *WebhookWorker) invokeAgent( return "", nil, runErr } - var usage *callbackUsage - if result.Usage != nil { - usage = &callbackUsage{ - PromptTokens: result.Usage.PromptTokens, - CompletionTokens: result.Usage.CompletionTokens, - TotalTokens: result.Usage.TotalTokens, - } - } + usage := newCallbackUsage(result.Usage) return result.Content, usage, nil } diff --git a/internal/webhooks/worker_test.go b/internal/webhooks/worker_test.go index 797c07af..bc2e3a9c 100644 --- a/internal/webhooks/worker_test.go +++ b/internal/webhooks/worker_test.go @@ -14,6 +14,7 @@ import ( "github.com/google/uuid" "github.com/nextlevelbuilder/goclaw/internal/crypto" + "github.com/nextlevelbuilder/goclaw/internal/providers" "github.com/nextlevelbuilder/goclaw/internal/security" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -181,6 +182,33 @@ func newTestCall(callbackURL string, agentID *uuid.UUID) *store.WebhookCallData return call } +func TestNewCallbackUsageMapsCacheTokens(t *testing.T) { + if got := newCallbackUsage(nil); got != nil { + t.Fatalf("nil usage must map to nil, got %+v", got) + } + u := &providers.Usage{ + PromptTokens: 10, + CompletionTokens: 5, + TotalTokens: 15, + CacheReadTokens: 8, + CacheCreationTokens: 2, + PromptTokensIncludeCachedSegments: true, + } + got := newCallbackUsage(u) + if got == nil { + t.Fatal("expected non-nil callbackUsage") + } + if got.PromptTokens != 10 || got.CompletionTokens != 5 || got.TotalTokens != 15 { + t.Errorf("base tokens wrong: %+v", got) + } + if got.CacheReadTokens != 8 || got.CacheCreationTokens != 2 { + t.Errorf("cache tokens not mapped: read=%d create=%d", got.CacheReadTokens, got.CacheCreationTokens) + } + if !got.PromptTokensIncludeCachedSegments { + t.Error("include-cached flag not mapped") + } +} + func TestDecodeAsyncPayload_UnwrapsAuditEnvelope(t *testing.T) { meta := asyncPayload{ Input: json.RawMessage(`"hello"`),