Files

290 lines
7.7 KiB
Go

package providers
import (
"encoding/json"
"strconv"
"strings"
)
// CacheBoundaryMarker separates stable from dynamic prompt content.
// Exported so agent package can assert consistency (agent has its own copy
// to avoid circular import; agent_test verifies they match).
const CacheBoundaryMarker = "<!-- GOCLAW_CACHE_BOUNDARY -->"
// SplitSystemPromptForCache splits a system prompt at CacheBoundaryMarker.
// Returns 2 blocks if boundary found: stable (with cache_control) + dynamic (without).
// Returns 1 block with cache_control if no boundary (backwards compat).
// Used by both Anthropic and DashScope cache middleware (identical wire format).
func SplitSystemPromptForCache(content string) []map[string]any {
ephemeral := map[string]any{"type": "ephemeral"}
before, after, ok := strings.Cut(content, CacheBoundaryMarker)
if !ok {
return []map[string]any{
{"type": "text", "text": content, "cache_control": ephemeral},
}
}
stable := strings.TrimSpace(before)
dynamic := strings.TrimSpace(after)
blocks := []map[string]any{
{"type": "text", "text": stable, "cache_control": ephemeral},
}
if dynamic != "" {
blocks = append(blocks, map[string]any{"type": "text", "text": dynamic})
}
return blocks
}
// buildRawBlock reconstructs a complete content block from streaming data.
// This is needed to preserve thinking blocks (with signatures) for tool use passback.
func (p *AnthropicProvider) buildRawBlock(blockType string, result *ChatResponse, toolCallJSON map[int]string, _ int) json.RawMessage {
switch blockType {
case "thinking":
block := map[string]any{
"type": "thinking",
"thinking": result.Thinking,
}
if result.ThinkingSignature != "" {
block["signature"] = result.ThinkingSignature
}
if b, err := json.Marshal(block); err == nil {
return b
}
case "text":
block := map[string]any{
"type": "text",
"text": result.Content,
}
if b, err := json.Marshal(block); err == nil {
return b
}
case "tool_use":
if len(result.ToolCalls) > 0 {
tc := result.ToolCalls[len(result.ToolCalls)-1]
// Parse accumulated JSON for this tool call
args := make(map[string]any)
for i, rawJSON := range toolCallJSON {
if i == len(result.ToolCalls)-1 && rawJSON != "" {
_ = json.Unmarshal([]byte(rawJSON), &args)
}
}
block := map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Name,
"input": args,
}
if b, err := json.Marshal(block); err == nil {
return b
}
}
case "redacted_thinking":
// Pass through as-is (we don't have the encrypted data in streaming)
block := map[string]any{
"type": "redacted_thinking",
}
if b, err := json.Marshal(block); err == nil {
return b
}
}
return nil
}
func (p *AnthropicProvider) buildRequestBody(model string, req ChatRequest, stream bool) map[string]any {
// Separate system messages and build conversation messages
var systemBlocks []map[string]any
var messages []map[string]any
for _, msg := range req.Messages {
switch msg.Role {
case "system":
systemBlocks = append(systemBlocks, SplitSystemPromptForCache(msg.Content)...)
case "user":
if len(msg.Images) > 0 || len(msg.Videos) > 0 {
var blocks []map[string]any
for _, img := range msg.Images {
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": img.MimeType,
"data": img.Data,
},
})
}
// Videos are not supported by Anthropic, they are omitted here.
if msg.Content != "" {
blocks = append(blocks, map[string]any{
"type": "text",
"text": msg.Content,
})
}
messages = append(messages, map[string]any{
"role": "user",
"content": blocks,
})
} else {
messages = append(messages, map[string]any{
"role": "user",
"content": msg.Content,
})
}
case "assistant":
// If we have raw content blocks (from Anthropic thinking), use them directly
// to preserve thinking blocks + signatures for tool use passback.
if msg.RawAssistantContent != nil {
var rawBlocks []json.RawMessage
if json.Unmarshal(msg.RawAssistantContent, &rawBlocks) == nil && len(rawBlocks) > 0 {
messages = append(messages, map[string]any{
"role": "assistant",
"content": rawBlocks,
})
continue
}
}
var blocks []map[string]any
if msg.Content != "" {
blocks = append(blocks, map[string]any{
"type": "text",
"text": msg.Content,
})
}
for _, tc := range msg.ToolCalls {
blocks = append(blocks, map[string]any{
"type": "tool_use",
"id": tc.ID,
"name": tc.Name,
"input": tc.Arguments,
})
}
messages = append(messages, map[string]any{
"role": "assistant",
"content": blocks,
})
case "tool":
messages = append(messages, map[string]any{
"role": "user",
"content": []map[string]any{
{
"type": "tool_result",
"tool_use_id": msg.ToolCallID,
"content": msg.Content,
},
},
})
}
}
body := map[string]any{
"model": model,
"max_tokens": 4096,
"messages": messages,
}
if stream {
body["stream"] = true
}
if len(systemBlocks) > 0 {
body["system"] = systemBlocks
}
// Translate tools to Anthropic format
if len(req.Tools) > 0 {
var tools []map[string]any
for _, t := range req.Tools {
if t.Type != "function" || t.Function == nil {
continue
}
cleanedParams := CleanSchemaForProvider("anthropic", t.Function.Parameters)
tool := map[string]any{
"name": t.Function.Name,
"description": t.Function.Description,
"input_schema": cleanedParams,
}
tools = append(tools, tool)
}
// Add cache_control breakpoint to the last tool (caches tool definitions prefix).
if len(tools) > 0 {
tools[len(tools)-1]["cache_control"] = map[string]any{"type": "ephemeral"}
}
body["tools"] = tools
}
// Merge options
if v, ok := req.Options[OptMaxTokens]; ok {
body["max_tokens"] = v
}
if v, ok := req.Options[OptTemperature]; ok {
if !anthropicSkipsTemperature(model) {
body["temperature"] = v
}
}
// Enable extended thinking if thinking_level is set
if level, ok := req.Options[OptThinkingLevel].(string); ok && level != "" && level != "off" {
budget := anthropicThinkingBudget(level)
body["thinking"] = map[string]any{
"type": "enabled",
"budget_tokens": budget,
}
// Anthropic requires no temperature when thinking is enabled
delete(body, "temperature")
// Ensure max_tokens accommodates thinking budget + response
if maxTok, ok := body["max_tokens"].(int); !ok || maxTok < budget+4096 {
body["max_tokens"] = budget + 8192
}
}
return body
}
// anthropicSkipsTemperature reports whether the Messages API rejects sampling
// parameters for this model. Claude Opus/Sonnet 4.6+ return HTTP 400 when
// temperature (and top_p/top_k) are included; omit them entirely.
func anthropicSkipsTemperature(model string) bool {
m := strings.ToLower(model)
for _, family := range []string{"claude-opus-", "claude-sonnet-"} {
after, ok := strings.CutPrefix(m, family)
if !ok {
continue
}
majorPart, afterMajor, hasMinor := strings.Cut(after, "-")
major, err := strconv.Atoi(majorPart)
if err != nil {
continue
}
if major > 4 {
return true
}
if major < 4 || !hasMinor {
continue
}
minorPart, _, _ := strings.Cut(afterMajor, "-")
minor, err := strconv.Atoi(minorPart)
if err == nil && minor >= 6 {
return true
}
}
return false
}
// anthropicThinkingBudget maps a thinking level to a token budget.
func anthropicThinkingBudget(level string) int {
switch level {
case "low":
return 4096
case "medium":
return 10000
case "high":
return 32000
default:
return 10000
}
}