import type { Model } from "~/services/copilot/get-models" import { COMPACT_AUTO_CONTINUE, COMPACT_REQUEST, compactAutoContinuePromptStarts, compactMessageSections, compactSummaryPromptStart, compactSystemPromptStarts, compactTextOnlyGuard, type CompactType, } from "~/lib/compact" import { getReasoningEffortForModel } from "~/lib/config" import type { AnthropicAssistantContentBlock, AnthropicCacheControl, AnthropicDocumentBlock, AnthropicImageBlock, AnthropicMessage, AnthropicMessagesPayload, AnthropicTextBlock, AnthropicToolResultBlock, AnthropicToolResultContentBlock, AnthropicUserContentBlock, } from "./anthropic-types" export const TOOL_REFERENCE_TURN_BOUNDARY = "Tool loaded." const IDE_EXECUTE_CODE_TOOL = "mcp__ide__executeCode" const IDE_GET_DIAGNOSTICS_TOOL = "mcp__ide__getDiagnostics" const IDE_GET_DIAGNOSTICS_DESCRIPTION = "Get language diagnostics from VS Code. Returns errors, warnings, information, and hints for files in the workspace." const PDF_FILE_READ_PREFIX = "PDF file read:" type AnthropicAttachmentBlock = AnthropicImageBlock | AnthropicDocumentBlock type AnthropicMessageContentBlock = | AnthropicUserContentBlock | AnthropicAssistantContentBlock type IndexedAttachment = { attachment: AnthropicAttachmentBlock order: number } const getBlockCacheControl = ( block: AnthropicMessageContentBlock | undefined, ): AnthropicCacheControl | undefined => { if (!block || block.type === "thinking") { return undefined } const cacheControl = block.cache_control if (!cacheControl || typeof cacheControl !== "object") { return } return cacheControl } export const getLastMessageContentCacheControl = ( lastMessage: AnthropicMessage | undefined, ): AnthropicCacheControl | undefined => { if (!lastMessage || !Array.isArray(lastMessage.content)) { return undefined } const cacheControl = getBlockCacheControl(lastMessage.content.at(-1)) return cacheControl ? { ...cacheControl } : undefined } // Apply the original last message tail's cache_control to the rewritten tail. If // the original tail was not marked, fall back to a default ephemeral marker. export const applyLastMessageCacheControl = ( anthropicPayload: AnthropicMessagesPayload, lastMessageCacheControl: AnthropicCacheControl | undefined, ): void => { const cacheControl = lastMessageCacheControl ?? { type: "ephemeral", } const lastMessage = anthropicPayload.messages.at(-1) if (!lastMessage || !Array.isArray(lastMessage.content)) { return } const lastBlock = lastMessage.content.at(-1) if (!lastBlock || lastBlock.type === "thinking" || lastBlock.cache_control) { return } lastBlock.cache_control = { ...cacheControl } } const getCompactCandidateText = (message: AnthropicMessage): string => { if (message.role !== "user") { return "" } if (typeof message.content === "string") { return message.content } return message.content .filter((block): block is AnthropicTextBlock => block.type === "text") .map((block) => block.text.startsWith("") ? "" : block.text, ) .filter((text) => text.length > 0) .join("\n\n") } const isCompactMessage = (lastMessage: AnthropicMessage): boolean => { const text = getCompactCandidateText(lastMessage) if (!text) { return false } return ( text.includes(compactTextOnlyGuard) && text.includes(compactSummaryPromptStart) && compactMessageSections.some((section) => text.includes(section)) ) } const isCompactAutoContinueMessage = ( lastMessage: AnthropicMessage, ): boolean => { const text = getCompactCandidateText(lastMessage) return ( Boolean(text) && compactAutoContinuePromptStarts.some((promptStart) => text.startsWith(promptStart), ) ) } export const getCompactType = ( anthropicPayload: AnthropicMessagesPayload, ): CompactType => { const lastMessage = anthropicPayload.messages.at(-1) if (lastMessage && isCompactMessage(lastMessage)) { return COMPACT_REQUEST } if (lastMessage && isCompactAutoContinueMessage(lastMessage)) { return COMPACT_AUTO_CONTINUE } const system = anthropicPayload.system if (typeof system === "string") { const hasCompactSystemPrompt = compactSystemPromptStarts.some( (promptStart) => system.startsWith(promptStart), ) return hasCompactSystemPrompt ? COMPACT_REQUEST : 0 } if (!Array.isArray(system)) return 0 const hasCompactSystemPrompt = system.some( (msg) => typeof msg.text === "string" && compactSystemPromptStarts.some((promptStart) => msg.text.startsWith(promptStart), ), ) if (hasCompactSystemPrompt) { return COMPACT_REQUEST } return 0 } const mergeContentWithText = ( tr: AnthropicToolResultBlock, textBlock: AnthropicTextBlock, ): AnthropicToolResultBlock => { if (typeof tr.content === "string") { return { ...tr, content: `${tr.content}\n\n${textBlock.text}` } } // Unable to merge, discard other text blocks, wait for the next round of re-request if (hasToolRef(tr)) { return tr } return { ...tr, content: [...tr.content, stripContentBlockCacheControl(textBlock)], } } const mergeContentWithTexts = ( tr: AnthropicToolResultBlock, textBlocks: Array, ): AnthropicToolResultBlock => { if (typeof tr.content === "string") { const appendedTexts = textBlocks.map((tb) => tb.text).join("\n\n") return { ...tr, content: `${tr.content}\n\n${appendedTexts}` } } // Unable to merge, discard other text blocks, wait for the next round of re-request if (hasToolRef(tr)) { return tr } return { ...tr, content: [...tr.content, ...textBlocks.map(stripContentBlockCacheControl)], } } const mergeContentWithAttachments = ( tr: AnthropicToolResultBlock, attachments: Array, ): AnthropicToolResultBlock => { const cleanAttachments = attachments.map(stripContentBlockCacheControl) if (typeof tr.content === "string") { return { ...tr, content: [{ type: "text", text: tr.content }, ...cleanAttachments], } } return { ...tr, content: [...tr.content, ...cleanAttachments], } } const stripContentBlockCacheControl = < T extends AnthropicToolResultContentBlock, >( block: T, ): T => { if (!Object.hasOwn(block, "cache_control")) { return block } const copy = { ...block } delete copy.cache_control return copy } const isAttachmentBlock = ( block: AnthropicUserContentBlock, ): block is AnthropicAttachmentBlock => { return block.type === "image" || block.type === "document" } const getMergeableToolResultIndices = ( toolResults: Array, ): Array => { return toolResults.flatMap((block, index) => block.is_error || hasToolRef(block) ? [] : [index], ) } const mergeAttachmentsIntoToolResults = ( toolResults: Array, attachmentsByToolResultIndex: Map>, ): Array => { if (attachmentsByToolResultIndex.size === 0) { return toolResults } return toolResults.map((block, index) => { const matchedAttachments = attachmentsByToolResultIndex.get(index) if (!matchedAttachments) { return block } const orderedAttachments = [...matchedAttachments] .sort((left, right) => left.order - right.order) .map(({ attachment }) => attachment) return mergeContentWithAttachments(block, orderedAttachments) }) } const assignAttachmentsToToolResults = ( target: Map>, attachments: Array, options: { toolResultIndices: Array fallbackToolResultIndices?: Array }, ): void => { const { toolResultIndices } = options const fallbackToolResultIndices = options.fallbackToolResultIndices ?? toolResultIndices if (attachments.length === 0) { return } if ( toolResultIndices.length > 0 && toolResultIndices.length === attachments.length ) { for (const [index, toolResultIndex] of toolResultIndices.entries()) { const currentAttachments = target.get(toolResultIndex) if (currentAttachments) { currentAttachments.push(attachments[index]) continue } target.set(toolResultIndex, [attachments[index]]) } return } const lastToolResultIndex = fallbackToolResultIndices.at(-1) if (lastToolResultIndex === undefined) { return } const currentAttachments = target.get(lastToolResultIndex) if (currentAttachments) { currentAttachments.push(...attachments) return } target.set(lastToolResultIndex, [...attachments]) } const startsWithPdfFileRead = ( toolResult: AnthropicToolResultBlock, ): boolean => { if (typeof toolResult.content === "string") { return toolResult.content.startsWith(PDF_FILE_READ_PREFIX) } if (toolResult.content.some((block) => block.type === "document")) { return false } if (toolResult.content.length === 0) { return false } const firstBlock = toolResult.content[0] if (firstBlock.type !== "text") { return false } return firstBlock.text.startsWith(PDF_FILE_READ_PREFIX) } const collectMergeableUserContent = ( content: Array, ): { toolResults: Array textBlocks: Array attachments: Array } | null => { const toolResults: Array = [] const textBlocks: Array = [] const attachments: Array = [] for (const [order, block] of content.entries()) { if (block.type === "tool_result") { toolResults.push(block) continue } if (block.type === "text") { textBlocks.push(block) continue } if (isAttachmentBlock(block)) { attachments.push({ attachment: block, order }) continue } return null } return { toolResults, textBlocks, attachments, } } const mergeAttachmentsForToolResults = ( toolResults: Array, attachments: Array, ): Array => { if (attachments.length === 0) { return toolResults } const documentBlocks = attachments.filter( ({ attachment }) => attachment.type === "document", ) const mergeableToolResultIndices = getMergeableToolResultIndices(toolResults) const pdfReadToolResultIndices = mergeableToolResultIndices.filter((index) => startsWithPdfFileRead(toolResults[index]), ) const attachmentsByToolResultIndex = new Map< number, Array >() let remainingAttachments = attachments let countMatchToolResultIndices = mergeableToolResultIndices // Match PDF read tool results and documents in order first, then leave any // unmatched documents to the generic fallback path below. if (documentBlocks.length > 0 && pdfReadToolResultIndices.length > 0) { const matchedDocumentCount = Math.min( pdfReadToolResultIndices.length, documentBlocks.length, ) const matchedDocuments = documentBlocks.slice(0, matchedDocumentCount) const matchedDocumentOrders = new Set( matchedDocuments.map(({ order }) => order), ) const matchedPdfToolResultIndices = pdfReadToolResultIndices.slice( 0, matchedDocumentCount, ) const matchedPdfToolResultIndexSet = new Set(matchedPdfToolResultIndices) assignAttachmentsToToolResults( attachmentsByToolResultIndex, matchedDocuments, { toolResultIndices: matchedPdfToolResultIndices, }, ) countMatchToolResultIndices = mergeableToolResultIndices.filter( (index) => !matchedPdfToolResultIndexSet.has(index), ) remainingAttachments = attachments.filter( ({ attachment, order }) => attachment.type !== "document" || !matchedDocumentOrders.has(order), ) } // Everything else keeps the existing count-match / last-tool-result fallback. assignAttachmentsToToolResults( attachmentsByToolResultIndex, remainingAttachments, { toolResultIndices: countMatchToolResultIndices, fallbackToolResultIndices: mergeableToolResultIndices, }, ) return mergeAttachmentsIntoToolResults( toolResults, attachmentsByToolResultIndex, ) } const mergeUserMessageContent = ( content: Array, ): Array | null => { const mergeableContent = collectMergeableUserContent(content) if (!mergeableContent) { return null } const { toolResults, textBlocks, attachments } = mergeableContent if ( toolResults.length === 0 || (textBlocks.length === 0 && attachments.length === 0) ) { return null } const mergedToolResults = textBlocks.length === 0 ? toolResults : mergeToolResult(toolResults, textBlocks) return mergeAttachmentsForToolResults(mergedToolResults, attachments) } const mergeToolResult = ( toolResults: Array, textBlocks: Array, ): Array => { if (toolResults.length === textBlocks.length) { return toolResults.map((tr, i) => mergeContentWithText(tr, textBlocks[i])) } const lastIndex = toolResults.length - 1 return toolResults.map((tr, i) => i === lastIndex ? mergeContentWithTexts(tr, textBlocks) : tr, ) } export const stripToolReferenceTurnBoundary = ( anthropicPayload: AnthropicMessagesPayload, ): void => { for (const msg of anthropicPayload.messages) { if (msg.role !== "user" || !Array.isArray(msg.content)) continue const hasToolReference = msg.content.some( (block) => block.type === "tool_result" && hasToolRef(block), ) if (!hasToolReference) continue msg.content = msg.content.filter( (block) => block.type !== "text" || block.text.trim() !== TOOL_REFERENCE_TURN_BOUNDARY, ) } } export const mergeToolResultForClaude = ( anthropicPayload: AnthropicMessagesPayload, options?: { skipLastMessage?: boolean }, ): void => { const lastMessageIndex = anthropicPayload.messages.length - 1 for (const [index, msg] of anthropicPayload.messages.entries()) { if (options?.skipLastMessage && index === lastMessageIndex) continue if (msg.role !== "user" || !Array.isArray(msg.content)) continue const mergedContent = mergeUserMessageContent(msg.content) if (mergedContent) { msg.content = mergedContent } } } // align with vscode copilot claude agent tools export const sanitizeIdeTools = (payload: AnthropicMessagesPayload): void => { if (!payload.tools || payload.tools.length === 0) { return } payload.tools = payload.tools.flatMap((tool) => { if (tool.name === IDE_EXECUTE_CODE_TOOL && !tool.defer_loading) { return [] } if (tool.name === IDE_GET_DIAGNOSTICS_TOOL) { return [ { ...tool, description: IDE_GET_DIAGNOSTICS_DESCRIPTION, }, ] } return [tool] }) } const hasToolRef = (block: AnthropicToolResultBlock) => { return ( Array.isArray(block.content) && block.content.some((c) => c.type === "tool_reference") ) } // Strip cache_control from system content blocks as the // Copilot Messages API does not support them (rejects extra fields like scope). // commit by nicktogo const stripCacheControl = (payload: AnthropicMessagesPayload): void => { if (Array.isArray(payload.system)) { for (const block of payload.system) { const cacheControl = block.cache_control if (cacheControl && typeof cacheControl === "object") { const { scope, ...rest } = cacheControl block.cache_control = rest } } } } // Pre-request processing: filter thinking blocks for Claude models so only // valid thinking blocks are sent to the Copilot Messages API. const filterAssistantThinkingBlocks = ( payload: AnthropicMessagesPayload, ): void => { for (const msg of payload.messages) { if (msg.role === "assistant" && Array.isArray(msg.content)) { msg.content = msg.content.filter((block) => { if (block.type !== "thinking") return true return ( block.thinking && block.thinking !== "Thinking..." && block.signature && !block.signature.includes("@") ) }) } } } export const prepareMessagesApiPayload = ( payload: AnthropicMessagesPayload, selectedModel?: Model, ): void => { stripCacheControl(payload) filterAssistantThinkingBlocks(payload) const hasThinking = Boolean(payload.thinking) // https://platform.claude.com/docs/en/build-with-claude/extended-thinking#extended-thinking-with-tool-use // Using tool_choice: {"type": "any"} or tool_choice: {"type": "tool", "name": "..."} will result in an error because these options force tool use, which is incompatible with extended thinking. const toolChoice = payload.tool_choice const disableThink = toolChoice?.type === "any" || toolChoice?.type === "tool" if (selectedModel?.capabilities.supports.adaptive_thinking && !disableThink) { payload.thinking = { type: "adaptive", } // align with vscode copilot if (!hasThinking) { payload.thinking.display = "summarized" } if (payload.model === "claude-opus-4.7") { payload.thinking.display = "summarized" } let effort = getReasoningEffortForModel(payload.model) if (effort === "none" || effort === "minimal") { effort = "low" } const reasoningEffort = selectedModel.capabilities.supports.reasoning_effort if (reasoningEffort && !reasoningEffort.includes(effort)) { effort = reasoningEffort.at(-1) as "low" | "medium" | "high" } payload.output_config = { effort: effort, } } }