Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/assistant/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ func (client *HTTPCompletionClient) completeAnthropic(
return nil, err
}
var response struct {
Error providerError `json:"error"`
Error providerError `json:"error"`
Usage map[string]any `json:"usage"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Expand All @@ -46,7 +47,7 @@ func (client *HTTPCompletionClient) completeAnthropic(
return nil, oops.In("assistant").Code("anthropic_empty").Errorf("provider returned an empty response")
}

return textCompletionResult(text), nil
return textCompletionResult(text, usageFromObject(response.Usage)), nil
}

func anthropicPayload(request *CompletionRequest) map[string]any {
Expand Down
13 changes: 8 additions & 5 deletions internal/assistant/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const (
jsonToolParamsKey = "parameters"
jsonCallIDKey = "call_id"
jsonOutputKey = "output"
jsonOutputTokensKey = "output_tokens"
jsonToolChoiceKey = "tool_choice"
jsonTextKey = "text"
jsonThinkingKey = "thinking"
Expand Down Expand Up @@ -64,9 +65,10 @@ type CompletionRequest struct {

// CompletionResult is a provider response plus model-visible side effects.
type CompletionResult struct {
Text string `json:"text"`
Thinking []string `json:"thinking,omitempty"`
ToolEvents []ToolEvent `json:"tool_events,omitempty"`
Text string `json:"text"`
Thinking []string `json:"thinking,omitempty"`
ToolEvents []ToolEvent `json:"tool_events,omitempty"`
Usage model.TokenUsage `json:"usage,omitempty"`
}

// ToolEvent captures one tool call for persistence and TUI rendering.
Expand Down Expand Up @@ -95,6 +97,7 @@ type providerResult struct {
OutputItems []any
Thinking []string
ToolCalls []toolCall
Usage model.TokenUsage
}

// HTTPCompletionClient is a small provider client for built-in API families.
Expand Down Expand Up @@ -133,6 +136,6 @@ func (client *HTTPCompletionClient) Complete(
}
}

func textCompletionResult(text string) *CompletionResult {
return &CompletionResult{Text: strings.TrimSpace(text), Thinking: nil, ToolEvents: nil}
func textCompletionResult(text string, usage model.TokenUsage) *CompletionResult {
return &CompletionResult{Text: strings.TrimSpace(text), Thinking: nil, ToolEvents: nil, Usage: usage}
}
5 changes: 3 additions & 2 deletions internal/assistant/openai_chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ func (client *HTTPCompletionClient) completeOpenAIChat(
return nil, err
}
var response struct {
Error providerError `json:"error"`
Error providerError `json:"error"`
Usage map[string]any `json:"usage"`
Choices []struct {
Message struct {
Content string `json:"content"`
Expand All @@ -46,7 +47,7 @@ func (client *HTTPCompletionClient) completeOpenAIChat(
return nil, oops.In("assistant").Code("openai_chat_empty").Errorf("provider returned an empty response")
}

return textCompletionResult(response.Choices[0].Message.Content), nil
return textCompletionResult(response.Choices[0].Message.Content, usageFromObject(response.Usage)), nil
}

func openAIChatMessages(request *CompletionRequest) []map[string]string {
Expand Down
7 changes: 6 additions & 1 deletion internal/assistant/openai_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"strings"

"github.com/samber/oops"

"github.com/omarluq/librecode/internal/model"
)

func (client *HTTPCompletionClient) completeOpenAIResponses(
Expand Down Expand Up @@ -36,14 +38,15 @@ func (client *HTTPCompletionClient) completeResponsesLoop(
input []any,
stream bool,
) (*CompletionResult, error) {
result := &CompletionResult{Text: "", Thinking: nil, ToolEvents: nil}
result := &CompletionResult{Text: "", Thinking: nil, ToolEvents: nil, Usage: model.EmptyTokenUsage()}
for {
payload := responsesPayload(request, input, stream)
providerResult, err := client.requestResponses(ctx, endpoint, headers, payload, stream, request.OnEvent)
if err != nil {
return nil, err
}
result.Thinking = append(result.Thinking, providerResult.Thinking...)
result.Usage = mergeUsage(result.Usage, providerResult.Usage)
if err := validateToolCalls(providerResult.ToolCalls); err != nil {
return nil, err
}
Expand Down Expand Up @@ -181,6 +184,7 @@ func providerResultFromResponse(response map[string]any) *providerResult {
OutputItems: outputItems,
Thinking: thinkingFromOutput(outputItems),
ToolCalls: toolCallsFromOutput(outputItems),
Usage: usageFromObject(response["usage"]),
}
}

Expand All @@ -195,6 +199,7 @@ func providerResultFromOutputItems(outputItems []any, fallbackText string) *prov
OutputItems: outputItems,
Thinking: thinkingFromOutput(outputItems),
ToolCalls: toolCallsFromOutput(outputItems),
Usage: model.EmptyTokenUsage(),
}
}

Expand Down
124 changes: 95 additions & 29 deletions internal/assistant/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,30 +70,35 @@ const (
StreamEventToolResult StreamEventKind = "tool_result"
// StreamEventSkillLoaded carries an explicitly loaded Agent Skill.
StreamEventSkillLoaded StreamEventKind = "skill_loaded"
// StreamEventUsage carries estimated or provider-reported token usage.
StreamEventUsage StreamEventKind = "usage"
)

// StreamEvent is emitted during prompt execution before final persistence.
type StreamEvent struct {
ToolEvent *ToolEvent `json:"tool_event,omitempty"`
Kind StreamEventKind `json:"kind"`
Text string `json:"text,omitempty"`
ToolEvent *ToolEvent `json:"tool_event,omitempty"`
Usage *model.TokenUsage `json:"usage,omitempty"`
Kind StreamEventKind `json:"kind"`
Text string `json:"text,omitempty"`
}

// PromptResponse describes persisted prompt output.
type PromptResponse struct {
SessionID string `json:"session_id"`
UserEntryID string `json:"user_entry_id"`
AssistantEntryID string `json:"assistant_entry_id"`
Text string `json:"text"`
Thinking []string `json:"thinking,omitempty"`
ToolEvents []ToolEvent `json:"tool_events,omitempty"`
Cached bool `json:"cached"`
SessionID string `json:"session_id"`
UserEntryID string `json:"user_entry_id"`
AssistantEntryID string `json:"assistant_entry_id"`
Text string `json:"text"`
Thinking []string `json:"thinking,omitempty"`
ToolEvents []ToolEvent `json:"tool_events,omitempty"`
Usage model.TokenUsage `json:"usage,omitempty"`
Cached bool `json:"cached"`
}

type responseBundle struct {
Text string
Thinking []string
ToolEvents []ToolEvent
Usage model.TokenUsage
}

// NewRuntime creates an assistant runtime.
Expand Down Expand Up @@ -197,6 +202,7 @@ func (runtime *Runtime) Prompt(ctx context.Context, request *PromptRequest) (*Pr
Text: bundle.Text,
Thinking: bundle.Thinking,
ToolEvents: bundle.ToolEvents,
Usage: bundle.Usage,
Cached: cached,
}, nil
}
Expand Down Expand Up @@ -368,7 +374,12 @@ func (runtime *Runtime) respond(
) {
if strings.HasPrefix(prompt, slashPrefix) {
slashResponse, slashToolEvents, slashErr := runtime.respondToSlashCommand(ctx, cwd, prompt, onEvent)
return &responseBundle{Text: slashResponse, Thinking: nil, ToolEvents: slashToolEvents}, false, slashErr
return &responseBundle{
Text: slashResponse,
Thinking: nil,
ToolEvents: slashToolEvents,
Usage: model.EmptyTokenUsage(),
}, false, slashErr
}

cacheKey := runtime.cacheKey(sessionID, prompt)
Expand All @@ -377,7 +388,12 @@ func (runtime *Runtime) respond(
return nil, false, oops.In("assistant").Code("cache_get").Wrapf(err, "read response cache")
}
if found {
return &responseBundle{Text: cachedResponse, Thinking: nil, ToolEvents: nil}, true, nil
return &responseBundle{
Text: cachedResponse,
Thinking: nil,
ToolEvents: nil,
Usage: model.EmptyTokenUsage(),
}, true, nil
}

bundle, err = runtime.modelResponse(ctx, sessionID, cwd, prompt, onEvent, onRetry)
Expand Down Expand Up @@ -449,7 +465,12 @@ func (runtime *Runtime) respondToSkillCommand(
if err != nil {
return "", nil, err
}
emitStreamEvent(onEvent, StreamEvent{ToolEvent: &toolEvent, Kind: StreamEventSkillLoaded, Text: skill.Name})
emitStreamEvent(onEvent, StreamEvent{
ToolEvent: &toolEvent,
Usage: nil,
Kind: StreamEventSkillLoaded,
Text: skill.Name,
})

return result, []ToolEvent{toolEvent}, nil
}
Expand Down Expand Up @@ -536,9 +557,9 @@ func (runtime *Runtime) modelResponse(
With("provider", selectedModel.Provider).
Wrapf(fmt.Errorf("%s", auth.Error), "resolve model auth")
}
sessionMessages, err := runtime.sessions.Messages(ctx, sessionID)
messages, err := runtime.modelContextMessages(ctx, sessionID)
if err != nil {
return nil, oops.In("assistant").Code("load_context").Wrapf(err, "load session context")
return nil, err
}

systemPrompt := defaultSystemPrompt(cwd)
Expand Down Expand Up @@ -569,10 +590,12 @@ func (runtime *Runtime) modelResponse(
}
}

estimatedUsage := estimateTokenUsage(systemPrompt, messages, &selectedModel)
runtime.emitUsage(ctx, onEvent, estimatedUsage)
request := &CompletionRequest{
Model: selectedModel,
Auth: auth,
Messages: messageEntities(sessionMessages),
Messages: messages,
SessionID: sessionID,
SystemPrompt: systemPrompt,
ThinkingLevel: runtime.cfg.Assistant.ThinkingLevel,
Expand All @@ -583,7 +606,15 @@ func (runtime *Runtime) modelResponse(
if err != nil {
return nil, err
}
return &responseBundle{Text: result.Text, Thinking: result.Thinking, ToolEvents: result.ToolEvents}, nil
usage := mergeUsage(estimatedUsage, result.Usage)
runtime.emitUsage(ctx, onEvent, usage)

return &responseBundle{
Text: result.Text,
Thinking: result.Thinking,
ToolEvents: result.ToolEvents,
Usage: usage,
}, nil
}

func (runtime *Runtime) completeWithRetry(
Expand Down Expand Up @@ -701,7 +732,12 @@ func (runtime *Runtime) emitActivatedSkillReads(
slog.Any("error", err),
)
}
emitStreamEvent(onEvent, StreamEvent{ToolEvent: &toolEvent, Kind: StreamEventSkillLoaded, Text: skill.Name})
emitStreamEvent(onEvent, StreamEvent{
ToolEvent: &toolEvent,
Usage: nil,
Kind: StreamEventSkillLoaded,
Text: skill.Name,
})
toolEvents = append(toolEvents, toolEvent)
}

Expand Down Expand Up @@ -742,20 +778,50 @@ func activeSkillMatchPayload(matches []core.SkillActivationDiagnostic) []map[str
return payload
}

func messageEntities(messages []database.SessionMessageEntity) []database.MessageEntity {
converted := make([]database.MessageEntity, 0, len(messages))
func (runtime *Runtime) modelContextMessages(ctx context.Context, sessionID string) ([]database.MessageEntity, error) {
leafEntry, _, err := runtime.sessions.LeafEntry(ctx, sessionID)
if err != nil {
return nil, oops.In("assistant").Code("load_context_leaf").Wrapf(err, "load session leaf")
}
leafID := ""
if leafEntry != nil {
leafID = leafEntry.ID
}
contextEntity, err := runtime.sessions.BuildContext(ctx, sessionID, leafID)
if err != nil {
return nil, oops.In("assistant").Code("load_context").Wrapf(err, "load session context")
}

return modelFacingMessages(contextEntity.Messages), nil
}

func modelFacingMessages(messages []database.MessageEntity) []database.MessageEntity {
filtered := make([]database.MessageEntity, 0, len(messages))
for index := range messages {
message := &messages[index]
converted = append(converted, database.MessageEntity{
Timestamp: message.CreatedAt,
Role: message.Role,
Content: message.Content,
Provider: message.Provider,
Model: message.Model,
})
message := messages[index]
if !isModelFacingRole(message.Role) || strings.TrimSpace(message.Content) == "" {
continue
}
filtered = append(filtered, message)
}

return filtered
}

func isModelFacingRole(role database.Role) bool {
switch role {
case database.RoleUser, database.RoleAssistant:
return true
case database.RoleToolResult,
database.RoleThinking,
database.RoleCustom,
database.RoleBashExecution,
database.RoleBranchSummary,
database.RoleCompactionSummary:
return false
}

return converted
return false
}

func defaultSystemPrompt(cwd string) string {
Expand Down
Loading
Loading