// anthropic.go 实现 Anthropic Messages 兼容流式适配器。
package modeladapter
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf16"
"cursor/gen/agentv1"
runtimecore "cursor/internal/backend/agent/core"
"cursor/internal/netproxy"
)
// AnthropicAdapter 实现 Anthropic 兼容流式请求。
type AnthropicAdapter struct {
// client 负责发送 HTTP 请求。
client *http.Client
}
type anthropicToolAccumulator struct {
CallID string
Name string
Args strings.Builder
LastEmittedPath string
LastStreamContent string
LastCreatePlanSnapshot string
}
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]any `json:"input_schema"`
CacheControl map[string]any `json:"cache_control,omitempty"`
}
const (
anthropicThinkOpenTag = ""
anthropicThinkCloseTag = ""
anthropicBillingHeaderSystemText = "x-anthropic-billing-header: cc_version=2.1.179.61a; cc_entrypoint=cli; cch=37703;"
)
type anthropicContentPartKind string
const (
anthropicContentPartText anthropicContentPartKind = "text"
anthropicContentPartReasoning anthropicContentPartKind = "reasoning"
anthropicContentPartThinkingCompleted anthropicContentPartKind = "thinking_completed"
)
type anthropicContentPart struct {
Kind anthropicContentPartKind
Text string
}
// anthropicThinkTagParser 负责把 text_delta 里的 标签拆回 reasoning 流。
type anthropicThinkTagParser struct {
carry string
inThink bool
}
func (parser *anthropicThinkTagParser) Consume(text string) []anthropicContentPart {
if parser == nil || text == "" {
return nil
}
input := parser.carry + text
parser.carry = ""
parts := make([]anthropicContentPart, 0, 4)
for input != "" {
if parser.inThink {
closeIndex := strings.Index(input, anthropicThinkCloseTag)
if closeIndex >= 0 {
if closeIndex > 0 {
parts = append(parts, anthropicContentPart{
Kind: anthropicContentPartReasoning,
Text: input[:closeIndex],
})
}
parts = append(parts, anthropicContentPart{Kind: anthropicContentPartThinkingCompleted})
parser.inThink = false
input = input[closeIndex+len(anthropicThinkCloseTag):]
continue
}
carryLen := anthropicTrailingTagPrefixLength(input, anthropicThinkCloseTag)
if emitText := input[:len(input)-carryLen]; emitText != "" {
parts = append(parts, anthropicContentPart{
Kind: anthropicContentPartReasoning,
Text: emitText,
})
}
parser.carry = input[len(input)-carryLen:]
break
}
openIndex := strings.Index(input, anthropicThinkOpenTag)
if openIndex >= 0 {
if openIndex > 0 {
parts = append(parts, anthropicContentPart{
Kind: anthropicContentPartText,
Text: input[:openIndex],
})
}
parser.inThink = true
input = input[openIndex+len(anthropicThinkOpenTag):]
continue
}
carryLen := anthropicTrailingTagPrefixLength(input, anthropicThinkOpenTag)
if emitText := input[:len(input)-carryLen]; emitText != "" {
parts = append(parts, anthropicContentPart{
Kind: anthropicContentPartText,
Text: emitText,
})
}
parser.carry = input[len(input)-carryLen:]
break
}
return parts
}
func (parser *anthropicThinkTagParser) Flush() []anthropicContentPart {
if parser == nil || parser.carry == "" {
return nil
}
kind := anthropicContentPartText
if parser.inThink {
kind = anthropicContentPartReasoning
}
text := parser.carry
parser.carry = ""
return []anthropicContentPart{{
Kind: kind,
Text: text,
}}
}
// NewAnthropicAdapter 创建一个 Anthropic 兼容适配器。
func NewAnthropicAdapter() *AnthropicAdapter {
return &AnthropicAdapter{
client: netproxy.NewHTTPClient(0),
}
}
func anthropicEndpointURL(baseURL string) string {
base := strings.TrimRight(strings.TrimSpace(baseURL), "/")
if ProviderURLHasEndpoint(base, "/v1/messages", "/messages") {
return base
}
return base + "/v1/messages"
}
// shouldRelocateAnthropicImages 判断是否需要把图片块搬运到末条 user 消息。
//
// 官方 Anthropic 端点(api.anthropic.com)可正确处理任意位置的图片,保持原样;
// 其余第三方中转站默认启用搬运,规避「非末尾图片被丢弃」的转换问题。
func shouldRelocateAnthropicImages(baseURL string) bool {
base := strings.ToLower(strings.TrimSpace(baseURL))
if base == "" {
return false
}
return !strings.Contains(base, "api.anthropic.com")
}
// ApplyAnthropicCompatibleAuthHeaders 同时兼容 Anthropic 原生 x-api-key 和 Bearer token 代理。
func ApplyAnthropicCompatibleAuthHeaders(httpReq *http.Request, apiKey string) {
if httpReq == nil {
return
}
token := anthropicCompatibleAuthToken(apiKey)
if token == "" {
return
}
httpReq.Header.Set("x-api-key", token)
httpReq.Header.Set("Authorization", "Bearer "+token)
}
func anthropicCompatibleAuthToken(apiKey string) string {
token := strings.TrimSpace(apiKey)
if len(token) >= len("Bearer ") && strings.EqualFold(token[:len("Bearer ")], "Bearer ") {
token = strings.TrimSpace(token[len("Bearer "):])
}
return token
}
func anthropicProviderSystemBlocks(systemParts []string) []map[string]any {
blocks := []map[string]any{{
"type": "text",
"text": anthropicBillingHeaderSystemText,
}}
if len(systemParts) > 0 {
blocks = append(blocks, map[string]any{
"type": "text",
"text": strings.Join(systemParts, "\n\n"),
})
}
return blocks
}
// Stream 发送 Messages 流式请求,并解析统一模型事件。
func (adapter *AnthropicAdapter) Stream(ctx context.Context, req StreamRequest, sink func(ModelEvent) error) error {
baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
if baseURL == "" {
return fmt.Errorf("anthropic base url is empty")
}
apiKey := strings.TrimSpace(req.APIKey)
if apiKey == "" {
return fmt.Errorf("anthropic api key is empty")
}
modelID := strings.TrimSpace(req.ProviderModelID)
if modelID == "" {
modelID = strings.TrimSpace(req.ModelID)
}
if modelID == "" {
return fmt.Errorf("anthropic model id is empty")
}
startedAt := time.Now().UTC()
finishedAt := time.Time{}
requestURL := anthropicEndpointURL(baseURL)
body := cloneRequestBodyOverride(req.RequestBodyOverride)
if len(body) == 0 {
thinkingConfig := buildAnthropicThinkingConfig(req)
relocateImages := shouldRelocateAnthropicImages(baseURL)
stableMessageCount := anthropicStableProviderMessageCount(req.Messages, req.StableMessageCount, thinkingConfig != nil)
systemParts, messages, err := normalizeAnthropicProviderMessages(req.Messages, thinkingConfig != nil, relocateImages)
if err != nil {
return err
}
tools := make([]anthropicTool, 0, len(req.Tools))
for _, raw := range req.Tools {
var descriptor struct {
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]any `json:"parameters"`
} `json:"function"`
}
if err := json.Unmarshal(raw, &descriptor); err != nil {
finishedAt = time.Now().UTC()
draftBody := map[string]any{
"model": modelID,
"messages": messages,
"stream": true,
"max_tokens": req.MaxTokens,
"tool_raw_count": len(req.Tools),
}
draftBody["system"] = anthropicProviderSystemBlocks(systemParts)
recordLLMRequestArtifact(req, "anthropic", modelID, "POST", requestURL, draftBody)
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "anthropic", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
return err
}
tools = append(tools, anthropicTool{
Name: strings.TrimSpace(descriptor.Function.Name),
Description: strings.TrimSpace(descriptor.Function.Description),
InputSchema: descriptor.Function.Parameters,
})
}
body = map[string]any{
"model": modelID,
"messages": messages,
"stream": true,
"max_tokens": maxAnthropicTokens(req),
}
if len(tools) > 0 {
body["tools"] = tools
}
body["system"] = anthropicProviderSystemBlocks(systemParts)
frontier := buildAnthropicCacheFrontier(body, stableMessageCount)
req.RequestKnobs = annotateAnthropicRequestKnobs(req.RequestKnobs, body, frontier)
body = cloneRequestBodyOverride(body)
applyAnthropicCacheBreakpoints(body, frontier.BreakpointPositions)
frontier.BreakpointCount = len(frontier.BreakpointPositions)
}
// applyAnthropicThinkingConfig 在 override 块之外无条件调用,确保 RequestBodyOverride
// 路径与正常构造路径行为一致:disabled 时强制 thinking:{type:disabled} 并清理冲突字段,
// 非 disabled 时按 AnthropicThinkingEffort 写 adaptive 配置。与 openai.go 的
// applyOpenAIThinkingDisable 对称——后者也是无条件在两条路径之后调用。
applyAnthropicThinkingConfig(body, req)
if err := ApplyAnthropicExtraParams(body, req.AnthropicExtraParamsEnabled, req.AnthropicExtraParamsJSON); err != nil {
finishedAt = time.Now().UTC()
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "anthropic", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
return err
}
recordLLMRequestArtifact(req, "anthropic", modelID, "POST", requestURL, body)
payload, err := json.Marshal(body)
if err != nil {
finishedAt = time.Now().UTC()
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "anthropic", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
return err
}
streamCtx, streamIdle := newProviderStreamIdleWatchdog(ctx, req.ProviderStreamIdleTimeout)
defer streamIdle.Stop()
buildHTTPRequest := func(requestContext context.Context) (*http.Request, error) {
httpReq, err := http.NewRequestWithContext(requestContext, http.MethodPost, requestURL, bytes.NewReader(payload))
if err != nil {
return nil, err
}
ApplyAnthropicCompatibleAuthHeaders(httpReq, apiKey)
httpReq.Header.Set("anthropic-version", "2023-06-01")
httpReq.Header.Set("content-type", "application/json")
httpReq.Header.Set("User-Agent", AnthropicClaudeCodeUserAgent)
if err := ApplyCustomHeaders(httpReq, req.CustomHeadersEnabled, req.CustomHeadersJSON); err != nil {
return nil, err
}
return httpReq, nil
}
resp, err := doProviderRequestWithRetry(streamCtx, adapter.client, "anthropic", req.RequestID, req.ModelCallID, buildHTTPRequest)
if err != nil {
if idleErr := streamIdle.Err(); idleErr != nil {
err = idleErr
}
finishedAt = time.Now().UTC()
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "anthropic", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
return err
}
streamIdle.AttachBody(resp.Body)
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
err = buildHTTPStatusError("anthropic adapter", resp)
finishedAt = time.Now().UTC()
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "anthropic", modelID, startedAt, time.Time{}, finishedAt, "", 0, 0, 0, 0, err))
return err
}
type anthropicUsage struct {
InputTokens *int64 `json:"input_tokens,omitempty"`
OutputTokens *int64 `json:"output_tokens,omitempty"`
CacheCreationInputTokens *int64 `json:"cache_creation_input_tokens,omitempty"`
CacheReadInputTokens *int64 `json:"cache_read_input_tokens,omitempty"`
}
type contentBlock struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
Text string `json:"text"`
Input any `json:"input"`
}
type anthropicEvent struct {
Type string `json:"type"`
RequestID string `json:"request_id"`
Index int `json:"index"`
ContentBlock contentBlock `json:"content_block"`
Message struct {
Model string `json:"model"`
Usage anthropicUsage `json:"usage"`
} `json:"message"`
Usage anthropicUsage `json:"usage"`
Delta struct {
Type string `json:"type"`
Text string `json:"text"`
Thinking string `json:"thinking"`
PartialJSON string `json:"partial_json"`
Signature string `json:"signature"`
StopReason string `json:"stop_reason"`
} `json:"delta"`
Error *struct {
Type string `json:"type"`
Code string `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
}
toolBlocks := make(map[int]*anthropicToolAccumulator)
thinkingStarted := time.Time{}
currentThinkingSignature := ""
thinkParser := &anthropicThinkTagParser{}
currentModel := modelID
inputTokens := int64(0)
outputTokens := int64(0)
cacheReadTokens := int64(0)
cacheWriteTokens := int64(0)
usagePresent := false
cacheReadPresent := false
cacheWritePresent := false
finishReason := "message_stop"
firstEventAt := time.Time{}
fail := func(streamErr error) error {
finishedAt = time.Now().UTC()
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "anthropic", currentModel, startedAt, firstEventAt, finishedAt, finishReason, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, streamErr))
return streamErr
}
flushThinkingCompleted := func() error {
if thinkingStarted.IsZero() {
return nil
}
duration := int32(time.Since(thinkingStarted).Milliseconds())
if duration < 0 {
duration = 0
}
thinkingSignature := strings.TrimSpace(currentThinkingSignature)
thinkingSignatureSource := ""
if thinkingSignature != "" {
thinkingSignatureSource = ReasoningSignatureSourceAnthropic
}
if err := sink(ModelEvent{
Kind: ModelEventKindThinkingCompleted,
OccurredAt: time.Now().UTC(),
Provider: "anthropic",
Model: currentModel,
ThinkingDurationMS: duration,
ThinkingSignature: thinkingSignature,
ThinkingSignatureSource: thinkingSignatureSource,
}); err != nil {
return err
}
thinkingStarted = time.Time{}
currentThinkingSignature = ""
return nil
}
emitTextDelta := func(text string) error {
if text == "" {
return nil
}
streamIdle.MarkEffectiveContent()
if err := flushThinkingCompleted(); err != nil {
return err
}
return sink(ModelEvent{
Kind: ModelEventKindTextDelta,
OccurredAt: time.Now().UTC(),
Provider: "anthropic",
Model: currentModel,
Text: text,
})
}
emitThinkingDelta := func(reasoning string) error {
if reasoning == "" {
return nil
}
streamIdle.MarkEffectiveContent()
if thinkingStarted.IsZero() {
thinkingStarted = time.Now()
}
return sink(ModelEvent{
Kind: ModelEventKindThinkingDelta,
OccurredAt: time.Now().UTC(),
Provider: "anthropic",
Model: currentModel,
Text: reasoning,
ThinkingStyle: agentv1.ThinkingStyle_THINKING_STYLE_DEFAULT,
})
}
emitTaggedTextParts := func(parts []anthropicContentPart) error {
for _, part := range parts {
switch part.Kind {
case anthropicContentPartText:
if err := emitTextDelta(part.Text); err != nil {
return err
}
case anthropicContentPartReasoning:
if err := emitThinkingDelta(part.Text); err != nil {
return err
}
case anthropicContentPartThinkingCompleted:
if err := flushThinkingCompleted(); err != nil {
return err
}
}
}
return nil
}
flushTaggedTextTail := func() error {
return emitTaggedTextParts(thinkParser.Flush())
}
applyUsage := func(usage anthropicUsage) {
if usage.InputTokens != nil {
usagePresent = true
inputTokens = maxInt64(*usage.InputTokens, 0)
}
if usage.OutputTokens != nil {
usagePresent = true
outputTokens = maxInt64(*usage.OutputTokens, 0)
}
if usage.CacheReadInputTokens != nil {
usagePresent = true
cacheReadPresent = true
cacheReadTokens = maxInt64(*usage.CacheReadInputTokens, 0)
}
if usage.CacheCreationInputTokens != nil {
usagePresent = true
cacheWritePresent = true
cacheWriteTokens = maxInt64(*usage.CacheCreationInputTokens, 0)
}
}
errorFromEvent := func(event anthropicEvent) error {
finishReason = "error"
if event.Error != nil {
parts := make([]string, 0, 4)
if value := strings.TrimSpace(event.Error.Type); value != "" {
parts = append(parts, "type="+value)
}
if value := strings.TrimSpace(event.Error.Code); value != "" {
parts = append(parts, "code="+value)
}
if value := strings.TrimSpace(event.RequestID); value != "" {
parts = append(parts, "request_id="+value)
}
if message := strings.TrimSpace(event.Error.Message); message != "" {
if len(parts) > 0 {
return fmt.Errorf("anthropic provider error %s: %s", strings.Join(parts, " "), message)
}
return fmt.Errorf("anthropic provider error: %s", message)
}
if len(parts) > 0 {
return fmt.Errorf("anthropic provider error %s", strings.Join(parts, " "))
}
}
return fmt.Errorf("anthropic provider error")
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
currentEvent := ""
dataLines := make([]string, 0, 2)
flush := func() error {
if currentEvent == "" || len(dataLines) == 0 {
dataLines = dataLines[:0]
return nil
}
payloadLine := strings.Join(dataLines, "\n")
dataLines = dataLines[:0]
if strings.TrimSpace(payloadLine) == "[DONE]" {
return nil
}
var event anthropicEvent
if err := json.Unmarshal([]byte(payloadLine), &event); err != nil {
return err
}
if currentEvent == "error" || strings.TrimSpace(event.Type) == "error" {
return errorFromEvent(event)
}
switch currentEvent {
case "message_start":
if strings.TrimSpace(event.Message.Model) != "" {
currentModel = strings.TrimSpace(event.Message.Model)
}
applyUsage(event.Message.Usage)
case "content_block_start":
if strings.TrimSpace(event.ContentBlock.Type) == "tool_use" {
if err := flushTaggedTextTail(); err != nil {
return err
}
if err := flushThinkingCompleted(); err != nil {
return err
}
accumulator := &anthropicToolAccumulator{
CallID: namespaceToolCallID(req.ModelCallID, event.ContentBlock.ID),
Name: strings.TrimSpace(event.ContentBlock.Name),
}
if !isEmptyAnthropicToolInput(event.ContentBlock.Input) {
if encoded, err := json.Marshal(event.ContentBlock.Input); err == nil && string(encoded) != "null" {
_, _ = accumulator.Args.Write(encoded)
}
}
toolBlocks[event.Index] = accumulator
streamIdle.MarkEffectiveContent()
if err := emitAnthropicToolProgress(sink, currentModel, accumulator, ""); err != nil {
return err
}
}
case "content_block_delta":
if event.Delta.Type == "text_delta" && event.Delta.Text != "" {
if err := emitTaggedTextParts(thinkParser.Consume(event.Delta.Text)); err != nil {
return err
}
}
if event.Delta.Type == "thinking_delta" && event.Delta.Thinking != "" {
if err := emitThinkingDelta(event.Delta.Thinking); err != nil {
return err
}
}
if event.Delta.Signature != "" {
currentThinkingSignature = strings.TrimSpace(event.Delta.Signature)
}
if event.Delta.Type == "input_json_delta" {
accumulator := toolBlocks[event.Index]
if accumulator != nil && event.Delta.PartialJSON != "" {
_, _ = accumulator.Args.WriteString(event.Delta.PartialJSON)
streamIdle.MarkEffectiveContent()
if err := emitAnthropicToolProgress(sink, currentModel, accumulator, event.Delta.PartialJSON); err != nil {
return err
}
}
}
case "content_block_stop":
accumulator := toolBlocks[event.Index]
if accumulator != nil {
argsJSON, err := completedAnthropicToolArgsJSON(accumulator)
if err != nil {
delete(toolBlocks, event.Index)
return err
}
if err := emitAnthropicToolProgress(sink, currentModel, accumulator, ""); err != nil {
return err
}
streamIdle.MarkEffectiveContent()
if err := sink(ModelEvent{
Kind: ModelEventKindToolLikeCompleted,
OccurredAt: time.Now().UTC(),
Provider: "anthropic",
Model: currentModel,
ToolInvocation: &runtimecore.ToolInvocation{
CallID: accumulator.CallID,
ToolName: accumulator.Name,
ArgsJSON: argsJSON,
},
}); err != nil {
return err
}
delete(toolBlocks, event.Index)
return nil
}
if err := flushTaggedTextTail(); err != nil {
return err
}
if err := flushThinkingCompleted(); err != nil {
return err
}
case "message_delta":
applyUsage(event.Usage)
if strings.TrimSpace(event.Delta.StopReason) != "" {
finishReason = strings.TrimSpace(event.Delta.StopReason)
}
// 当前 MVP 阶段只在 message_stop 时统一收口,不在这里重复发 turn finished。
return nil
case "message_stop":
if err := flushTaggedTextTail(); err != nil {
return err
}
if err := flushThinkingCompleted(); err != nil {
return err
}
if err := sink(ModelEvent{
Kind: ModelEventKindTurnFinished,
OccurredAt: time.Now().UTC(),
Provider: "anthropic",
Model: currentModel,
InputTokens: inputTokens,
OutputTokens: outputTokens,
CacheReadTokens: cacheReadTokens,
CacheWriteTokens: cacheWriteTokens,
UsagePresent: usagePresent,
CacheReadPresent: cacheReadPresent,
CacheWritePresent: cacheWritePresent,
FinishReason: finishReason,
}); err != nil {
return err
}
}
return nil
}
for scanner.Scan() {
rawLine := scanner.Text()
_, _ = appendLLMResponseArtifact(req, rawLine+"\n")
line := strings.TrimSpace(rawLine)
if line == "" {
if err := flush(); err != nil {
return fail(err)
}
currentEvent = ""
continue
}
if firstEventAt.IsZero() {
firstEventAt = time.Now().UTC()
}
if strings.HasPrefix(line, "event:") {
currentEvent = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
continue
}
if strings.HasPrefix(line, "data:") {
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
}
}
if err := flush(); err != nil {
return fail(err)
}
if err := scanner.Err(); err != nil {
if idleErr := streamIdle.Err(); idleErr != nil {
return fail(idleErr)
}
return fail(err)
}
if err := flushTaggedTextTail(); err != nil {
return fail(err)
}
if err := flushThinkingCompleted(); err != nil {
return fail(err)
}
finishedAt = time.Now().UTC()
recordLLMSummaryArtifact(req, buildLLMSummaryPayload(req, "anthropic", currentModel, startedAt, firstEventAt, finishedAt, finishReason, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, nil))
return nil
}
func anthropicTrailingTagPrefixLength(text string, tag string) int {
maxLen := len(text)
if len(tag)-1 < maxLen {
maxLen = len(tag) - 1
}
for size := maxLen; size > 0; size-- {
if strings.HasSuffix(text, tag[:size]) {
return size
}
}
return 0
}
func anthropicEphemeralCacheControl() map[string]any {
return map[string]any{"type": "ephemeral"}
}
type anthropicCacheFrontier struct {
CanonicalBodyHash string `json:"canonical_body_hash,omitempty"`
FrontierHash string `json:"frontier_hash,omitempty"`
FrontierPath string `json:"frontier_path,omitempty"`
BreakpointPositions []string `json:"breakpoint_positions,omitempty"`
BreakpointCount int `json:"breakpoint_count,omitempty"`
ExpectedCacheRead bool `json:"expected_cache_read,omitempty"`
PreviousFrontierMatched bool `json:"previous_frontier_matched,omitempty"`
}
func buildAnthropicCacheFrontier(canonicalBody map[string]any, stableMessageCount int) anthropicCacheFrontier {
frontier := anthropicCacheFrontier{
CanonicalBodyHash: anthropicCanonicalHash(canonicalBody),
BreakpointPositions: anthropicCacheBreakpointPositions(canonicalBody, stableMessageCount),
}
if len(frontier.BreakpointPositions) > 0 {
frontier.FrontierPath = frontier.BreakpointPositions[len(frontier.BreakpointPositions)-1]
frontier.FrontierHash = anthropicCanonicalPrefixHash(canonicalBody, frontier.FrontierPath)
}
frontier.BreakpointCount = len(frontier.BreakpointPositions)
return frontier
}
func anthropicCacheBreakpointPositions(body map[string]any, stableMessageCount int) []string {
positions := make([]string, 0, 4)
if tools, ok := body["tools"].([]anthropicTool); ok && len(tools) > 0 {
positions = append(positions, fmt.Sprintf("tools[%d]", len(tools)-1))
} else if tools, ok := body["tools"].([]any); ok && len(tools) > 0 {
positions = append(positions, fmt.Sprintf("tools[%d]", len(tools)-1))
}
if system, ok := body["system"].([]map[string]any); ok && len(system) > 0 {
positions = append(positions, fmt.Sprintf("system[%d]", len(system)-1))
} else if system, ok := body["system"].([]any); ok && len(system) > 0 {
positions = append(positions, fmt.Sprintf("system[%d]", len(system)-1))
}
if stableMessageCount > 0 {
if path := anthropicMessageCacheBreakpointPath(body, stableMessageCount); path != "" {
positions = append(positions, path)
}
}
if path := anthropicMessageCacheBreakpointPath(body, anthropicBodyMessageCount(body)); path != "" {
positions = append(positions, path)
}
return dedupeAnthropicCacheBreakpointPositions(positions)
}
func dedupeAnthropicCacheBreakpointPositions(items []string) []string {
seen := make(map[string]struct{}, len(items))
out := make([]string, 0, len(items))
for _, item := range items {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if _, ok := seen[item]; ok {
continue
}
seen[item] = struct{}{}
out = append(out, item)
}
return out
}
func anthropicBodyMessageCount(body map[string]any) int {
messages, ok := body["messages"].([]anthropicMessage)
if ok {
return len(messages)
}
genericMessages, ok := body["messages"].([]any)
if ok {
return len(genericMessages)
}
return 0
}
func anthropicMessageCacheBreakpointPath(body map[string]any, messageCount int) string {
messages, ok := anthropicMessagesFromBody(body)
if !ok || len(messages) == 0 || messageCount <= 0 {
return ""
}
if messageCount > len(messages) {
messageCount = len(messages)
}
for messageIndex := messageCount - 1; messageIndex >= 0; messageIndex-- {
message := messages[messageIndex]
for blockIndex := len(message.Content) - 1; blockIndex >= 0; blockIndex-- {
if isAnthropicCacheableBlock(message.Content[blockIndex]) {
return fmt.Sprintf("messages[%d].content[%d]", messageIndex, blockIndex)
}
}
}
return ""
}
func anthropicMessagesFromBody(body map[string]any) ([]anthropicMessage, bool) {
messages, ok := body["messages"].([]anthropicMessage)
if ok {
return messages, true
}
genericMessages, ok := body["messages"].([]any)
if !ok {
return nil, false
}
messages = make([]anthropicMessage, 0, len(genericMessages))
for _, item := range genericMessages {
messageMap, ok := item.(map[string]any)
if !ok {
return nil, false
}
contentItems, ok := messageMap["content"].([]any)
if !ok {
return nil, false
}
content := make([]map[string]any, 0, len(contentItems))
for _, contentItem := range contentItems {
block, ok := contentItem.(map[string]any)
if !ok {
return nil, false
}
content = append(content, block)
}
messages = append(messages, anthropicMessage{
Role: strings.TrimSpace(anthropicStringValue(messageMap["role"])),
Content: content,
})
}
return messages, true
}
func applyAnthropicCacheBreakpoints(body map[string]any, positions []string) {
for _, position := range positions {
applyAnthropicCacheBreakpoint(body, position)
}
}
func applyAnthropicCacheBreakpoint(body map[string]any, position string) {
position = strings.TrimSpace(position)
if strings.HasPrefix(position, "tools[") {
index, ok := parseAnthropicBracketIndex(position, "tools")
if !ok {
return
}
tools, ok := body["tools"].([]any)
if !ok || index < 0 || index >= len(tools) {
return
}
tool, ok := tools[index].(map[string]any)
if !ok {
return
}
tool["cache_control"] = anthropicEphemeralCacheControl()
return
}
if strings.HasPrefix(position, "system[") {
index, ok := parseAnthropicBracketIndex(position, "system")
if !ok {
return
}
system, ok := body["system"].([]any)
if !ok || index < 0 || index >= len(system) {
return
}
block, ok := system[index].(map[string]any)
if !ok {
return
}
block["cache_control"] = anthropicEphemeralCacheControl()
return
}
messageIndex, blockIndex, ok := parseAnthropicMessageBlockPath(position)
if !ok {
return
}
messages, ok := body["messages"].([]any)
if !ok || messageIndex < 0 || messageIndex >= len(messages) {
return
}
message, ok := messages[messageIndex].(map[string]any)
if !ok {
return
}
content, ok := message["content"].([]any)
if !ok || blockIndex < 0 || blockIndex >= len(content) {
return
}
block, ok := content[blockIndex].(map[string]any)
if !ok {
return
}
block["cache_control"] = anthropicEphemeralCacheControl()
}
func parseAnthropicBracketIndex(position string, prefix string) (int, bool) {
start := prefix + "["
if !strings.HasPrefix(position, start) || !strings.HasSuffix(position, "]") {
return 0, false
}
value, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(position, start), "]"))
return value, err == nil
}
func parseAnthropicMessageBlockPath(position string) (int, int, bool) {
const messagePrefix = "messages["
const contentPrefix = "].content["
if !strings.HasPrefix(position, messagePrefix) || !strings.HasSuffix(position, "]") {
return 0, 0, false
}
rest := strings.TrimPrefix(position, messagePrefix)
parts := strings.Split(rest, contentPrefix)
if len(parts) != 2 {
return 0, 0, false
}
messageIndex, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
blockIndex, err := strconv.Atoi(strings.TrimSuffix(parts[1], "]"))
if err != nil {
return 0, 0, false
}
return messageIndex, blockIndex, true
}
func annotateAnthropicRequestKnobs(requestKnobs map[string]any, canonicalBody map[string]any, frontier anthropicCacheFrontier) map[string]any {
if len(requestKnobs) == 0 {
requestKnobs = map[string]any{}
}
previousHash := anthropicPreviousFrontierHash(requestKnobs)
previousPath := anthropicPreviousFrontierPath(requestKnobs)
currentPreviousFrontierHash := ""
if previousPath != "" {
currentPreviousFrontierHash = anthropicCanonicalPrefixHash(canonicalBody, previousPath)
}
frontier.PreviousFrontierMatched = previousHash != "" && currentPreviousFrontierHash == previousHash
frontier.ExpectedCacheRead = frontier.PreviousFrontierMatched && frontier.BreakpointCount > 0
requestKnobs["cache_frontier"] = map[string]any{
"canonical_body_hash": frontier.CanonicalBodyHash,
"frontier_hash": frontier.FrontierHash,
"frontier_path": frontier.FrontierPath,
"breakpoint_positions": append([]string(nil), frontier.BreakpointPositions...),
"breakpoint_count": frontier.BreakpointCount,
"expected_cache_read": frontier.ExpectedCacheRead,
"previous_frontier_matched": frontier.PreviousFrontierMatched,
"previous_frontier_hash": previousHash,
"previous_frontier_path": previousPath,
"current_previous_prefix_hash": currentPreviousFrontierHash,
}
return requestKnobs
}
func anthropicPreviousFrontierHash(requestKnobs map[string]any) string {
if len(requestKnobs) == 0 {
return ""
}
value := strings.TrimSpace(anthropicStringValue(requestKnobs["previous_cache_frontier_hash"]))
if value != "" {
return value
}
previous, ok := requestKnobs["previous_cache_frontier"].(map[string]any)
if !ok {
return ""
}
return strings.TrimSpace(anthropicStringValue(previous["frontier_hash"]))
}
func anthropicPreviousFrontierPath(requestKnobs map[string]any) string {
if len(requestKnobs) == 0 {
return ""
}
previous, ok := requestKnobs["previous_cache_frontier"].(map[string]any)
if !ok {
return ""
}
return strings.TrimSpace(anthropicStringValue(previous["frontier_path"]))
}
func anthropicStringValue(value any) string {
switch typed := value.(type) {
case string:
return typed
case fmt.Stringer:
return typed.String()
default:
return ""
}
}
func anthropicCanonicalHash(value any) string {
payload, err := json.Marshal(value)
if err != nil {
return ""
}
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])[:16]
}
func anthropicCanonicalPrefixHash(body map[string]any, frontierPath string) string {
prefix := cloneRequestBodyOverride(body)
if strings.TrimSpace(frontierPath) == "" {
return anthropicCanonicalHash(prefix)
}
trimAnthropicBodyAfterFrontier(prefix, frontierPath)
return anthropicCanonicalHash(prefix)
}
func trimAnthropicBodyAfterFrontier(body map[string]any, frontierPath string) {
if strings.HasPrefix(frontierPath, "tools[") {
return
}
if strings.HasPrefix(frontierPath, "system[") {
delete(body, "messages")
return
}
messageIndex, blockIndex, ok := parseAnthropicMessageBlockPath(frontierPath)
if !ok {
return
}
messages, ok := body["messages"].([]any)
if !ok || messageIndex < 0 || messageIndex >= len(messages) {
return
}
messages = messages[:messageIndex+1]
message, ok := messages[messageIndex].(map[string]any)
if ok {
if content, ok := message["content"].([]any); ok && blockIndex >= 0 && blockIndex < len(content) {
message["content"] = content[:blockIndex+1]
}
}
body["messages"] = messages
}
type anthropicMessage struct {
Role string `json:"role"`
Content []map[string]any `json:"content"`
}
func anthropicStableProviderMessageCount(input []Message, stableReplayMessageCount int, thinkingEnabled bool) int {
if len(input) == 0 || stableReplayMessageCount <= 0 {
return 0
}
stableReplayMessages := make([]Message, 0, stableReplayMessageCount)
for _, message := range input {
if strings.TrimSpace(message.Role) == "system" {
continue
}
if len(stableReplayMessages) >= stableReplayMessageCount {
break
}
stableReplayMessages = append(stableReplayMessages, message)
}
if len(stableReplayMessages) == 0 {
return 0
}
_, messages, err := normalizeAnthropicProviderMessages(stableReplayMessages, thinkingEnabled, false)
if err != nil {
return 0
}
return len(messages)
}
func applyAnthropicMessageCacheBreakpoints(messages []anthropicMessage, stableMessageCountOverride ...int) {
if len(stableMessageCountOverride) == 0 || len(messages) == 0 {
return
}
stableMessageCount := stableMessageCountOverride[0]
if stableMessageCount > 0 {
applyAnthropicMessageCacheBreakpointAt(messages, stableMessageCount)
}
if len(messages) > stableMessageCount {
applyAnthropicMessageCacheBreakpointAt(messages, len(messages))
}
}
func applyAnthropicMessageCacheBreakpointAt(messages []anthropicMessage, messageCount int) {
if len(messages) == 0 || messageCount <= 0 {
return
}
if messageCount > len(messages) {
messageCount = len(messages)
}
for messageIndex := messageCount - 1; messageIndex >= 0; messageIndex-- {
message := messages[messageIndex]
for blockIndex := len(message.Content) - 1; blockIndex >= 0; blockIndex-- {
block := message.Content[blockIndex]
if !isAnthropicCacheableBlock(block) {
continue
}
block["cache_control"] = anthropicEphemeralCacheControl()
return
}
}
}
func isAnthropicCacheableBlock(block map[string]any) bool {
if len(block) == 0 {
return false
}
switch strings.TrimSpace(anthropicStringField(block, "type")) {
case contentPartTypeText:
return strings.TrimSpace(anthropicStringField(block, "text")) != ""
case "tool_result":
return strings.TrimSpace(anthropicStringField(block, "content")) != ""
case "tool_use":
return strings.TrimSpace(anthropicStringField(block, "id")) != "" && strings.TrimSpace(anthropicStringField(block, "name")) != ""
default:
return false
}
}
// anthropicThinkingCarrier 记录请求内最近一个有 reasoning+signature 的 assistant 轮次。
// thinking 模式下上游要求每个 assistant 轮次都回传 thinking 块;当某轮次(如 DeepSeek
// adaptive thinking 跳过思考的 tool-call 轮次)没有 reasoning 时,用 carrier 的
// thinking+signature 兜底,避免上游 "thinking must be passed back" 400。
type anthropicThinkingCarrier struct {
reasoning string
signature string
}
func normalizeAnthropicProviderMessages(input []Message, thinkingEnabled bool, relocateImages bool) ([]string, []anthropicMessage, error) {
systemParts := make([]string, 0, len(input))
messages := make([]anthropicMessage, 0, len(input))
pendingToolResults := make([]map[string]any, 0, 2)
var thinkingCarrier *anthropicThinkingCarrier
flushToolResults := func() {
if len(pendingToolResults) == 0 {
return
}
messages = append(messages, anthropicMessage{
Role: "user",
Content: append([]map[string]any(nil), pendingToolResults...),
})
pendingToolResults = pendingToolResults[:0]
}
for _, message := range input {
role := strings.TrimSpace(message.Role)
switch role {
case "system":
if hasImageContentParts(message.ContentParts) {
return nil, nil, fmt.Errorf("anthropic system message does not support image content")
}
content := message.Content
if strings.TrimSpace(content) == "" && len(message.ContentParts) > 0 {
content = collapseTextContentParts(message.ContentParts)
}
if strings.TrimSpace(content) != "" {
systemParts = append(systemParts, content)
}
case "tool":
toolUseID := providerToolCallID(message.ToolCallID)
if toolUseID == "" {
return nil, nil, fmt.Errorf("anthropic tool message requires tool_call_id")
}
pendingToolResults = append(pendingToolResults, map[string]any{
"type": "tool_result",
"tool_use_id": toolUseID,
"content": message.Content,
})
case "user", "assistant":
flushToolResults()
if thinkingEnabled && role == "assistant" {
if reasoning := strings.TrimSpace(message.ReasoningContent); reasoning != "" {
thinkingCarrier = &anthropicThinkingCarrier{
reasoning: reasoning,
signature: anthropicThinkingSignature(message),
}
}
}
contentBlocks, err := anthropicProviderContentBlocks(message, thinkingEnabled, thinkingCarrier)
if err != nil {
return nil, nil, err
}
blocks := make([]map[string]any, 0, len(contentBlocks)+len(message.ToolCalls))
blocks = append(blocks, contentBlocks...)
if role == "assistant" {
for _, toolCall := range message.ToolCalls {
inputJSON, err := decodeAnthropicToolInput(toolCall.Function.Arguments)
if err != nil {
return nil, nil, err
}
blocks = append(blocks, map[string]any{
"type": "tool_use",
"id": providerToolCallID(toolCall.ID),
"name": strings.TrimSpace(toolCall.Function.Name),
"input": inputJSON,
})
}
}
if len(blocks) == 0 {
continue
}
if role == "assistant" && mergeAnthropicAssistantToolUseWithPrevious(&messages, message, blocks) {
continue
}
messages = append(messages, anthropicMessage{
Role: role,
Content: blocks,
})
default:
flushToolResults()
if strings.TrimSpace(message.Content) == "" {
continue
}
messages = append(messages, anthropicMessage{
Role: "user",
Content: []map[string]any{{
"type": "text",
"text": message.Content,
}},
})
}
}
flushToolResults()
if relocateImages {
messages = relocateAnthropicImagesToLastUserMessage(messages)
}
return systemParts, messages, nil
}
// relocateAnthropicImagesToLastUserMessage 把所有 user 消息里的 image 块搬运到最后一条 user 消息的末尾。
//
// 背景:部分第三方中转站(如 Bedrock 代理)在 Anthropic→上游 的消息转换中,
// 会丢弃「后面还跟着大量文本/消息」的非末尾图片块。将图片统一移动到末条 user 消息
// 可规避该问题,同时保留图片信息本身。
//
// 数据流演变:
//
// [user_info] [query + IMG] [reminder] [reminder] [current_request]
// → [user_info] [query] [reminder] [reminder] [current_request + IMG]
//
// 搬运后若某条 user 消息 content 变空,则丢弃该消息,避免 Anthropic 拒绝空内容消息。
func relocateAnthropicImagesToLastUserMessage(messages []anthropicMessage) []anthropicMessage {
lastUserIndex := -1
for index := len(messages) - 1; index >= 0; index-- {
if strings.TrimSpace(messages[index].Role) == "user" {
lastUserIndex = index
break
}
}
if lastUserIndex < 0 {
return messages
}
relocated := make([]map[string]any, 0, 2)
for index := 0; index < len(messages); index++ {
if index == lastUserIndex || strings.TrimSpace(messages[index].Role) != "user" {
continue
}
kept := make([]map[string]any, 0, len(messages[index].Content))
for _, block := range messages[index].Content {
if isAnthropicImageBlock(block) {
relocated = append(relocated, block)
continue
}
kept = append(kept, block)
}
messages[index].Content = kept
}
if len(relocated) == 0 {
return messages
}
messages[lastUserIndex].Content = append(messages[lastUserIndex].Content, relocated...)
compacted := make([]anthropicMessage, 0, len(messages))
for index, message := range messages {
if index != lastUserIndex && strings.TrimSpace(message.Role) == "user" && len(message.Content) == 0 {
continue
}
compacted = append(compacted, message)
}
return compacted
}
func isAnthropicImageBlock(block map[string]any) bool {
return strings.TrimSpace(anthropicStringField(block, "type")) == "image"
}
func anthropicProviderContentBlocks(message Message, thinkingEnabled bool, carrier *anthropicThinkingCarrier) ([]map[string]any, error) {
blocks, err := anthropicContentBlocks(message)
if err != nil {
return nil, err
}
if !shouldIncludeAnthropicThinkingBlock(message, thinkingEnabled) {
return blocks, nil
}
reasoning := strings.TrimSpace(message.ReasoningContent)
signature := anthropicThinkingSignature(message)
if reasoning == "" && carrier != nil {
reasoning = carrier.reasoning
signature = carrier.signature
}
thinkingBlock := map[string]any{
"type": "thinking",
"thinking": reasoning,
}
if signature != "" {
thinkingBlock["signature"] = signature
}
return append([]map[string]any{thinkingBlock}, blocks...), nil
}
func mergeAnthropicAssistantToolUseWithPrevious(messages *[]anthropicMessage, message Message, blocks []map[string]any) bool {
if messages == nil || len(*messages) == 0 {
return false
}
if strings.TrimSpace(message.Role) != "assistant" || len(message.ToolCalls) == 0 {
return false
}
if strings.TrimSpace(message.Content) != "" || len(message.ContentParts) > 0 {
return false
}
reasoning := strings.TrimSpace(message.ReasoningContent)
signature := anthropicThinkingSignature(message)
if reasoning == "" {
return false
}
last := &(*messages)[len(*messages)-1]
if strings.TrimSpace(last.Role) != "assistant" || !anthropicMessageHasLeadingThinking(*last, reasoning, signature) {
return false
}
toolUseBlocks := make([]map[string]any, 0, len(blocks))
for _, block := range blocks {
if strings.TrimSpace(anthropicStringField(block, "type")) == "thinking" {
continue
}
toolUseBlocks = append(toolUseBlocks, block)
}
if len(toolUseBlocks) == 0 {
return false
}
last.Content = append(last.Content, toolUseBlocks...)
return true
}
func anthropicMessageHasLeadingThinking(message anthropicMessage, reasoning string, signature string) bool {
if len(message.Content) == 0 {
return false
}
first := message.Content[0]
if strings.TrimSpace(anthropicStringField(first, "type")) != "thinking" {
return false
}
return strings.TrimSpace(anthropicStringField(first, "thinking")) == reasoning && strings.TrimSpace(anthropicStringField(first, "signature")) == signature
}
func anthropicThinkingSignature(message Message) string {
signature := strings.TrimSpace(message.ReasoningSignature)
if signature == "" {
return ""
}
source := strings.TrimSpace(message.ReasoningSignatureSource)
if source == "" || source == ReasoningSignatureSourceAnthropic {
return signature
}
return ""
}
func anthropicStringField(payload map[string]any, key string) string {
if len(payload) == 0 || strings.TrimSpace(key) == "" {
return ""
}
value, ok := payload[key]
if !ok {
return ""
}
text, ok := value.(string)
if !ok {
return ""
}
return text
}
func shouldIncludeAnthropicThinkingBlock(message Message, thinkingEnabled bool) bool {
if !thinkingEnabled {
return false
}
if strings.TrimSpace(message.Role) != "assistant" {
return false
}
return true
}
func decodeAnthropicToolInput(arguments string) (any, error) {
trimmed := strings.TrimSpace(arguments)
if trimmed == "" {
return map[string]any{}, nil
}
var value any
if err := json.Unmarshal([]byte(trimmed), &value); err != nil {
return nil, fmt.Errorf("decode anthropic tool input failed: %w", err)
}
return value, nil
}
func completedAnthropicToolArgsJSON(accumulator *anthropicToolAccumulator) ([]byte, error) {
if accumulator == nil {
return []byte("{}"), nil
}
trimmed := strings.TrimSpace(accumulator.Args.String())
if trimmed == "" {
return []byte("{}"), nil
}
var value map[string]any
if err := json.Unmarshal([]byte(trimmed), &value); err != nil {
toolName := strings.TrimSpace(accumulator.Name)
if toolName == "" {
toolName = "tool"
}
return nil, fmt.Errorf("anthropic returned incomplete or malformed tool input for %s: %w", toolName, err)
}
if value == nil {
toolName := strings.TrimSpace(accumulator.Name)
if toolName == "" {
toolName = "tool"
}
return nil, fmt.Errorf("anthropic returned non-object tool input for %s", toolName)
}
return []byte(trimmed), nil
}
func buildAnthropicThinkingConfig(req StreamRequest) map[string]any {
if normalizeRuntimeThinkingEffort(req.ThinkingEffort) == "disabled" {
return map[string]any{
"type": "disabled",
}
}
if strings.TrimSpace(req.AnthropicThinkingEffort) == "" {
return nil
}
return map[string]any{
"type": "adaptive",
"display": "summarized",
}
}
// applyAnthropicThinkingConfig 在请求体构造完成后(含 RequestBodyOverride 路径)无条件调用,
// 与 openai.go 的 applyOpenAIThinkingDisable 对称。它把 thinking 配置写入 body 并在 disabled
// 时清理与之冲突的字段,确保两条构造路径行为一致:
// - disabled: 强制 thinking:{type:"disabled"},删除 output_config / 残留 thinking adaptive 配置,
// 记录 thinking_disabled_provider_param=thinking.type knob
// - adaptive: 按 AnthropicThinkingEffort 写 thinking:{type:adaptive,display:summarized} + output_config
//
// 在 override 路径下,上层若已在 override body 里塞了 thinking/output_config,disabled 时会被正确覆盖。
func applyAnthropicThinkingConfig(body map[string]any, req StreamRequest) {
if len(body) == 0 {
return
}
if normalizeRuntimeThinkingEffort(req.ThinkingEffort) != "disabled" {
if strings.TrimSpace(req.AnthropicThinkingEffort) == "" {
return
}
body["thinking"] = map[string]any{
"type": "adaptive",
"display": "summarized",
}
body["output_config"] = buildAnthropicOutputConfig(req)
return
}
body["thinking"] = map[string]any{"type": "disabled"}
delete(body, "output_config")
setRequestKnob(req, "thinking_disabled_provider_param", "thinking.type")
}
func buildAnthropicOutputConfig(req StreamRequest) map[string]any {
return map[string]any{
"effort": anthropicThinkingEffort(req),
}
}
func anthropicThinkingEffort(req StreamRequest) string {
switch strings.ToLower(strings.TrimSpace(req.AnthropicThinkingEffort)) {
case "low", "medium", "high", "xhigh", "max":
return strings.ToLower(strings.TrimSpace(req.AnthropicThinkingEffort))
default:
return "xhigh"
}
}
func emitAnthropicToolProgress(
sink func(ModelEvent) error,
model string,
accumulator *anthropicToolAccumulator,
argsTextDelta string,
) error {
if accumulator == nil {
return nil
}
toolName := strings.TrimSpace(accumulator.Name)
if toolName == "CreatePlan" {
return emitCreatePlanToolProgress(
sink,
"anthropic",
model,
accumulator.CallID,
accumulator.Args.String(),
argsTextDelta,
&accumulator.LastCreatePlanSnapshot,
)
}
if toolName != "Write" && toolName != "PatchEdit" {
return nil
}
rawArgs := accumulator.Args.String()
path, pathFound, pathComplete := extractJSONStringFieldPrefix(rawArgs, "path")
if !pathFound {
path, pathFound, pathComplete = extractJSONStringFieldPrefix(rawArgs, "file_path")
}
if pathFound && pathComplete {
trimmedPath := strings.TrimSpace(path)
if trimmedPath != "" {
pathChanged := trimmedPath != accumulator.LastEmittedPath
accumulator.LastEmittedPath = trimmedPath
if toolName == "PatchEdit" && pathChanged {
if err := sink(ModelEvent{
Kind: ModelEventKindPartialToolCall,
OccurredAt: time.Now().UTC(),
Provider: "anthropic",
Model: model,
ToolCallID: strings.TrimSpace(accumulator.CallID),
ToolCall: &agentv1.ToolCall{
Tool: &agentv1.ToolCall_EditToolCall{
EditToolCall: &agentv1.EditToolCall{
Args: &agentv1.EditArgs{Path: trimmedPath},
},
},
},
}); err != nil {
return err
}
}
if toolName == "Write" && pathChanged {
if err := sink(ModelEvent{
Kind: ModelEventKindPartialToolCall,
OccurredAt: time.Now().UTC(),
Provider: "anthropic",
Model: model,
ToolCallID: strings.TrimSpace(accumulator.CallID),
ArgsTextDelta: argsTextDelta,
ToolCall: &agentv1.ToolCall{
Tool: &agentv1.ToolCall_EditToolCall{
EditToolCall: &agentv1.EditToolCall{
Args: &agentv1.EditArgs{Path: trimmedPath},
},
},
},
}); err != nil {
return err
}
}
}
}
streamContent, streamFound := extractToolStreamContentPrefix(rawArgs, toolName)
if !streamFound {
return nil
}
delta := suffixAfterCommonPrefix(accumulator.LastStreamContent, streamContent)
if delta == "" {
return nil
}
accumulator.LastStreamContent = streamContent
return sink(ModelEvent{
Kind: ModelEventKindToolCallDelta,
OccurredAt: time.Now().UTC(),
Provider: "anthropic",
Model: model,
ToolCallID: strings.TrimSpace(accumulator.CallID),
ToolCallDelta: &agentv1.ToolCallDelta{
Delta: &agentv1.ToolCallDelta_EditToolCallDelta{
EditToolCallDelta: &agentv1.EditToolCallDelta{
StreamContentDelta: delta,
},
},
},
})
}
func isEmptyAnthropicToolInput(input any) bool {
if input == nil {
return true
}
encoded, err := json.Marshal(input)
if err != nil {
return false
}
switch string(encoded) {
case "", "null", "{}", "[]":
return true
default:
return false
}
}
func extractJSONStringFieldPrefix(input string, field string) (string, bool, bool) {
keyToken := `"` + strings.TrimSpace(field) + `"`
start := strings.Index(input, keyToken)
if start < 0 {
return "", false, false
}
index := start + len(keyToken)
for index < len(input) && isJSONWhitespace(input[index]) {
index++
}
if index >= len(input) || input[index] != ':' {
return "", false, false
}
index++
for index < len(input) && isJSONWhitespace(input[index]) {
index++
}
if index >= len(input) || input[index] != '"' {
return "", false, false
}
index++
var builder strings.Builder
for index < len(input) {
character := input[index]
if character == '"' {
return builder.String(), true, true
}
if character != '\\' {
builder.WriteByte(character)
index++
continue
}
if index+1 >= len(input) {
return builder.String(), true, false
}
next := input[index+1]
switch next {
case '"', '\\', '/':
builder.WriteByte(next)
index += 2
case 'b':
builder.WriteByte('\b')
index += 2
case 'f':
builder.WriteByte('\f')
index += 2
case 'n':
builder.WriteByte('\n')
index += 2
case 'r':
builder.WriteByte('\r')
index += 2
case 't':
builder.WriteByte('\t')
index += 2
case 'u':
if index+6 > len(input) {
return builder.String(), true, false
}
runeValue, width, ok := decodeUnicodeEscape(input[index:])
if !ok {
return builder.String(), true, false
}
builder.WriteRune(runeValue)
index += width
default:
builder.WriteByte(next)
index += 2
}
}
return builder.String(), true, false
}
func decodeUnicodeEscape(input string) (rune, int, bool) {
if len(input) < 6 || input[0] != '\\' || input[1] != 'u' {
return 0, 0, false
}
value, err := strconv.ParseUint(input[2:6], 16, 16)
if err != nil {
return 0, 0, false
}
r := rune(value)
if utf16.IsSurrogate(r) {
if len(input) < 12 || input[6] != '\\' || input[7] != 'u' {
return 0, 0, false
}
nextValue, nextErr := strconv.ParseUint(input[8:12], 16, 16)
if nextErr != nil {
return 0, 0, false
}
decoded := utf16.DecodeRune(r, rune(nextValue))
if decoded == '\uFFFD' {
return 0, 0, false
}
return decoded, 12, true
}
return r, 6, true
}
func suffixAfterCommonPrefix(previous string, current string) string {
if previous == "" {
return current
}
maxPrefix := len(previous)
if len(current) < maxPrefix {
maxPrefix = len(current)
}
index := 0
for index < maxPrefix && previous[index] == current[index] {
index++
}
return current[index:]
}
func extractToolStreamContentPrefix(rawArgs string, toolName string) (string, bool) {
switch strings.TrimSpace(toolName) {
case "PatchEdit":
if value, found, _ := extractJSONStringFieldPrefix(rawArgs, "new_string"); found {
return value, true
}
case "Write":
for _, field := range []string{"contents", "content", "stream_content", "streamContent"} {
if value, found, _ := extractJSONStringFieldPrefix(rawArgs, field); found {
return value, true
}
}
}
return "", false
}
func isJSONWhitespace(character byte) bool {
switch character {
case ' ', '\t', '\n', '\r':
return true
default:
return false
}
}
// anthropicThinkingBudget 计算当前 Anthropic thinking 预算。
func anthropicThinkingBudget(maxTokens int) int {
if maxTokens <= 0 {
return 2048
}
budget := maxTokens / 2
if budget < 1024 {
budget = 1024
}
return budget
}