Files
Cyrene_v2/internal/runtime/model/anthropic.go
T
Aska 7e39153a69
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 修复审计落差 #1/#2/#3/#6 + 管理面板移动端适配
#1 工具布尔标记生效(agent-106, 安全红线): loop toolSchemas/runToolCalls 加 allow-set 安全门 restrictSchemas/applyToolSecurityGate——群聊/陌生人只暴露允许工具(默认关), 被拒not_allowed软提示+AuditEvent, owner全工具不变; 身份靠 ActorEnv(非提示词)
#2 checkpoint 恢复(agent-109): run.go LoadLatest + resumableStopReason门控(只恢复in-flight工具循环,不出答案不续跑), 幂等不重放副作用, 新会话不误恢复; resume_test 4用例
#3 tool_result(agent-108, 协议层): Anthropic 原生tool_result块(tool_use_id/is_error)非user纯文本 + OpenAI tool消息补tool_call_id; Content加ToolCallID/IsError等; (loop接线层2处留待,见备注)
#6 capacity_awaken推送(agent-107): onCapacity从只Debug改推WS hub(BroadcastCapacityAwaken, sessionId定向+payload对齐前端AwakenPayload)
管理面板移动端(agent-110): AdminApp 汉堡菜单+抽屉侧栏+标题truncate防换行, RBAC守卫不受影响
全仓 build/vet/test绿 + 前端build绿; 前端无涉差异已对齐

备注: #3 loop接线层2处(assistant工具轮append历史 + Message.ToolCallID/IsError)留给后续; #4代审/#5子代理触发/#7拆文件后置(主人说后三不急)
2026-09-07 13:43:18 +08:00

499 lines
16 KiB
Go

