Files
Cyrene_v2/internal/runtime/model/anthropic_encode.go
T
AskaEth c7b2cd8ae0
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(model): 解析 stop_reason + max_tokens 截断重试/非空兜底 — 不再静默空回复
- model.go: Response.StopReason; anthropic wire加stop_reason; openai finish_reason映射(两协议统一,原值透传)
- truncation.go(新,159行): effectiveMaxTokens/retryMaxTokens(×2封顶32768)/isTruncatedEmpty(空正文+无tool_use+stop_reason∈{max_tokens,length})/withEmptyBodyHint/callWithTruncationRetry/callStreamWithTruncationRetry/dropThinking/emitTextSegment
- anthropic.go Chat/ChatStream 走重试+兜底; anthropic_encode.go(新)拆编码(anthropic.go 529→357合规); anthropic_stream message_delta落StopReason
- agent/loop: 把模型层StopReason带到llm.call span的model_stop_reason属性(修掉trace硬编码stop的误导); loop.Result.StopReason语义未动
- 触发仅当截断空正文; tool_use空正文永不触发(native_toolcall_test仍过); 至多1次重试; 兜底回'(思考过长,未能生成回复)'绝不用thinking
- 新增7测试(非流式/流式重试+兜底+tool_use不触发+封顶+openai映射), build/vet/test绿+3平台交叉编译
2026-09-08 23:52:47 +08:00

179 lines
5.7 KiB
Go

package model
import (
"fmt"
"strings"
)
// buildAnthropicMessages splits a normalized message list into a top-level system
// string and the remaining messages in Anthropic's content-block form. All
// RoleSystem messages are concatenated into a single system prompt (Anthropic
// carries it at the request root, not in messages); messages with no content
// parts are dropped because Anthropic rejects empty content.
func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) {
var sys strings.Builder
out := make([]anthropicMessage, 0, len(msgs))
for _, m := range msgs {
if m.Role == RoleSystem {
if t := messageText(m); t != "" {
if sys.Len() > 0 {
sys.WriteString("\n")
}
sys.WriteString(t)
}
continue
}
blocks, err := encodeAnthropicMessage(m)
if err != nil {
return "", nil, err
}
if len(blocks) == 0 {
continue
}
out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks})
}
return sys.String(), out, nil
}
// messageText concatenates the text-bearing parts of a message (system prompts
// and tool-result bodies). Tool-result parts carry their body in Text, so a
// RoleTool turn's content serializes correctly for OpenAI's tool message.
func messageText(m Message) string {
var b strings.Builder
for _, part := range m.Content {
if part.Type == ContentTypeText || part.Type == ContentTypeToolResult {
b.WriteString(part.Text)
}
}
return b.String()
}
// anthropicRole maps a normalized Role to the Anthropic role vocabulary. system
// messages are extracted to the top-level system field and never reach here.
func anthropicRole(r Role) string {
switch r {
case RoleAssistant:
return "assistant"
default:
// user, tool and any future roles all land on the user turn; tool_result
// blocks (M2) belong to the user role in Anthropic.
return "user"
}
}
// encodeAnthropicMessage turns a normalized message into Anthropic content
// blocks. A RoleTool turn is ALWAYS rendered as native tool_result blocks (never
// a user plain-text turn), because Anthropic requires a tool result to arrive as
// a tool_result block carrying the tool_use_id and the is_error flag.
func encodeAnthropicMessage(m Message) ([]any, error) {
if m.Role == RoleTool {
return encodeAnthropicToolResult(m)
}
return encodeAnthropicContent(m.Content)
}
// encodeAnthropicToolResult renders a RoleTool turn as tool_result blocks. Each
// ContentTypeToolResult part becomes one block; legacy text parts are wrapped as
// tool_result blocks too so the turn never degrades into a plain user message.
// The tool_use_id falls back to the message-level ToolCallID when a part does not
// carry its own.
func encodeAnthropicToolResult(m Message) ([]any, error) {
blocks := make([]any, 0, len(m.Content))
for _, p := range m.Content {
switch p.Type {
case ContentTypeToolResult, ContentTypeText:
id := p.ToolCallID
if id == "" {
id = m.ToolCallID
}
blocks = append(blocks, AnthropicToolResult(id, p.Text, p.IsError))
}
}
if len(blocks) == 0 {
blocks = append(blocks, AnthropicToolResult(m.ToolCallID, "", false))
}
return blocks, nil
}
// encodeAnthropicContent turns normalized content parts into Anthropic content
// blocks. Text / image / tool_use (assistant) parts are handled here; tool_result
// parts are rendered at the message level by encodeAnthropicToolResult.
func encodeAnthropicContent(parts []Content) ([]any, error) {
blocks := make([]any, 0, len(parts))
for _, p := range parts {
switch p.Type {
case ContentTypeText:
blocks = append(blocks, map[string]any{"type": "text", "text": p.Text})
case ContentTypeImage:
mediaType, data, err := parseAnthropicImageData(p.ImageURL)
if err != nil {
return nil, err
}
blocks = append(blocks, map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": mediaType,
"data": data,
},
})
case ContentTypeToolCall:
args := p.ToolArgs
if args == nil {
args = map[string]any{}
}
blocks = append(blocks, map[string]any{
"type": "tool_use",
"id": p.ToolCallID,
"name": p.ToolCallName,
"input": args,
})
case ContentTypeToolResult:
blocks = append(blocks, AnthropicToolResult(p.ToolCallID, p.Text, p.IsError))
default:
return nil, fmt.Errorf("model: %w: unsupported content type %q", ErrBadRequest, p.Type)
}
}
return blocks, nil
}
// parseAnthropicImageData extracts the media type and base64 payload from a data:
// URI. Anthropic requires a base64 image source and cannot reference a remote
// URL, so any other URI form is rejected as a bad request.
func parseAnthropicImageData(uri string) (string, string, error) {
if !strings.HasPrefix(uri, anthropicDataPrefix) {
return "", "", fmt.Errorf("model: %w: anthropic image requires a data: URI, got %q", ErrBadRequest, uri)
}
rest := strings.TrimPrefix(uri, anthropicDataPrefix)
comma := strings.Index(rest, ",")
if comma < 0 {
return "", "", fmt.Errorf("model: %w: malformed data: URI %q", ErrBadRequest, uri)
}
meta := rest[:comma]
data := rest[comma+1:]
mediaType := meta
if idx := strings.Index(meta, ";"); idx >= 0 {
mediaType = meta[:idx]
}
if mediaType == "" {
return "", "", fmt.Errorf("model: %w: data: URI missing media type in %q", ErrBadRequest, uri)
}
return mediaType, data, nil
}
// buildAnthropicTools converts ToolSpec schemas into Anthropic tools.
func buildAnthropicTools(tools []ToolSpec) []anthropicTool {
if len(tools) == 0 {
return nil
}
out := make([]anthropicTool, 0, len(tools))
for _, t := range tools {
out = append(out, anthropicTool{
Name: t.Name,
Description: t.Description,
InputSchema: t.Parameters,
})
}
return out
}