feat(webhooks): split webhook usage tokens by cache vs non-cache (#1345)

Surface cache-read and cache-creation input tokens (plus the
prompt_tokens_include_cached_segments flag) in webhook usage responses,
so callers can distinguish cached from non-cached input tokens.

The data already flowed end-to-end via providers.Usage and
agent.RunResult.Usage; the two webhook envelope structs (webhookLLMUsage
for the sync LLM webhook, callbackUsage for the async callback payload)
copied only 3 of the fields, dropping the cache data. Add the 3 cache
fields (mirroring providers.Usage JSON tags exactly, all omitempty) and
populate them. The async callbackUsage mapping is extracted into a pure
newCallbackUsage helper for unit testing.

Additive and non-breaking: omitempty keeps responses without caching
byte-identical. No schema change.
This commit is contained in:
thotam authored and GitHub committed 2026-07-04 23:26:49 +07:00
1 parent fa7a85b339
commit f87cdaf616
5 files changed
+97 -23

No files matched your search

+13 -2
View File
@@ -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": ""
+18 -9
View File
@@ -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,
}
}
+14 -1
View File
@@ -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)
}
+24 -11
View File
@@ -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
}
+28
View File
@@ -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"`),