Path: blob/main/extensions/copilot/src/extension/byok/common/anthropicMessageConverter.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import { ContentBlockParam, ImageBlockParam, MessageParam, RedactedThinkingBlockParam, TextBlockParam, ThinkingBlockParam } from '@anthropic-ai/sdk/resources';5import { Raw } from '@vscode/prompt-tsx';6import type { LanguageModelChatMessage } from 'vscode';7import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpointTypes';8import { isDefined } from '../../../util/vs/base/common/types';9import { LanguageModelChatMessageRole, LanguageModelDataPart, LanguageModelTextPart, LanguageModelThinkingPart, LanguageModelToolCallPart, LanguageModelToolResultPart, LanguageModelToolResultPart2 } from '../../../vscodeTypes';1011function apiContentToAnthropicContent(content: (LanguageModelTextPart | LanguageModelToolResultPart | LanguageModelToolCallPart | LanguageModelDataPart | LanguageModelThinkingPart)[]): ContentBlockParam[] {12const convertedContent: ContentBlockParam[] = [];1314for (const part of content) {15if (part instanceof LanguageModelThinkingPart) {16// Check if this is a redacted thinking block17if (part.metadata?.redactedData) {18convertedContent.push({19type: 'redacted_thinking',20data: part.metadata.redactedData,21});22} else if (part.metadata?._completeThinking) {23// Only push thinking block when we have the complete thinking marker24convertedContent.push({25type: 'thinking',26thinking: part.metadata._completeThinking,27signature: part.metadata.signature || '',28});29}30// Skip incremental thinking parts - we only care about the complete one31} else if (part instanceof LanguageModelToolCallPart) {32convertedContent.push({33type: 'tool_use',34id: part.callId,35input: part.input,36name: part.name,37});38} else if (part instanceof LanguageModelDataPart && part.mimeType === CustomDataPartMimeTypes.CacheControl && part.data.toString() === 'ephemeral') {39const previousBlock = convertedContent.at(-1);40if (previousBlock && contentBlockSupportsCacheControl(previousBlock)) {41previousBlock.cache_control = { type: 'ephemeral' };42} else {43// Empty string is invalid44convertedContent.push({45type: 'text',46text: ' ',47cache_control: { type: 'ephemeral' }48});49}50} else if (part instanceof LanguageModelDataPart) {51if (part.mimeType !== CustomDataPartMimeTypes.StatefulMarker) {52convertedContent.push({53type: 'image',54source: {55type: 'base64',56data: Buffer.from(part.data).toString('base64'),57media_type: part.mimeType as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp'58}59});60}61} else if (part instanceof LanguageModelToolResultPart || part instanceof LanguageModelToolResultPart2) {62convertedContent.push({63type: 'tool_result',64tool_use_id: part.callId,65content: part.content.map((p): TextBlockParam | ImageBlockParam | undefined => {66if (p instanceof LanguageModelTextPart) {67return { type: 'text', text: p.value };68} else if (p instanceof LanguageModelDataPart && p.mimeType === CustomDataPartMimeTypes.CacheControl && p.data.toString() === 'ephemeral') {69// Empty string is invalid70return { type: 'text', text: ' ', cache_control: { type: 'ephemeral' } };71} else if (p instanceof LanguageModelDataPart) {72return { type: 'image', source: { type: 'base64', media_type: p.mimeType as any, data: Buffer.from(p.data).toString('base64') } };73}74}).filter(isDefined),75});76} else {77// Anthropic errors if we have text parts with empty string text content78if (part.value === '') {79continue;80}81convertedContent.push({82type: 'text',83text: part.value84});85}86}87return convertedContent;88}8990export function apiMessageToAnthropicMessage(messages: LanguageModelChatMessage[]): { messages: MessageParam[]; system: TextBlockParam } {91const unmergedMessages: MessageParam[] = [];92const systemMessage: TextBlockParam = {93type: 'text',94text: ''95};96for (const message of messages) {97if (message.role === LanguageModelChatMessageRole.Assistant) {98unmergedMessages.push({99role: 'assistant',100content: apiContentToAnthropicContent(message.content),101});102} else if (message.role === LanguageModelChatMessageRole.User) {103unmergedMessages.push({104role: 'user',105content: apiContentToAnthropicContent(message.content),106});107} else {108systemMessage.text += message.content.map(p => {109// For some reason instance of doesn't work110if (p instanceof LanguageModelTextPart) {111return p.value;112} else if (p instanceof LanguageModelDataPart && p.mimeType === CustomDataPartMimeTypes.CacheControl && p.data.toString() === 'ephemeral') {113systemMessage.cache_control = { type: 'ephemeral' };114}115return '';116}).join('');117}118}119120// Merge messages of the same type that are adjacent together, this is what anthropic expects121const mergedMessages: MessageParam[] = [];122for (const message of unmergedMessages) {123if (mergedMessages.length === 0 || mergedMessages[mergedMessages.length - 1].role !== message.role) {124mergedMessages.push(message);125} else {126// Merge with the previous message of the same role127const prevMessage = mergedMessages[mergedMessages.length - 1];128// Concat the content arrays if they're both arrays - They always will be due to the way apiContentToAnthropicContent works129if (Array.isArray(prevMessage.content) && Array.isArray(message.content)) {130(prevMessage.content as ContentBlockParam[]).push(...(message.content as ContentBlockParam[]));131}132}133}134return { messages: mergedMessages, system: systemMessage };135}136137function contentBlockSupportsCacheControl(block: ContentBlockParam): block is Exclude<ContentBlockParam, | ThinkingBlockParam | RedactedThinkingBlockParam> {138return block.type !== 'thinking' && block.type !== 'redacted_thinking';139}140141export function anthropicMessagesToRawMessagesForLogging(messages: MessageParam[], system: TextBlockParam): Raw.ChatMessage[] {142// Start with full-fidelity conversion, then sanitize for logging143const fullMessages = anthropicMessagesToRawMessages(messages, system);144145// Replace bulky content with placeholders146return fullMessages.map(message => {147const content = message.content.map(part => {148if (part.type === Raw.ChatCompletionContentPartKind.Image) {149// Replace actual image URLs with placeholder for logging150return {151...part,152imageUrl: { url: '(image)' }153};154}155return part;156});157158if (message.role === Raw.ChatRole.Tool) {159// Replace tool result content with placeholder for logging160return {161...message,162content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: '(tool result)' }]163};164}165166return {167...message,168content169};170});171}172173/**174* Full-fidelity conversion of Anthropic MessageParam[] + system to Raw.ChatMessage[] suitable for sending to endpoints.175* Compared to the logging variant, this preserves tool_result content and image data (as data URLs when possible).176*/177export function anthropicMessagesToRawMessages(messages: MessageParam[], system: TextBlockParam): Raw.ChatMessage[] {178const rawMessages: Raw.ChatMessage[] = [];179180if (system) {181const systemContent: Raw.ChatCompletionContentPart[] = [];182if (system.text) {183systemContent.push({ type: Raw.ChatCompletionContentPartKind.Text, text: system.text });184}185if (system.cache_control) {186systemContent.push({ type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, cacheType: system.cache_control.type });187}188if (systemContent.length) {189rawMessages.push({ role: Raw.ChatRole.System, content: systemContent });190}191}192193for (const message of messages) {194const content: Raw.ChatCompletionContentPart[] = [];195let toolCalls: Raw.ChatMessageToolCall[] | undefined;196let toolCallId: string | undefined;197198const toRawImage = (img: ImageBlockParam): Raw.ChatCompletionContentPartImage | undefined => {199if (img.source.type === 'base64') {200return { type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: `data:${img.source.media_type};base64,${img.source.data}` } };201} else if (img.source.type === 'url') {202return { type: Raw.ChatCompletionContentPartKind.Image, imageUrl: { url: img.source.url } };203}204};205206const pushImage = (img: ImageBlockParam) => {207const imagePart = toRawImage(img);208if (imagePart) {209content.push(imagePart);210}211};212213const pushCache = (block?: ContentBlockParam) => {214if (block && contentBlockSupportsCacheControl(block) && block.cache_control) {215content.push({ type: Raw.ChatCompletionContentPartKind.CacheBreakpoint, cacheType: block.cache_control.type });216}217};218219if (Array.isArray(message.content)) {220for (const block of message.content) {221if (block.type === 'text') {222content.push({ type: Raw.ChatCompletionContentPartKind.Text, text: block.text });223pushCache(block);224} else if (block.type === 'image') {225pushImage(block);226pushCache(block);227} else if (block.type === 'thinking') {228// Include thinking content for logging229content.push({230type: Raw.ChatCompletionContentPartKind.Text,231text: `[THINKING: ${block.thinking}]`232});233} else if (block.type === 'redacted_thinking') {234content.push({235type: Raw.ChatCompletionContentPartKind.Text,236text: '[REDACTED THINKING]'237});238} else if (block.type === 'tool_use') {239// tool_use appears in assistant messages; represent as toolCalls on assistant message240toolCalls ??= [];241toolCalls.push({242id: block.id,243type: 'function',244function: { name: block.name, arguments: JSON.stringify(block.input ?? {}) }245});246// no content part, tool call is separate247pushCache(block);248} else if (block.type === 'tool_result') {249// tool_result appears in user role; we'll emit a Raw.Tool message later with this toolCallId and content250toolCallId = block.tool_use_id;251// Translate tool result content to raw parts252const toolContent: Raw.ChatCompletionContentPart[] = [];253if (typeof block.content === 'string') {254toolContent.push({ type: Raw.ChatCompletionContentPartKind.Text, text: block.content });255} else {256for (const c of block.content ?? []) {257if (c.type === 'text') {258toolContent.push({ type: Raw.ChatCompletionContentPartKind.Text, text: c.text });259} else if (c.type === 'image') {260const imagePart = toRawImage(c);261if (imagePart) {262toolContent.push(imagePart);263}264}265}266}267// Emit the tool result message now and continue to next message268rawMessages.push({ role: Raw.ChatRole.Tool, content: toolContent.length ? toolContent : [{ type: Raw.ChatCompletionContentPartKind.Text, text: '' }], toolCallId });269toolCallId = undefined;270} else {271// thinking or unsupported types are ignored272}273}274} else if (typeof message.content === 'string') {275content.push({ type: Raw.ChatCompletionContentPartKind.Text, text: message.content });276}277278if (message.role === 'assistant') {279const msg: Raw.AssistantChatMessage = { role: Raw.ChatRole.Assistant, content };280if (toolCalls && toolCalls.length > 0) {281msg.toolCalls = toolCalls;282}283rawMessages.push(msg);284} else if (message.role === 'user') {285// note: tool_result handled earlier; here we push standard user content if any286if (content.length) {287rawMessages.push({ role: Raw.ChatRole.User, content });288}289}290}291292return rawMessages;293}294295296