package model
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
)
// Anthropic Messages (/v1/messages) wire protocol constants.
const (
// anthropicAPIVersion is the Messages API version sent on every request.
anthropicAPIVersion = "2023-06-01"
// anthropicDefaultMaxTokens is the default max_tokens sent when
// Request.MaxTokens is zero. Anthropic requires max_tokens to be present and
// positive, so a sensible default is applied rather than omitting it.
anthropicDefaultMaxTokens = 1024
// anthropicDataPrefix marks a payload embedded as a data: URI.
anthropicDataPrefix = "data:"
)
// anthropicRequest is the body sent to /v1/messages. Messages carry Anthropic's
// content-block model and the system prompt lives at the request root.
type anthropicRequest struct {
Model string `json:"model"`
System string `json:"system,omitempty"`
Messages []anthropicMessage `json:"messages"`
Tools []anthropicTool `json:"tools,omitempty"`
ToolChoice any `json:"tool_choice,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,omitempty"`
}
// anthropicMessage is one conversation turn. Content is an array of content
// blocks (text / image for now; tool_result blocks are added by M2).
type anthropicMessage struct {
Role string `json:"role"`
Content any `json:"content"`
}
// anthropicTool is the wire form of a tool schema.
type anthropicTool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema any `json:"input_schema"`
}
// anthropicResponse is the JSON body returned by /v1/messages.
type anthropicResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Content []anthropicBlock `json:"content"`
Usage *anthropicUsage `json:"usage,omitempty"`
}
// anthropicBlock is a single content block in a response. For tool_use blocks the
// Input field is a fully decoded object (never a JSON string).
type anthropicBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input map[string]any `json:"input,omitempty"`
}
// anthropicUsage reports token consumption. Anthropic reports input_tokens and
// output_tokens rather than a single total.
type anthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// AnthropicChatAdapter calls an Anthropic Messages (/v1/messages) endpoint. It is
// constructed from a config.LLMConfig and implements Adapter for both
// non-streaming and token-level SSE streaming, speaking the provider's native
// content-block protocol so tool_use.input arrives as a structured object.
type AnthropicChatAdapter struct {
name string
baseURL string
apiKey string
model string
client *http.Client
recorder Recorder
}
// AnthropicOption configures an AnthropicChatAdapter.
type AnthropicOption func(*AnthropicChatAdapter)
// WithAnthropicHTTPClient overrides the default HTTP client (used to inject mocks
// or a tuned transport in tests and production).
func WithAnthropicHTTPClient(c *http.Client) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.client = c }
}
// WithAnthropicRecorder wires a per-call Recorder for CallRecord accounting.
func WithAnthropicRecorder(r Recorder) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.recorder = r }
}
// WithAnthropicModel overrides the adapter's default model name.
func WithAnthropicModel(m string) AnthropicOption {
return func(a *AnthropicChatAdapter) { a.model = m }
}
// NewAnthropicChat builds an Anthropic adapter from a supplier config. baseURL is
// trimmed of any trailing slash before the /v1/messages path is appended.
func NewAnthropicChat(cfg config.LLMConfig, opts ...AnthropicOption) *AnthropicChatAdapter {
a := &AnthropicChatAdapter{
name: "anthropic",
baseURL: strings.TrimRight(cfg.AnthropicBaseURL, "/"),
apiKey: cfg.AnthropicAPIKey,
model: cfg.AnthropicModel,
client: &http.Client{
Transport: http.DefaultTransport,
Timeout: defaultHTTPTimeout,
},
}
for _, o := range opts {
o(a)
}
return a
}
// Name returns the adapter identifier.
func (a *AnthropicChatAdapter) Name() string { return a.name }
// Chat performs a non-streaming completion.
func (a *AnthropicChatAdapter) Chat(ctx context.Context, req Request, opts Options) (Response, error) {
start := time.Now()
if a.baseURL == "" {
return Response{}, ErrUpstream
}
ctx, cancel := withCallTimeout(ctx, opts)
defer cancel()
model, err := a.resolveModel(req)
if err != nil {
return Response{}, err
}
req.Model = model
body, err := a.buildRequest(req, false)
if err != nil {
return Response{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
}
resp, err := a.doRequest(httpReq, false)
if err != nil {
return Response{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Response{}, a.upstreamError(resp)
}
var wire anthropicResponse
if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil {
return Response{}, fmt.Errorf("model: %w: decode response: %v", ErrUpstream, err)
}
out := responseFromAnthropicWire(&wire)
a.record(req, start, out.TotalTokens)
return out, nil
}
// ChatStream streams structured events via onEvent and returns the aggregated
// response with usage when the provider reports it. Text blocks are delivered as
// text_start/text_delta/text_end events; tool_use blocks are delivered as
// tool_call_start/tool_call_delta/tool_call_end so the frontend can render each
// segment independently.
func (a *AnthropicChatAdapter) ChatStream(ctx context.Context, req Request, onEvent func(StreamEvent)) (Response, error) {
start := time.Now()
if a.baseURL == "" {
return Response{}, ErrUpstream
}
model, err := a.resolveModel(req)
if err != nil {
return Response{}, err
}
req.Model = model
body, err := a.buildRequest(req, true)
if err != nil {
return Response{}, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, a.endpoint(), bytes.NewReader(body))
if err != nil {
return Response{}, fmt.Errorf("model: %w: build request: %v", ErrUpstream, err)
}
resp, err := a.doRequest(httpReq, true)
if err != nil {
return Response{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Response{}, a.upstreamError(resp)
}
out, err := scanAnthropicSSE(resp.Body, onEvent)
if err != nil {
return Response{}, err
}
out.Model = model
a.record(req, start, out.TotalTokens)
return out, nil
}
// resolveModel returns the effective model name.
func (a *AnthropicChatAdapter) resolveModel(req Request) (string, error) {
if req.Model != "" {
return req.Model, nil
}
if a.model != "" {
return a.model, nil
}
return "", ErrNoModel
}
// endpoint returns the full /v1/messages URL.
func (a *AnthropicChatAdapter) endpoint() string {
return a.baseURL + "/v1/messages"
}
func (a *AnthropicChatAdapter) buildRequest(req Request, stream bool) ([]byte, error) {
system, msgs, err := buildAnthropicMessages(req.Messages)
if err != nil {
return nil, err
}
tokenLimit := req.MaxTokens
if tokenLimit == 0 {
tokenLimit = anthropicDefaultMaxTokens
}
wire := anthropicRequest{
Model: req.Model,
System: system,
Messages: msgs,
Tools: buildAnthropicTools(req.Tools),
ToolChoice: req.ToolChoice,
Temperature: req.Temperature,
MaxTokens: tokenLimit,
Stream: stream,
}
buf, err := json.Marshal(wire)
if err != nil {
return nil, fmt.Errorf("model: %w: marshal request: %v", ErrBadRequest, err)
}
return buf, nil
}
func (a *AnthropicChatAdapter) doRequest(httpReq *http.Request, stream bool) (*http.Response, error) {
if a.apiKey != "" {
httpReq.Header.Set("x-api-key", a.apiKey)
}
httpReq.Header.Set("anthropic-version", anthropicAPIVersion)
httpReq.Header.Set("Content-Type", "application/json")
if stream {
httpReq.Header.Set("Accept", "text/event-stream")
} else {
httpReq.Header.Set("Accept", "application/json")
}
resp, err := a.client.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("model: %w: %v", ErrUpstream, err)
}
return resp, nil
}
func (a *AnthropicChatAdapter) upstreamError(resp *http.Response) error {
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyCap))
return fmt.Errorf("model: %w: status %d: %s", ErrUpstream, resp.StatusCode, strings.TrimSpace(string(snippet)))
}
func (a *AnthropicChatAdapter) record(req Request, start time.Time, tokens int) {
if a.recorder == nil {
return
}
a.recorder(CallRecord{
Model: req.Model,
Purpose: req.Purpose,
Tokens: tokens,
TookMs: time.Since(start).Milliseconds(),
})
}
// 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
}
// responseFromAnthropicWire converts a non-streaming /v1/messages response into a
// normalized Response. Each text block contributes to the assistant content; each
// tool_use block becomes a ToolCall whose Args is taken directly from the decoded
// input object.
func responseFromAnthropicWire(wire *anthropicResponse) Response {
out := Response{Model: wire.Model}
var b strings.Builder
var toolCalls []ToolCall
for _, blk := range wire.Content {
switch blk.Type {
case "text":
b.WriteString(blk.Text)
case "tool_use":
toolCalls = append(toolCalls, ToolCall{Name: blk.Name, ID: blk.ID, Args: blk.Input})
}
}
out.Content = b.String()
out.Message = Message{Role: RoleAssistant, Content: []Content{{Type: ContentTypeText, Text: out.Content}}}
out.ToolCalls = toolCalls
if u := wire.Usage; u != nil {
out.PromptTokens = u.InputTokens
out.CompletionTokens = u.OutputTokens
out.TotalTokens = u.InputTokens + u.OutputTokens
}
return out
}
// AnthropicToolResult builds the content block used to feed a tool result back to
// Anthropic (role: user, first content element). toolUseID must equal the id of a
// previous tool_use block; isError marks an execution failure.
func AnthropicToolResult(toolUseID, content string, isError bool) map[string]any {
return map[string]any{
"type": "tool_result",
"tool_use_id": toolUseID,
"content": content,
"is_error": isError,
}
}