mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 08:15:29 +00:00
Co-authored-by: cyphercodes <cyphercodes@users.noreply.github.com>
290 lines
7.7 KiB
Go
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
|
|
}
|
|
}
|