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