Files
pulse/internal/api/chat_service_adapter.go
T
rcourtman 2390987e12 Account Anthropic prompt-cache usage instead of dropping it
The Anthropic client logged cache_creation_input_tokens and
cache_read_input_tokens and then returned only input_tokens and
output_tokens, so every cached Patrol run reached the usage ledger, the
cost preview, the budget check and the weekly digest with its cache
buckets missing. Pulse #2350 reproduced the loss on both the Chat and
ChatStream paths, and #2118's reporter was told to trust the provider's
billing view over Pulse's totals until this was fixed.

Both response paths now carry the two buckets as fields disjoint from
InputTokens (streaming assigns them from message_start and the final
cumulative message_delta rather than summing). The agentic loops, Patrol
responses and run records sum them beside the ordinary totals, the usage
ledger and its persisted copies keep them, and cost.EstimateUsageUSD
prices each bucket at Anthropic's published cache rates (5-minute write
1.25x, read 0.1x of the input rate), falling back to the input rate for
providers without a known cache price. Spend summaries, the digest spend,
the cost projection medians and the usage export expose the buckets and
price them the same way. InputTokens keeps its meaning everywhere, so the
telemetry token buckets are unchanged.
2026-10-03 09:31:34 +01:00

131 lines
4.4 KiB
Go

package api
import (
"context"
"encoding/json"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/chat"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
)
// chatServiceAdapter wraps chat.Service to implement ai.ChatServiceProvider.
// This bridges the chat package (concrete) to the ai package (interface) without
// creating an import cycle.
type chatServiceAdapter struct {
svc *chat.Service
}
func (a *chatServiceAdapter) CreateSession(ctx context.Context) (*ai.ChatSession, error) {
session, err := a.svc.CreateSession(ctx)
if err != nil {
return nil, err
}
return &ai.ChatSession{ID: session.ID}, nil
}
func (a *chatServiceAdapter) ExecuteStream(ctx context.Context, req ai.ChatExecuteRequest, callback ai.ChatStreamCallback) error {
return a.svc.ExecuteStream(ctx, chat.ExecuteRequest{
Prompt: req.Prompt,
SessionID: req.SessionID,
}, adaptCallback(callback))
}
func (a *chatServiceAdapter) ExecutePatrolStream(ctx context.Context, req ai.PatrolExecuteRequest, callback ai.ChatStreamCallback) (*ai.PatrolStreamResponse, error) {
resp, err := a.svc.ExecutePatrolStream(ctx, adaptPatrolExecuteRequest(req), adaptCallback(callback))
if err != nil {
if resp != nil {
return &ai.PatrolStreamResponse{
Content: resp.Content,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
CacheCreationInputTokens: resp.CacheCreationInputTokens,
CacheReadInputTokens: resp.CacheReadInputTokens,
}, err
}
return nil, err
}
return &ai.PatrolStreamResponse{
Content: resp.Content,
InputTokens: resp.InputTokens,
OutputTokens: resp.OutputTokens,
CacheCreationInputTokens: resp.CacheCreationInputTokens,
CacheReadInputTokens: resp.CacheReadInputTokens,
}, nil
}
func adaptPatrolExecuteRequest(req ai.PatrolExecuteRequest) chat.PatrolRequest {
return chat.PatrolRequest{
Prompt: req.Prompt,
SystemPrompt: req.SystemPrompt,
SessionID: req.SessionID,
ExecutionID: req.ExecutionID,
UseCase: req.UseCase,
MaxTurns: req.MaxTurns,
AllowedToolNames: append([]string(nil), req.AllowedToolNames...),
}
}
//nolint:dupl // mirrors orchestratorChatAdapter.GetMessages: same source messages mapped onto a deliberately separate output contract that may diverge
func (a *chatServiceAdapter) GetMessages(ctx context.Context, sessionID string) ([]ai.ChatMessage, error) {
messages, err := a.svc.GetMessages(ctx, sessionID)
if err != nil {
return nil, err
}
result := make([]ai.ChatMessage, len(messages))
for i, m := range messages {
result[i] = adaptChatMessage(m)
}
return result, nil
}
func adaptChatMessage(m chat.Message) ai.ChatMessage {
msg := ai.ChatMessage{
ID: m.ID,
Role: m.Role,
Content: m.Content,
ReasoningContent: m.ReasoningContent,
Timestamp: m.Timestamp,
}
for _, tc := range m.ToolCalls {
msg.ToolCalls = append(msg.ToolCalls, tc.NormalizeCollections())
}
if m.ToolResult != nil {
toolResult := *m.ToolResult
msg.ToolResult = &toolResult
}
return msg.NormalizeCollections()
}
func (a *chatServiceAdapter) DeleteSession(ctx context.Context, sessionID string) error {
return a.svc.DeleteSession(ctx, sessionID)
}
func (a *chatServiceAdapter) ReloadConfig(ctx context.Context, cfg *config.AIConfig) error {
return a.svc.Restart(ctx, cfg)
}
func (a *chatServiceAdapter) SetPatrolProviderFactory(factory func(string) (providers.StreamingProvider, error)) {
a.svc.SetPatrolProviderFactory(factory)
}
// GetExecutor exposes the underlying chat service's tool executor so that
// patrol can set the finding creator. This satisfies the
// chatServiceExecutorAccessor interface in the ai package.
func (a *chatServiceAdapter) GetExecutor() *tools.PulseToolExecutor {
return a.svc.GetExecutor()
}
// adaptCallback converts an ai.ChatStreamCallback to a chat.StreamCallback.
// The ai package uses []byte for event data, while the chat package uses json.RawMessage.
func adaptCallback(callback ai.ChatStreamCallback) chat.StreamCallback {
return func(event chat.StreamEvent) {
callback(ai.ChatStreamEvent{
Type: event.Type,
Data: json.RawMessage(event.Data),
})
}
}