mirror of
https://github.com/wavetermdev/waveterm.git
synced 2025-11-28 05:00:26 +08:00
823 lines
25 KiB
Go
823 lines
25 KiB
Go
// Copyright 2025, Command Line Inc.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package anthropic
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
|
|
"github.com/wavetermdev/waveterm/pkg/util/utilfn"
|
|
)
|
|
|
|
// these conversions are based off the anthropic spec
|
|
// and the aiprompts/aisdk-uimessage-type.md doc (v5)
|
|
|
|
// buildAnthropicHTTPRequest creates a complete HTTP request for the Anthropic API
|
|
func buildAnthropicHTTPRequest(ctx context.Context, msgs []anthropicInputMessage, chatOpts uctypes.WaveChatOpts) (*http.Request, error) {
|
|
opts := chatOpts.Config
|
|
if opts.Model == "" {
|
|
return nil, errors.New("opts.model is required")
|
|
}
|
|
if chatOpts.ClientId == "" {
|
|
return nil, errors.New("chatOpts.ClientId is required")
|
|
}
|
|
|
|
// Set defaults
|
|
endpoint := opts.BaseURL
|
|
if endpoint == "" {
|
|
return nil, errors.New("BaseURL is required")
|
|
}
|
|
|
|
apiVersion := opts.APIVersion
|
|
if apiVersion == "" {
|
|
apiVersion = AnthropicDefaultAPIVersion
|
|
}
|
|
|
|
maxTokens := opts.MaxTokens
|
|
if maxTokens <= 0 {
|
|
maxTokens = AnthropicDefaultMaxTokens
|
|
}
|
|
|
|
// Convert messages to clear FileName fields from Source blocks
|
|
convertedMsgs := make([]anthropicInputMessage, len(msgs))
|
|
for i, msg := range msgs {
|
|
convertedMsgs[i] = convertMessageForAPI(msg)
|
|
}
|
|
|
|
// inject chatOpts.TabState as a "text" block at the END of the LAST "user" message found (append to Content)
|
|
if chatOpts.TabState != "" {
|
|
// Find the last "user" message
|
|
for i := len(convertedMsgs) - 1; i >= 0; i-- {
|
|
if convertedMsgs[i].Role == "user" {
|
|
// Create a text block with the TabState content
|
|
tabStateBlock := anthropicMessageContentBlock{
|
|
Type: "text",
|
|
Text: chatOpts.TabState,
|
|
}
|
|
// Append to the Content of this message
|
|
convertedMsgs[i].Content = append(convertedMsgs[i].Content, tabStateBlock)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// inject chatOpts.PlatformInfo, AppStaticFiles, and AppGoFile as "text" blocks at the END of the LAST "user" message found (append to Content)
|
|
if chatOpts.PlatformInfo != "" || chatOpts.AppStaticFiles != "" || chatOpts.AppGoFile != "" {
|
|
// Find the last "user" message
|
|
for i := len(convertedMsgs) - 1; i >= 0; i-- {
|
|
if convertedMsgs[i].Role == "user" {
|
|
if chatOpts.PlatformInfo != "" {
|
|
platformInfoBlock := anthropicMessageContentBlock{
|
|
Type: "text",
|
|
Text: "<PlatformInfo>\n" + chatOpts.PlatformInfo + "\n</PlatformInfo>",
|
|
}
|
|
convertedMsgs[i].Content = append(convertedMsgs[i].Content, platformInfoBlock)
|
|
}
|
|
if chatOpts.AppStaticFiles != "" {
|
|
appStaticFilesBlock := anthropicMessageContentBlock{
|
|
Type: "text",
|
|
Text: "<CurrentAppStaticFiles>\n" + chatOpts.AppStaticFiles + "\n</CurrentAppStaticFiles>",
|
|
}
|
|
convertedMsgs[i].Content = append(convertedMsgs[i].Content, appStaticFilesBlock)
|
|
}
|
|
if chatOpts.AppGoFile != "" {
|
|
appGoFileBlock := anthropicMessageContentBlock{
|
|
Type: "text",
|
|
Text: "<CurrentAppGoFile>\n" + chatOpts.AppGoFile + "\n</CurrentAppGoFile>",
|
|
}
|
|
convertedMsgs[i].Content = append(convertedMsgs[i].Content, appGoFileBlock)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build request body
|
|
reqBody := &anthropicStreamRequest{
|
|
Model: opts.Model,
|
|
MaxTokens: maxTokens,
|
|
Stream: true,
|
|
Messages: convertedMsgs,
|
|
}
|
|
|
|
// Add system prompt if provided
|
|
if len(chatOpts.SystemPrompt) > 0 {
|
|
systemBlocks := make([]anthropicMessageContentBlock, len(chatOpts.SystemPrompt))
|
|
for i, prompt := range chatOpts.SystemPrompt {
|
|
systemBlocks[i] = anthropicMessageContentBlock{
|
|
Type: "text",
|
|
Text: prompt,
|
|
}
|
|
}
|
|
reqBody.System = systemBlocks
|
|
}
|
|
|
|
if len(chatOpts.Tools) > 0 {
|
|
cleanedTools := make([]uctypes.ToolDefinition, len(chatOpts.Tools))
|
|
for i, tool := range chatOpts.Tools {
|
|
cleanedTools[i] = *tool.Clean()
|
|
}
|
|
reqBody.Tools = cleanedTools
|
|
}
|
|
for _, tool := range chatOpts.TabTools {
|
|
cleanedTool := *tool.Clean()
|
|
reqBody.Tools = append(reqBody.Tools, cleanedTool)
|
|
}
|
|
|
|
// Enable extended thinking based on level
|
|
reqBody.Thinking = makeThinkingOpts(opts.ThinkingLevel, maxTokens)
|
|
|
|
// pretty print json of anthropicMsgs
|
|
if jsonStr, err := utilfn.MarshalIndentNoHTMLString(convertedMsgs, "", " "); err == nil {
|
|
log.Printf("system-prompt: %v\n", chatOpts.SystemPrompt)
|
|
var toolNames []string
|
|
for _, tool := range chatOpts.Tools {
|
|
toolNames = append(toolNames, tool.Name)
|
|
}
|
|
for _, tool := range chatOpts.TabTools {
|
|
toolNames = append(toolNames, tool.Name)
|
|
}
|
|
log.Printf("tools: %s\n", strings.Join(toolNames, ", "))
|
|
log.Printf("anthropicMsgs JSON:\n%s", jsonStr)
|
|
log.Printf("has-api-key: %v\n", opts.APIToken != "")
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
encoder := json.NewEncoder(&buf)
|
|
encoder.SetEscapeHTML(false)
|
|
err := encoder.Encode(reqBody)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, &buf)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("content-type", "application/json")
|
|
if opts.APIToken != "" {
|
|
req.Header.Set("x-api-key", opts.APIToken)
|
|
}
|
|
req.Header.Set("anthropic-version", apiVersion)
|
|
req.Header.Set("accept", "text/event-stream")
|
|
req.Header.Set("X-Wave-ClientId", chatOpts.ClientId)
|
|
req.Header.Set("X-Wave-APIType", uctypes.APIType_AnthropicMessages)
|
|
|
|
return req, nil
|
|
}
|
|
|
|
// convertToolUsePart converts a tool-* type UIMessagePart to an Anthropic tool_use or tool_result block
|
|
func convertToolUsePart(p uctypes.UIMessagePart) (*anthropicMessageContentBlock, error) {
|
|
// Sanity check that this is actually a tool-* type
|
|
if !strings.HasPrefix(p.Type, "tool-") {
|
|
return nil, fmt.Errorf("convertToolUsePart expects 'tool-*' type, got '%s'", p.Type)
|
|
}
|
|
|
|
// Extract tool name from type field (format: "tool-{name}")
|
|
toolName := strings.TrimPrefix(p.Type, "tool-")
|
|
if toolName == "" {
|
|
return nil, fmt.Errorf("tool name is empty (type was '%s')", p.Type)
|
|
}
|
|
if len(toolName) > 200 {
|
|
return nil, fmt.Errorf("tool name exceeds 200 character limit: %d characters", len(toolName))
|
|
}
|
|
if p.ToolCallID == "" {
|
|
return nil, fmt.Errorf("tool call ID is required but missing")
|
|
}
|
|
|
|
// Validate ToolCallID charset (must match ^[a-zA-Z0-9_-]+$)
|
|
validIDPattern := regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
|
if !validIDPattern.MatchString(p.ToolCallID) {
|
|
return nil, fmt.Errorf("tool call ID contains invalid characters (must be alphanumeric, underscore, or dash): %s", p.ToolCallID)
|
|
}
|
|
|
|
// Handle different states
|
|
if p.State == "input-streaming" || p.State == "input-available" {
|
|
// These states represent tool calls (tool_use blocks)
|
|
// Anthropic expects an object for input, never nil
|
|
input := p.Input
|
|
if input == nil {
|
|
input = map[string]interface{}{}
|
|
} else {
|
|
// Validate that input is an object (map), not string/array
|
|
if _, ok := input.(map[string]interface{}); !ok {
|
|
return nil, fmt.Errorf("tool input must be an object/map, got %T", input)
|
|
}
|
|
}
|
|
|
|
return &anthropicMessageContentBlock{
|
|
Type: "tool_use",
|
|
ID: p.ToolCallID,
|
|
Name: toolName,
|
|
Input: input,
|
|
}, nil
|
|
|
|
} else if p.State == "output-available" {
|
|
// This state represents successful tool execution result (tool_result block)
|
|
var content interface{}
|
|
if p.Output != nil {
|
|
// Try to convert output to string if it's not already
|
|
if outputStr, ok := p.Output.(string); ok {
|
|
content = outputStr
|
|
} else {
|
|
// If it's not a string, marshal it to JSON
|
|
outputBytes, err := json.Marshal(p.Output)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal tool output: %w", err)
|
|
}
|
|
content = string(outputBytes)
|
|
}
|
|
} else {
|
|
content = ""
|
|
}
|
|
|
|
return &anthropicMessageContentBlock{
|
|
Type: "tool_result",
|
|
ToolUseID: p.ToolCallID,
|
|
Content: content,
|
|
}, nil
|
|
|
|
} else if p.State == "output-error" {
|
|
// This state represents failed tool execution (tool_result block with error)
|
|
errorContent := p.ErrorText
|
|
if errorContent == "" {
|
|
errorContent = "Tool execution failed"
|
|
}
|
|
|
|
return &anthropicMessageContentBlock{
|
|
Type: "tool_result",
|
|
ToolUseID: p.ToolCallID,
|
|
Content: errorContent,
|
|
IsError: true,
|
|
}, nil
|
|
|
|
} else {
|
|
return nil, fmt.Errorf("invalid tool part state '%s' (must be 'input-streaming', 'input-available', 'output-available', or 'output-error')", p.State)
|
|
}
|
|
}
|
|
|
|
// convertPartToAnthropicBlocks converts a single UIMessagePart to one or more Anthropic content blocks
|
|
func convertPartToAnthropicBlocks(p uctypes.UIMessagePart, role string, blockIndex int) ([]anthropicMessageContentBlock, error) {
|
|
if p.Type == "text" {
|
|
return []anthropicMessageContentBlock{{
|
|
Type: "text",
|
|
Text: p.Text,
|
|
}}, nil
|
|
} else if p.Type == "reasoning" {
|
|
// Check if we have a signature in provider metadata
|
|
signature, hasSignature := p.ProviderMetadata[ProviderMetadataThinkingSignatureKey]
|
|
if !hasSignature {
|
|
return nil, fmt.Errorf("reasoning part requires signature in provider metadata key '%s'", ProviderMetadataThinkingSignatureKey)
|
|
}
|
|
|
|
signatureStr, ok := signature.(string)
|
|
if !ok {
|
|
return nil, fmt.Errorf("reasoning part signature must be a string, got %T", signature)
|
|
}
|
|
|
|
return []anthropicMessageContentBlock{{
|
|
Type: "thinking",
|
|
Thinking: p.Text,
|
|
Signature: signatureStr,
|
|
}}, nil
|
|
} else if p.Type == "source-url" || p.Type == "source-document" {
|
|
// no longer convert citations
|
|
return nil, nil
|
|
} else if p.Type == "step-start" {
|
|
// Omit step-start parts from Anthropic
|
|
return nil, nil
|
|
} else if strings.HasPrefix(p.Type, "data-") {
|
|
// Omit data-* parts from Anthropic
|
|
return nil, nil
|
|
} else if p.Type == "file" {
|
|
// Anthropic expects files in user messages
|
|
if role != "user" {
|
|
return nil, fmt.Errorf("dropping file part in %s message (files should be in user messages)", role)
|
|
}
|
|
block, err := convertFileUIMessagePart(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []anthropicMessageContentBlock{*block}, nil
|
|
} else if strings.HasPrefix(p.Type, "tool-") {
|
|
block, err := convertToolUsePart(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return []anthropicMessageContentBlock{*block}, nil
|
|
} else {
|
|
// Skip unknown part types
|
|
return nil, fmt.Errorf("dropping unknown part type '%s'", p.Type)
|
|
}
|
|
}
|
|
|
|
// convertPartsToAnthropicBlocks converts UseChatMessagePart array to Anthropic content blocks with role-based validation
|
|
func convertPartsToAnthropicBlocks(parts []uctypes.UIMessagePart, role string) ([]anthropicMessageContentBlock, error) {
|
|
var blocks []anthropicMessageContentBlock
|
|
|
|
for _, p := range parts {
|
|
partBlocks, err := convertPartToAnthropicBlocks(p, role, len(blocks))
|
|
if err != nil {
|
|
log.Printf("anthropic: %v", err)
|
|
continue
|
|
}
|
|
blocks = append(blocks, partBlocks...)
|
|
}
|
|
|
|
return blocks, nil
|
|
}
|
|
|
|
// convertFileUIMessagePart converts a file part to Anthropic image or document block format
|
|
func convertFileUIMessagePart(p uctypes.UIMessagePart) (*anthropicMessageContentBlock, error) {
|
|
if p.Type != "file" {
|
|
return nil, fmt.Errorf("convertFileUIMessagePart expects 'file' type, got '%s'", p.Type)
|
|
}
|
|
if p.URL == "" {
|
|
return nil, errors.New("file part missing url")
|
|
}
|
|
if p.MediaType == "" {
|
|
return nil, errors.New("file part missing mediaType")
|
|
}
|
|
|
|
// Validate URL protocol - only allow data:, http:, https:
|
|
if !strings.HasPrefix(p.URL, "data:") &&
|
|
!strings.HasPrefix(p.URL, "http://") &&
|
|
!strings.HasPrefix(p.URL, "https://") {
|
|
return nil, fmt.Errorf("unsupported URL protocol in file part: %s", p.URL)
|
|
}
|
|
|
|
// Branch on mediaType first to determine block type and constraints
|
|
switch {
|
|
case strings.HasPrefix(p.MediaType, "image/"):
|
|
// image/* (jpeg, png, gif, webp) → Anthropic image block
|
|
if strings.HasPrefix(p.URL, "data:") {
|
|
// Data URL → base64 source
|
|
parts := strings.SplitN(p.URL, ",", 2)
|
|
if len(parts) != 2 {
|
|
return nil, errors.New("invalid data URL format")
|
|
}
|
|
return &anthropicMessageContentBlock{
|
|
Type: "image",
|
|
Source: &anthropicSource{
|
|
Type: "base64",
|
|
Data: parts[1],
|
|
MediaType: p.MediaType,
|
|
},
|
|
}, nil
|
|
} else {
|
|
// HTTP/HTTPS URL → url source (no media_type for image URLs)
|
|
return &anthropicMessageContentBlock{
|
|
Type: "image",
|
|
Source: &anthropicSource{
|
|
Type: "url",
|
|
URL: p.URL,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
case p.MediaType == "application/pdf":
|
|
// application/pdf → Anthropic document block
|
|
if strings.HasPrefix(p.URL, "data:") {
|
|
// Data URL → base64 source
|
|
parts := strings.SplitN(p.URL, ",", 2)
|
|
if len(parts) != 2 {
|
|
return nil, errors.New("invalid data URL format")
|
|
}
|
|
return &anthropicMessageContentBlock{
|
|
Type: "document",
|
|
Source: &anthropicSource{
|
|
Type: "base64",
|
|
Data: parts[1],
|
|
MediaType: p.MediaType,
|
|
},
|
|
}, nil
|
|
} else {
|
|
// HTTP/HTTPS URL → url source (no media_type for URL sources)
|
|
return &anthropicMessageContentBlock{
|
|
Type: "document",
|
|
Source: &anthropicSource{
|
|
Type: "url",
|
|
URL: p.URL,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
case p.MediaType == "text/plain":
|
|
// text/plain → Anthropic document block, but NO URL form supported
|
|
if strings.HasPrefix(p.URL, "data:") {
|
|
// Data URL → decode base64 data and return as document with PlainTextSource
|
|
parts := strings.SplitN(p.URL, ",", 2)
|
|
if len(parts) != 2 {
|
|
return nil, errors.New("invalid data URL format")
|
|
}
|
|
// Decode base64 data
|
|
textData, err := base64.StdEncoding.DecodeString(parts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode base64 data: %w", err)
|
|
}
|
|
return &anthropicMessageContentBlock{
|
|
Type: "document",
|
|
Source: &anthropicSource{
|
|
Type: "text",
|
|
Data: string(textData),
|
|
MediaType: "text/plain",
|
|
},
|
|
}, nil
|
|
} else {
|
|
// HTTP/HTTPS URL → not supported inline, would need to fetch
|
|
return nil, fmt.Errorf("dropping text/plain file with URL (must be fetched and converted to base64 or uploaded to Files API)")
|
|
}
|
|
|
|
default:
|
|
// Other media types → not supported inline, must upload and use file_id
|
|
return nil, fmt.Errorf("dropping file with unsupported media type '%s' (must be uploaded to Files API and sent as file_id)", p.MediaType)
|
|
}
|
|
|
|
}
|
|
|
|
// convertAIMessageToAnthropicChatMessage converts an AIMessage to anthropicChatMessage
|
|
// These messages are ALWAYS role "user"
|
|
func ConvertAIMessageToAnthropicChatMessage(aiMsg uctypes.AIMessage) (*anthropicChatMessage, error) {
|
|
if err := aiMsg.Validate(); err != nil {
|
|
return nil, fmt.Errorf("invalid AIMessage: %w", err)
|
|
}
|
|
|
|
var contentBlocks []anthropicMessageContentBlock
|
|
|
|
for i, part := range aiMsg.Parts {
|
|
switch part.Type {
|
|
case uctypes.AIMessagePartTypeText:
|
|
if part.Text == "" {
|
|
return nil, fmt.Errorf("part %d: text type requires non-empty text field", i)
|
|
}
|
|
contentBlocks = append(contentBlocks, anthropicMessageContentBlock{
|
|
Type: "text",
|
|
Text: part.Text,
|
|
})
|
|
|
|
case uctypes.AIMessagePartTypeFile:
|
|
block, err := convertFileAIMessagePart(part)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("part %d: %w", i, err)
|
|
}
|
|
contentBlocks = append(contentBlocks, *block)
|
|
|
|
default:
|
|
return nil, fmt.Errorf("part %d: unsupported part type '%s'", i, part.Type)
|
|
}
|
|
}
|
|
|
|
return &anthropicChatMessage{
|
|
MessageId: aiMsg.MessageId,
|
|
Role: "user",
|
|
Content: contentBlocks,
|
|
}, nil
|
|
}
|
|
|
|
// hasInlineData checks if the part has data available for inline use (either Data field or data URL)
|
|
func hasInlineData(part uctypes.AIMessagePart) bool {
|
|
hasData := len(part.Data) > 0
|
|
hasURL := part.URL != "" && strings.HasPrefix(part.URL, "data:")
|
|
return hasData || hasURL
|
|
}
|
|
|
|
// extractBase64Data extracts base64 data from either the Data field or a data URL
|
|
func extractBase64Data(part uctypes.AIMessagePart) (string, error) {
|
|
hasData := len(part.Data) > 0
|
|
hasURL := part.URL != ""
|
|
|
|
if hasData {
|
|
// Raw data → base64 encode
|
|
return base64.StdEncoding.EncodeToString(part.Data), nil
|
|
} else if hasURL && strings.HasPrefix(part.URL, "data:") {
|
|
// Data URL → check format and extract/encode data appropriately
|
|
parts := strings.SplitN(part.URL, ",", 2)
|
|
if len(parts) != 2 {
|
|
return "", errors.New("invalid data URL format")
|
|
}
|
|
|
|
header := parts[0]
|
|
data := parts[1]
|
|
|
|
// Check if it's already base64 encoded: data:mediatype;base64,<data>
|
|
if strings.Contains(header, ";base64") {
|
|
// Already base64 encoded
|
|
return data, nil
|
|
} else {
|
|
// Raw data that needs base64 encoding: data:mediatype,<raw_data>
|
|
return base64.StdEncoding.EncodeToString([]byte(data)), nil
|
|
}
|
|
}
|
|
|
|
return "", errors.New("no data available for base64 extraction")
|
|
}
|
|
|
|
// convertFileAIMessagePart converts a file AIMessagePart to anthropicMessageContentBlock
|
|
func convertFileAIMessagePart(part uctypes.AIMessagePart) (*anthropicMessageContentBlock, error) {
|
|
if part.Type != uctypes.AIMessagePartTypeFile {
|
|
return nil, fmt.Errorf("convertFileAIMessagePart expects 'file' type, got '%s'", part.Type)
|
|
}
|
|
|
|
if err := part.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Validate URL protocol if URL is provided - only allow data:, http:, https:
|
|
if part.URL != "" {
|
|
if !strings.HasPrefix(part.URL, "data:") &&
|
|
!strings.HasPrefix(part.URL, "http://") &&
|
|
!strings.HasPrefix(part.URL, "https://") {
|
|
return nil, fmt.Errorf("unsupported URL protocol in file part: %s", part.URL)
|
|
}
|
|
}
|
|
|
|
// Branch on mimetype to determine block type and constraints
|
|
switch {
|
|
case strings.HasPrefix(part.MimeType, "image/"):
|
|
// image/* (jpeg, png, gif, webp) → Anthropic image block
|
|
if hasInlineData(part) {
|
|
// Data available → use base64 source
|
|
base64Data, err := extractBase64Data(part)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &anthropicMessageContentBlock{
|
|
Type: "image",
|
|
Source: &anthropicSource{
|
|
Type: "base64",
|
|
Data: base64Data,
|
|
MediaType: part.MimeType,
|
|
FileName: part.FileName,
|
|
},
|
|
SourcePreviewUrl: part.PreviewUrl,
|
|
}, nil
|
|
} else {
|
|
// HTTP/HTTPS URL → url source (no media_type for image URLs)
|
|
return &anthropicMessageContentBlock{
|
|
Type: "image",
|
|
Source: &anthropicSource{
|
|
Type: "url",
|
|
URL: part.URL,
|
|
FileName: part.FileName,
|
|
},
|
|
SourcePreviewUrl: part.PreviewUrl,
|
|
}, nil
|
|
}
|
|
|
|
case part.MimeType == "application/pdf":
|
|
// application/pdf → Anthropic document block
|
|
if hasInlineData(part) {
|
|
// Data available → use base64 source
|
|
base64Data, err := extractBase64Data(part)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &anthropicMessageContentBlock{
|
|
Type: "document",
|
|
Source: &anthropicSource{
|
|
Type: "base64",
|
|
Data: base64Data,
|
|
MediaType: part.MimeType,
|
|
FileName: part.FileName,
|
|
},
|
|
SourcePreviewUrl: part.PreviewUrl,
|
|
}, nil
|
|
} else {
|
|
// HTTP/HTTPS URL → url source (no media_type for URL sources)
|
|
return &anthropicMessageContentBlock{
|
|
Type: "document",
|
|
Source: &anthropicSource{
|
|
Type: "url",
|
|
URL: part.URL,
|
|
FileName: part.FileName,
|
|
},
|
|
SourcePreviewUrl: part.PreviewUrl,
|
|
}, nil
|
|
}
|
|
|
|
case part.MimeType == "text/plain":
|
|
// text/plain → Anthropic document block, but NO URL form supported
|
|
if hasInlineData(part) {
|
|
var textData string
|
|
if len(part.Data) > 0 {
|
|
// Raw data → convert to string directly
|
|
textData = string(part.Data)
|
|
} else {
|
|
// Data URL → extract base64 data and decode back to string
|
|
base64Data, err := extractBase64Data(part)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(base64Data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode base64 data: %w", err)
|
|
}
|
|
textData = string(decoded)
|
|
}
|
|
return &anthropicMessageContentBlock{
|
|
Type: "document",
|
|
Source: &anthropicSource{
|
|
Type: "text",
|
|
Data: textData,
|
|
MediaType: part.MimeType,
|
|
FileName: part.FileName,
|
|
},
|
|
}, nil
|
|
} else {
|
|
// HTTP/HTTPS URL → not supported inline, would need to fetch
|
|
return nil, fmt.Errorf("text/plain file with URL not supported (must be fetched and converted to base64 or uploaded to Files API)")
|
|
}
|
|
|
|
default:
|
|
// Other media types → not supported inline, must upload and use file_id
|
|
return nil, fmt.Errorf("unsupported media type '%s' (must be uploaded to Files API and sent as file_id)", part.MimeType)
|
|
}
|
|
}
|
|
|
|
// ConvertToUIMessage converts an anthropicChatMessage to a UIMessage
|
|
func (m *anthropicChatMessage) ConvertToUIMessage() *uctypes.UIMessage {
|
|
var parts []uctypes.UIMessagePart
|
|
|
|
// Iterate over all content blocks
|
|
for _, block := range m.Content {
|
|
switch block.Type {
|
|
case "text":
|
|
// Convert text blocks to UIMessagePart
|
|
parts = append(parts, uctypes.UIMessagePart{
|
|
Type: "text",
|
|
Text: block.Text,
|
|
})
|
|
case "image":
|
|
// Convert image blocks to data-userfile UIMessagePart (only for user role)
|
|
if m.Role == "user" && block.Source != nil {
|
|
parts = append(parts, uctypes.UIMessagePart{
|
|
Type: "data-userfile",
|
|
Data: uctypes.UIMessageDataUserFile{
|
|
FileName: block.Source.FileName,
|
|
Size: block.Source.Size,
|
|
MimeType: block.Source.MediaType,
|
|
PreviewUrl: block.SourcePreviewUrl,
|
|
},
|
|
})
|
|
}
|
|
case "document":
|
|
// Convert document blocks to data-userfile UIMessagePart (only for user role)
|
|
if m.Role == "user" && block.Source != nil {
|
|
parts = append(parts, uctypes.UIMessagePart{
|
|
Type: "data-userfile",
|
|
Data: uctypes.UIMessageDataUserFile{
|
|
FileName: block.Source.FileName,
|
|
Size: block.Source.Size,
|
|
MimeType: block.Source.MediaType,
|
|
PreviewUrl: block.SourcePreviewUrl,
|
|
},
|
|
})
|
|
}
|
|
case "tool_use":
|
|
// Convert tool_use blocks to tool UIMessagePart with input-available state
|
|
if block.Name != "" && block.ID != "" {
|
|
parts = append(parts, uctypes.UIMessagePart{
|
|
Type: "tool-" + block.Name,
|
|
State: "input-available",
|
|
ToolCallID: block.ID,
|
|
Input: block.Input,
|
|
})
|
|
}
|
|
default:
|
|
// For now, skip all other types (will implement later)
|
|
continue
|
|
}
|
|
}
|
|
|
|
if len(parts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
return &uctypes.UIMessage{
|
|
ID: m.MessageId,
|
|
Role: m.Role,
|
|
Parts: parts,
|
|
}
|
|
}
|
|
|
|
// convertMessageForAPI creates a copy of the anthropicInputMessage with internal fields stripped from content blocks
|
|
func convertMessageForAPI(msg anthropicInputMessage) anthropicInputMessage {
|
|
// Create a copy of the message
|
|
converted := anthropicInputMessage{
|
|
Role: msg.Role,
|
|
Content: make([]anthropicMessageContentBlock, len(msg.Content)),
|
|
}
|
|
|
|
// Copy each content block and clean it (strips internal fields)
|
|
for i, block := range msg.Content {
|
|
converted.Content[i] = *block.Clean()
|
|
}
|
|
|
|
return converted
|
|
}
|
|
|
|
// ConvertToolResultsToAnthropicChatMessage converts AIToolResult slice to anthropicChatMessage
|
|
func ConvertToolResultsToAnthropicChatMessage(toolResults []uctypes.AIToolResult) (*anthropicChatMessage, error) {
|
|
if len(toolResults) == 0 {
|
|
return nil, errors.New("toolResults cannot be empty")
|
|
}
|
|
|
|
var contentBlocks []anthropicMessageContentBlock
|
|
|
|
for _, result := range toolResults {
|
|
if result.ToolUseID == "" {
|
|
return nil, fmt.Errorf("tool result missing ToolUseID")
|
|
}
|
|
|
|
var content interface{}
|
|
var isError bool
|
|
|
|
if result.ErrorText != "" {
|
|
content = result.ErrorText
|
|
isError = true
|
|
} else {
|
|
// Check if text looks like an image data URL
|
|
if strings.HasPrefix(result.Text, "data:image/") {
|
|
// Parse the data URL to extract media type and base64 data
|
|
parts := strings.SplitN(result.Text, ",", 2)
|
|
if len(parts) == 2 {
|
|
// Extract media type from "data:image/png;base64"
|
|
mediaTypePart := strings.TrimPrefix(parts[0], "data:")
|
|
mediaType := strings.Split(mediaTypePart, ";")[0]
|
|
|
|
// Create content as array with image block
|
|
content = []anthropicMessageContentBlock{
|
|
{
|
|
Type: "image",
|
|
Source: &anthropicSource{
|
|
Type: "base64",
|
|
Data: parts[1],
|
|
MediaType: mediaType,
|
|
},
|
|
},
|
|
}
|
|
isError = false
|
|
} else {
|
|
// Failed to parse data URL
|
|
content = "failed to parse image data URL"
|
|
isError = true
|
|
}
|
|
} else {
|
|
content = result.Text
|
|
isError = false
|
|
}
|
|
}
|
|
|
|
contentBlocks = append(contentBlocks, anthropicMessageContentBlock{
|
|
Type: "tool_result",
|
|
ToolUseID: result.ToolUseID,
|
|
Content: content,
|
|
IsError: isError,
|
|
})
|
|
}
|
|
|
|
return &anthropicChatMessage{
|
|
MessageId: uuid.New().String(),
|
|
Role: "user",
|
|
Content: contentBlocks,
|
|
}, nil
|
|
}
|
|
|
|
// ConvertAIChatToUIChat converts an AIChat to a UIChat for Anthropic
|
|
func ConvertAIChatToUIChat(aiChat uctypes.AIChat) (*uctypes.UIChat, error) {
|
|
if aiChat.APIType != uctypes.APIType_AnthropicMessages {
|
|
return nil, fmt.Errorf("APIType must be '%s', got '%s'", uctypes.APIType_AnthropicMessages, aiChat.APIType)
|
|
}
|
|
|
|
uiMessages := make([]uctypes.UIMessage, 0, len(aiChat.NativeMessages))
|
|
|
|
for i, nativeMsg := range aiChat.NativeMessages {
|
|
anthropicMsg, ok := nativeMsg.(*anthropicChatMessage)
|
|
if !ok {
|
|
return nil, fmt.Errorf("message %d: expected *anthropicChatMessage, got %T", i, nativeMsg)
|
|
}
|
|
|
|
uiMsg := anthropicMsg.ConvertToUIMessage()
|
|
if uiMsg != nil {
|
|
uiMessages = append(uiMessages, *uiMsg)
|
|
}
|
|
}
|
|
|
|
return &uctypes.UIChat{
|
|
ChatId: aiChat.ChatId,
|
|
APIType: aiChat.APIType,
|
|
Model: aiChat.Model,
|
|
APIVersion: aiChat.APIVersion,
|
|
Messages: uiMessages,
|
|
}, nil
|
|
}
|