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
249 changes: 219 additions & 30 deletions internal/assistant/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,54 +10,119 @@ import (

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

func (client *HTTPCompletionClient) completeAnthropic(
ctx context.Context,
request *CompletionRequest,
) (*CompletionResult, error) {
payload := anthropicPayload(request)
endpoint := joinEndpoint(request.Model.BaseURL, "/v1/messages")
content, err := client.postJSON(ctx, endpoint, anthropicHeaders(request), payload)
if err != nil {
return nil, err
state := anthropicLoopState{
messages: anthropicMessages(request.Messages),
endpoint: joinEndpoint(request.Model.BaseURL, "/v1/messages"),
result: &CompletionResult{Text: "", Thinking: nil, ToolEvents: nil, Usage: model.EmptyTokenUsage()},
}
var response struct {
Error providerError `json:"error"`
Usage map[string]any `json:"usage"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
for range maxToolIterations {
finished, err := client.advanceAnthropicLoop(ctx, request, &state)
if err != nil {
return nil, err
}
if finished {
return state.result, nil
}
}
if err := json.Unmarshal(content, &response); err != nil {
return nil, oops.In("assistant").Code("anthropic_decode").Wrapf(err, "decode anthropic response")

return nil, toolIterationLimitError()
}

type anthropicLoopState struct {
result *CompletionResult
endpoint string
messages []map[string]any
}

func (client *HTTPCompletionClient) advanceAnthropicLoop(
ctx context.Context,
request *CompletionRequest,
state *anthropicLoopState,
) (bool, error) {
payload := anthropicPayload(request, state.messages)
providerResult, err := client.requestAnthropic(ctx, state.endpoint, request, payload)
if err != nil {
return false, err
}
if response.Error.Message != "" {
return nil, providerErrorToOops("anthropic_error", &response.Error)
state.result.Usage = mergeUsage(state.result.Usage, providerResult.Usage)
if err := validateToolCalls(providerResult.ToolCalls); err != nil {
return false, err
}
parts := make([]string, 0, len(response.Content))
for _, block := range response.Content {
if block.Type == jsonTextKey && block.Text != "" {
parts = append(parts, block.Text)
if len(providerResult.ToolCalls) == 0 {
if fallback := textToolCallsFromText(providerResult.Text); len(fallback) > 0 {
providerResult.ToolCalls = fallback
} else {
return finishTextResult(state.result, providerResult.Text, "anthropic_empty")
}
}
text := strings.TrimSpace(strings.Join(parts, "\n"))
if text == "" {
return nil, oops.In("assistant").Code("anthropic_empty").Errorf("provider returned an empty response")
events := executeAnthropicToolCalls(ctx, request, providerResult.ToolCalls)
state.result.ToolEvents = append(state.result.ToolEvents, events...)
if err := appendAnthropicToolConversation(state, providerResult, events); err != nil {
return false, err
}

return false, nil
}

func executeAnthropicToolCalls(
ctx context.Context,
request *CompletionRequest,
calls []toolCall,
) []ToolEvent {
_, events := executeToolCalls(
ctx,
request.CWD,
calls,
request.OnEvent,
request.OnToolCall,
request.OnToolResult,
)

return events
}

func appendAnthropicToolConversation(
state *anthropicLoopState,
providerResult *providerResult,
events []ToolEvent,
) error {
if hasTextFallbackToolCalls(providerResult.ToolCalls) {
state.messages = append(
state.messages,
map[string]any{jsonRoleKey: jsonAssistantRole, jsonContentKey: providerResult.Text},
map[string]any{jsonRoleKey: jsonUserRole, jsonContentKey: textToolResultPrompt(events)},
)
return nil
}
toolResultMessage, err := anthropicToolResultMessage(providerResult.ToolCalls, events)
if err != nil {
return err
}
state.messages = append(
state.messages,
anthropicAssistantToolMessage(providerResult.ToolCalls),
toolResultMessage,
)

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

func anthropicPayload(request *CompletionRequest) map[string]any {
func anthropicPayload(request *CompletionRequest, messages []map[string]any) map[string]any {
// Anthropic's recent Claude models reject temperature when thinking/adaptive
// reasoning is available. Match production agent clients by omitting
// temperature unless/until librecode exposes an explicit user setting.
payload := map[string]any{
jsonModelKey: request.Model.ID,
"max_tokens": minPositive(request.Model.MaxTokens, 4096),
"messages": anthropicMessages(request.Messages),
"messages": messages,
"tools": anthropicTools(),
}
if usesAnthropicOAuth(request) {
payload["system"] = anthropicOAuthSystemPrompt(request.SystemPrompt)
Expand Down Expand Up @@ -212,25 +277,149 @@ func appendAnthropicBeta(existing string, values ...string) string {
return strings.Join(output, ",")
}

func anthropicMessages(messages []database.MessageEntity) []map[string]string {
output := []map[string]string{}
func (client *HTTPCompletionClient) requestAnthropic(
ctx context.Context,
endpoint string,
request *CompletionRequest,
payload map[string]any,
) (*providerResult, error) {
content, err := client.postJSON(ctx, endpoint, anthropicHeaders(request), payload)
if err != nil {
return nil, err
}

return parseAnthropicResult(content)
}

func parseAnthropicResult(content []byte) (*providerResult, error) {
var response struct {
Error providerError `json:"error"`
Usage map[string]any `json:"usage"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Input any `json:"input"`
ID string `json:"id"`
Name string `json:"name"`
} `json:"content"`
}
if err := json.Unmarshal(content, &response); err != nil {
return nil, oops.In("assistant").Code("anthropic_decode").Wrapf(err, "decode anthropic response")
}
if response.Error.Message != "" {
return nil, providerErrorToOops("anthropic_error", &response.Error)
}
parts := make([]string, 0, len(response.Content))
calls := make([]toolCall, 0, len(response.Content))
for _, block := range response.Content {
switch block.Type {
case jsonTextKey:
if block.Text != "" {
parts = append(parts, block.Text)
}
case anthropicToolUseType:
calls = append(calls, anthropicToolCall(block.ID, block.Name, block.Input))
}
}

return &providerResult{
Text: strings.TrimSpace(strings.Join(parts, "\n")),
OutputItems: nil,
Thinking: nil,
ToolCalls: calls,
Usage: usageFromObject(response.Usage),
}, nil
}

func anthropicToolCall(id, name string, input any) toolCall {
arguments, argumentsJSON := anthropicToolArguments(input)

return toolCall{Arguments: arguments, ID: id, Name: name, ArgumentsJSON: argumentsJSON, TextFallback: false}
}

func anthropicToolArguments(input any) (arguments map[string]any, argumentsJSON string) {
arguments = map[string]any{}
payload, err := json.Marshal(input)
if err != nil {
return arguments, "{}"
}
if len(payload) == 0 || string(payload) == "null" {
return arguments, "{}"
}
if err := json.Unmarshal(payload, &arguments); err != nil {
return map[string]any{}, string(payload)
}

return arguments, string(payload)
}

func anthropicAssistantToolMessage(calls []toolCall) map[string]any {
blocks := make([]map[string]any, 0, len(calls))
for _, call := range calls {
blocks = append(blocks, map[string]any{
jsonTypeKey: anthropicToolUseType,
"id": call.ID,
jsonToolNameKey: call.Name,
"input": call.Arguments,
})
}

return map[string]any{jsonRoleKey: jsonAssistantRole, jsonContentKey: blocks}
}

func anthropicToolResultMessage(calls []toolCall, events []ToolEvent) (map[string]any, error) {
if len(events) != len(calls) {
return nil, oops.In("assistant").
Code("anthropic_tool_message_mismatch").
With("calls", len(calls)).
With("events", len(events)).
Errorf("build Anthropic tool result message: mismatched tool calls and results")
}
blocks := make([]map[string]any, 0, len(events))
for index, event := range events {
blocks = append(blocks, map[string]any{
jsonTypeKey: anthropicToolResultType,
"tool_use_id": calls[index].ID,
jsonContentKey: toolOutputText(event.Result, event.DetailsJSON),
})
}

return map[string]any{jsonRoleKey: jsonUserRole, jsonContentKey: blocks}, nil
}

func anthropicMessages(messages []database.MessageEntity) []map[string]any {
output := []map[string]any{}
for _, message := range messages {
role, ok := anthropicRole(message.Role)
if !ok || message.Content == "" {
continue
}
output = append(output, map[string]string{jsonRoleKey: role, jsonContentKey: message.Content})
output = append(output, map[string]any{jsonRoleKey: role, jsonContentKey: message.Content})
}

return output
}

func anthropicTools() []map[string]any {
definitions := tool.AllDefinitions()
tools := make([]map[string]any, 0, len(definitions))
for _, definition := range definitions {
tools = append(tools, map[string]any{
jsonToolNameKey: string(definition.Name),
jsonDescriptionKey: definition.Description,
jsonInputSchemaKey: toolParameterSchema(definition.Name),
})
}

return tools
}

func anthropicRole(role database.Role) (string, bool) {
switch role {
case database.RoleUser:
return jsonUserRole, true
case database.RoleAssistant:
return "assistant", true
return jsonAssistantRole, true
case database.RoleToolResult,
database.RoleThinking,
database.RoleCustom,
Expand Down
12 changes: 6 additions & 6 deletions internal/assistant/anthropic_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
func TestAnthropicPayloadOmitsTemperature(t *testing.T) {
t.Parallel()

payload := anthropicPayload(testCompletionRequestAuth("anthropic-claude", "subscription-access-token"))
payload := anthropicPayload(testCompletionRequestAuth("anthropic-claude", "subscription-access-token"), nil)

assert.NotContains(t, payload, "temperature")
assert.Equal(t, "", payload[jsonModelKey])
Expand All @@ -25,7 +25,7 @@ func TestAnthropicPayloadUsesStructuredSystemPrompt(t *testing.T) {

request := testCompletionRequestAuth("sk-ant-api03-secret")
request.SystemPrompt = anthropicTestSystemPrompt
payload := anthropicPayload(request)
payload := anthropicPayload(request, nil)

assert.Equal(t, []map[string]any{anthropicSystemText(anthropicTestSystemPrompt)}, payload["system"])
}
Expand All @@ -35,7 +35,7 @@ func TestAnthropicOAuthPayloadAddsClaudeCodeIdentity(t *testing.T) {

request := testCompletionRequestAuth("anthropic-claude", "sk-ant-oat-secret")
request.SystemPrompt = anthropicTestSystemPrompt
payload := anthropicPayload(request)
payload := anthropicPayload(request, nil)

systemBlocks, ok := payload["system"].([]map[string]any)
assert.True(t, ok)
Expand All @@ -51,7 +51,7 @@ func TestAnthropicPayloadAddsBudgetThinking(t *testing.T) {
request.Model.ID = "claude-sonnet-4-5"
request.Model.Reasoning = true
request.ThinkingLevel = thinkingLow
payload := anthropicPayload(request)
payload := anthropicPayload(request, nil)

assert.Equal(t, map[string]any{
jsonTypeKey: "enabled",
Expand All @@ -67,7 +67,7 @@ func TestAnthropicPayloadDisablesThinkingWhenOff(t *testing.T) {
request.Model.ID = "claude-opus-4-7"
request.Model.Reasoning = true
request.ThinkingLevel = thinkingOff
payload := anthropicPayload(request)
payload := anthropicPayload(request, nil)

assert.Equal(t, map[string]any{jsonTypeKey: "disabled"}, payload[jsonThinkingKey])
assert.NotContains(t, payload, "output_config")
Expand All @@ -80,7 +80,7 @@ func TestAnthropicPayloadAddsAdaptiveThinking(t *testing.T) {
request.Model.ID = "claude-opus-4-7"
request.Model.Reasoning = true
request.ThinkingLevel = thinkingXHigh
payload := anthropicPayload(request)
payload := anthropicPayload(request, nil)

assert.Equal(t, map[string]any{
jsonTypeKey: "adaptive",
Expand Down
Loading
Loading