import shim from '../../../shim'; import JoplinError from '../../../JoplinError'; import Logger from '@joplin/utils/Logger'; import { ChatFinishReason, ChatMessage, ChatOptions, ChatResult, ChatRole, ChatToolCall, ChatToolMessage, ProviderClassification } from '../types'; import ChatProviderBase from './ChatProviderBase'; const logger = Logger.create('AnthropicProvider'); interface AnthropicUsage { input_tokens?: number; output_tokens?: number; } interface AnthropicTextContentBlock { type: 'text'; text: string; } interface AnthropicToolUseContentBlock { type: 'tool_use'; id: string; name: string; input: Record; } interface AnthropicImageContent { type: 'image'; source: { type: 'base64'; media_type: string; data: string; }; } interface AnthropicToolResultContentBlock { type: 'tool_result'; tool_use_id: string; content: string|AnthropicImageContent[]; } interface AnthropicThinkingContentBlock { type: 'thinking'; thinking: string; } type AnthropicContentBlock = AnthropicToolUseContentBlock|AnthropicToolResultContentBlock|AnthropicTextContentBlock|AnthropicThinkingContentBlock; interface AnthropicResponse { content?: AnthropicContentBlock[]; usage?: AnthropicUsage; stop_reason?: string; error?: { message?: string }; } const toChatFinishReason = (reason: string|undefined): ChatFinishReason|undefined => { if (!reason) return undefined; if (reason === 'max_tokens') return 'length'; if (reason === 'end_turn' || reason === 'stop_sequence') return 'stop'; if (reason === 'tool_use') return 'tool_calls'; return 'other'; }; interface Options { apiKey: string; model: string; } interface AnthropicMessage { role: 'user'|'assistant'; content: string|AnthropicContentBlock[]; } // Anthropic requires tool results to come immediately after their calls, with parallel tool calls in the same block. This block merges adjacent tool calls. const fixToolResults = (messages: AnthropicMessage[]) => { const result: (AnthropicMessage|null)[] = [...messages]; const getPrevious = (index: number) => { for (let i = index - 1; i >= 0; i--) { if (!result[i]) continue; return result[i]; } return null; }; let previousHadToolResults = false; for (let i = 0; i < result.length; i++) { const previous = getPrevious(i); const message = result[i]; if (!message) continue; let hasToolResults = false; if (Array.isArray(message.content) && previous) { const toolResults = message.content.filter(item => item.type === 'tool_result'); hasToolResults ||= toolResults.length > 0; if (hasToolResults && previousHadToolResults) { if (!Array.isArray(previous.content)) throw new Error('Invalid state: Expected previous.content to be an array'); previous.content = [...previous.content, ...toolResults]; message.content = message.content.filter(item => item.type !== 'tool_result'); // Avoid empty user messages if (message.content.length === 0) { result[i] = null; } } } previousHadToolResults = hasToolResults; } return result.filter(item => !!item); }; const convertMessages = (messages: ChatMessage[]) => { const convertToolCall = (toolCall: ChatToolCall) => { return { type: 'tool_use' as const, name: toolCall.toolName, id: toolCall.callId, input: toolCall.arguments, }; }; const convertToolResponse = (message: ChatToolMessage): AnthropicMessage => { let content; if (typeof message.content === 'string') { content = message.content; } else { content = [{ type: 'image' as const, source: { type: 'base64' as const, media_type: message.content.mimeType, data: message.content.base64Only, }, }]; } return { role: 'user', content: [ { type: 'tool_result', tool_use_id: message.toolCallId, content, ...(message.isError ? { is_error: true } : {}), }, ], }; }; const result = messages .map((message): AnthropicMessage => { if (message.role === ChatRole.System) return null; if (message.role === ChatRole.Tool) { return convertToolResponse(message); } else if (message.role === ChatRole.Assistant || message.role === ChatRole.User) { return { role: message.role, content: message.toolCalls ? [ message.content ? { type: 'text' as const, text: message.content } : null, ...message.toolCalls.map(convertToolCall), ].filter(item => !!item) : message.content, }; } const exhaustivenessCheck: never = message.role; throw new Error(`Unsupported role ${exhaustivenessCheck}`); }) .filter(m => !!m); const systemMessages = messages.filter(m => m.role === 'system').map(m => m.content); const turnMessages = fixToolResults(result); return { systemMessages, turnMessages }; }; // Anthropic requires max_tokens. We pick a sensible default if the caller // doesn't supply one, so plugins don't have to remember a provider-specific // requirement. const DEFAULT_MAX_TOKENS = 4096; export default class AnthropicProvider extends ChatProviderBase { public id = 'anthropic'; public classification: ProviderClassification = 'remote'; private apiKey_: string; private model_: string; public constructor(options: Options) { super(); this.apiKey_ = options.apiKey; this.model_ = options.model; } protected async doChat(messages: ChatMessage[], options?: ChatOptions): Promise { if (!this.apiKey_) throw new JoplinError('Anthropic provider has no API key configured', 'aiProviderNotConfigured'); if (!this.model_) throw new JoplinError('Anthropic provider has no model configured', 'aiProviderNotConfigured'); // Anthropic's API separates system prompts from the messages array. const { systemMessages, turnMessages } = convertMessages(messages); const body: Record = { model: this.model_, messages: turnMessages, max_tokens: options?.maxTokens ?? DEFAULT_MAX_TOKENS, }; if (systemMessages.length) body.system = systemMessages.join('\n\n'); if (options?.temperature !== undefined) body.temperature = options.temperature; if (options?.responseFormat?.type === 'json_schema') { // Anthropic's API accepts the schema property directly: const schema = options.responseFormat.json_schema.schema; body.output_config = { format: { type: options.responseFormat.type, schema, }, }; } if (options?.tools) { body.tools = options.tools.map(tool => { return { name: tool.id, description: tool.description, input_schema: tool.inputSchema, }; }); } const doRequest = async () => { const response = await shim.fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'x-api-key': this.apiKey_, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json', }, body: JSON.stringify(body), signal: options.signal, }); return { response, text: await response.text() }; }; // Older Anthropic models may not support the "output_config" property: let { response, text } = await doRequest(); if (response.status === 400 && body.output_config && /output_config|json_schema/.test(text)) { logger.warn(`Model ${this.model_} rejected output_config; retrying without structured output schema.`); delete body.output_config; ({ response, text } = await doRequest()); } let json: AnthropicResponse; try { json = JSON.parse(text) as AnthropicResponse; } catch { throw new JoplinError(`Anthropic returned non-JSON response: ${text.slice(0, 200)}`, response.status); } if (response.status >= 400) { const detail = json?.error?.message ? `: ${json.error.message}` : ''; throw new JoplinError(`Anthropic returned ${response.status}${detail}`, response.status); } const inputTokens = json.usage?.input_tokens ?? 0; const outputTokens = json.usage?.output_tokens ?? 0; const toolCalls: ChatToolCall[] = []; const textMessages = []; const thinkingMessages = []; for (const response of json.content) { if (response.type === 'tool_use' && typeof response.input === 'object') { toolCalls.push({ callId: response.id, toolName: response.name, arguments: response.input, parseError: null, }); } else if (response.type === 'text' && typeof response.text === 'string') { textMessages.push(response.text); } else if (response.type === 'thinking' && typeof response.thinking === 'string') { thinkingMessages.push(response.thinking); } } return { text: textMessages.join(''), toolCalls, usage: { inputTokens, outputTokens }, finishReason: toChatFinishReason(json.stop_reason), reasoningText: thinkingMessages.length ? thinkingMessages.join('') : undefined, }; } } // For testing export const _internal = { convertMessages };