mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-27 05:57:27 +00:00
2390987e12
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.
131 lines
4.4 KiB
Go
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),
|
|
})
|
|
}
|
|
}
|