package model import ( "fmt" "strings" ) // buildAnthropicMessages 把归一化消息列表拆成顶层的 system // 字符串与其余按 Anthropic 内容块形式表示的消息。所有 // RoleSystem 消息拼接成单个 system 提示词(Anthropic // 把它放在请求根而非 messages 里);没有内容片段的消息 // 会被丢弃,因为 Anthropic 拒绝空内容。挂在非用户回合上的 // 文档部件在这里就被拒绝(design/24 §2.3-③):文档本期永不 // 抵达编码器,漏网即编程错误,绝不静默丢块。 // // 连续的 RoleTool 回合在这里被合并成**一条** user 消息。这是 // Anthropic 线格式的硬要求,不是风格取舍:assistant 回合里每个 // tool_use 的应答块都必须落在**紧接其后的那一条**消息里,而回填侧 // (loop/tool_history.go)是按调用逐条产出 RoleTool 消息的,因此 // 一批并行调用的多个结果不合并就会分散在多条 user 消息中,上游 // 只在下一条里看到其中一个 id,其余 id 被判「没有 tool_result」, // 整个请求 400(真机 P0;单调用回合因此永远看不出问题)。 func buildAnthropicMessages(msgs []Message) (string, []anthropicMessage, error) { if err := ValidateDocumentParts(msgs); err != nil { return "", nil, err } var sys strings.Builder out := make([]anthropicMessage, 0, len(msgs)) // pendingToolResult 是 out 中正在累积的工具结果回合下标,-1 表示 // 没有可合并的目标。每追加一条非工具结果回合就复位它,因此只有 // **真正相邻**的结果才会被并入同一条消息 —— 两批不同 assistant // 回合的结果绝不会被混在一起。RoleSystem 回合被抽到顶层 system // 字段、不进 messages,不打断相邻性,所以不参与复位。 pendingToolResult := -1 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 } if m.Role != RoleTool { pendingToolResult = -1 out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks}) continue } if pendingToolResult >= 0 { // 合并进上一条工具结果回合。类型断言必然成立(上面只写 // []any);不成立时故意**落到下面**再开一条消息,而不是 // 静默丢弃已有块 —— 丢块会让请求更坏,且无从察觉。 if merged, ok := out[pendingToolResult].Content.([]any); ok { out[pendingToolResult].Content = append(merged, blocks...) continue } } out = append(out, anthropicMessage{Role: anthropicRole(m.Role), Content: blocks}) pendingToolResult = len(out) - 1 } return sys.String(), out, nil } // messageText 拼接消息中带文本的片段(system 提示词 // 与工具结果正文)。工具结果片段把正文放在 Text 里, // 因此 RoleTool 回合的内容能正确序列化为 OpenAI 的 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 把归一化的 Role 映射到 Anthropic 的角色词汇。 // system 消息已被抽取到顶层 system 字段,不会走到这里。 func anthropicRole(r Role) string { switch r { case RoleAssistant: return "assistant" default: // user、tool 以及未来任何角色都落在 user 回合上; // tool_result 块(M2)在 Anthropic 里属于 user 角色。 return "user" } } // encodeAnthropicMessage 把归一化消息转成 Anthropic 内容块。 // RoleTool 回合永远渲染为原生 tool_result 块 // (绝不是 user 纯文本回合),因为 Anthropic 要求工具结果以 // 携带 tool_use_id 与 is_error 标志的 tool_result 块形式到达。 func encodeAnthropicMessage(m Message) ([]any, error) { if m.Role == RoleTool { return encodeAnthropicToolResult(m) } return encodeAnthropicContent(m.Content) } // encodeAnthropicToolResult 把 RoleTool 回合渲染为 tool_result 块。 // 每个 ContentTypeToolResult 片段变成一个块;遗留的文本片段也会 // 被包成 tool_result 块,使该回合永远不会退化成普通 user 消息。 // 当片段自身没有携带 tool_use_id 时,回退到消息级的 // ToolCallID。 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 把归一化内容片段转成 Anthropic 内容块。 // 文本 / 图片 / tool_use(助手)片段在此处理;tool_result // 片段由 encodeAnthropicToolResult 在消息级渲染。文档片段不走 // 这里:本期 DeepSeek 的 anthropic 兼容端点明确不支持 document // 块,所以文档部件落到 default 分支被拒绝(design/24 §2.3-②, // fail-loud 绝不静默丢块)。 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 从 data: URI 中提取媒体类型与 base64 // 负载。Anthropic 要求 base64 图片源、无法引用远程 // URL,因此其它任何 URI 形式都按错误请求拒绝。 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 把 ToolSpec schema 转换为 Anthropic 工具。 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 }