文件
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

296 行
8.6 KiB
TypeScript

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<string, unknown>;
}
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<ChatResult> {
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<string, unknown> = {
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 };