mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 22:15:47 +00:00
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:
1 parent
fa7a85b339
commit
f87cdaf616
5 files changed
+97
-23
No files matched your search
+13
-2
@@ -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": ""
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`),
|
||||
|
||||
Reference in new issue
Block a user