Skip to content

Commit 31c7578

Browse files
committed
Feat: Add native Anthropic format adapter
- Implement dedicated 'anthropic' format adapter in lib/fetch-wrapper/formats/anthropic.ts. - Handle top-level 'system' field injection (supports string and array). - Handle 'tool_result' content blocks in user messages. - Update fetch wrapper detection order to prioritize Anthropic over OpenAI-compatible. - Fixes incompatibility where OpenAI adapter would inject invalid 'system' role messages into Anthropic requests.
1 parent 541d24b commit 31c7578

3 files changed

Lines changed: 131 additions & 4 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import type { FormatDescriptor, ToolOutput } from "../types"
2+
import type { PluginState } from "../../state"
3+
4+
/**
5+
* Anthropic Messages API format with top-level `system` array.
6+
* Tool calls: `tool_use` blocks in assistant content with `id`
7+
* Tool results: `tool_result` blocks in user content with `tool_use_id`
8+
*/
9+
export const anthropicFormat: FormatDescriptor = {
10+
name: 'anthropic',
11+
12+
detect(body: any): boolean {
13+
// Anthropic has top-level `system` field (can be string or array) AND messages array
14+
// This distinguishes it from OpenAI (no top-level system) and Bedrock (has inferenceConfig)
15+
return (
16+
body.system !== undefined &&
17+
Array.isArray(body.messages)
18+
)
19+
},
20+
21+
getDataArray(body: any): any[] | undefined {
22+
return body.messages
23+
},
24+
25+
injectSystemMessage(body: any, injection: string): boolean {
26+
if (!injection) return false
27+
28+
// Anthropic system can be:
29+
// 1. A string: "You are a helpful assistant"
30+
// 2. An array of blocks: [{"type": "text", "text": "...", "cache_control": {...}}]
31+
32+
// Convert to array if needed
33+
if (typeof body.system === 'string') {
34+
body.system = [{ type: 'text', text: body.system }]
35+
} else if (!Array.isArray(body.system)) {
36+
body.system = []
37+
}
38+
39+
// Append the injection as a text block
40+
body.system.push({ type: 'text', text: injection })
41+
return true
42+
},
43+
44+
extractToolOutputs(data: any[], state: PluginState): ToolOutput[] {
45+
const outputs: ToolOutput[] = []
46+
47+
for (const m of data) {
48+
// Tool results are in user messages with type='tool_result'
49+
if (m.role === 'user' && Array.isArray(m.content)) {
50+
for (const block of m.content) {
51+
if (block.type === 'tool_result' && block.tool_use_id) {
52+
const toolUseId = block.tool_use_id.toLowerCase()
53+
const metadata = state.toolParameters.get(toolUseId)
54+
outputs.push({
55+
id: toolUseId,
56+
toolName: metadata?.tool
57+
})
58+
}
59+
}
60+
}
61+
}
62+
63+
return outputs
64+
},
65+
66+
replaceToolOutput(data: any[], toolId: string, prunedMessage: string, _state: PluginState): boolean {
67+
const toolIdLower = toolId.toLowerCase()
68+
let replaced = false
69+
70+
for (let i = 0; i < data.length; i++) {
71+
const m = data[i]
72+
73+
if (m.role === 'user' && Array.isArray(m.content)) {
74+
let messageModified = false
75+
const newContent = m.content.map((block: any) => {
76+
if (block.type === 'tool_result' && block.tool_use_id?.toLowerCase() === toolIdLower) {
77+
messageModified = true
78+
// Anthropic tool_result content can be string or array of content blocks
79+
// Replace with simple string
80+
return {
81+
...block,
82+
content: prunedMessage
83+
}
84+
}
85+
return block
86+
})
87+
if (messageModified) {
88+
data[i] = { ...m, content: newContent }
89+
replaced = true
90+
}
91+
}
92+
}
93+
94+
return replaced
95+
},
96+
97+
hasToolOutputs(data: any[]): boolean {
98+
for (const m of data) {
99+
if (m.role === 'user' && Array.isArray(m.content)) {
100+
for (const block of m.content) {
101+
if (block.type === 'tool_result') return true
102+
}
103+
}
104+
}
105+
return false
106+
},
107+
108+
getLogMetadata(data: any[], replacedCount: number, inputUrl: string): Record<string, any> {
109+
return {
110+
url: inputUrl,
111+
replacedCount,
112+
totalMessages: data.length,
113+
format: 'anthropic'
114+
}
115+
}
116+
}

lib/fetch-wrapper/formats/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { openaiChatFormat } from './openai-chat'
22
export { openaiResponsesFormat } from './openai-responses'
33
export { geminiFormat } from './gemini'
44
export { bedrockFormat } from './bedrock'
5+
export { anthropicFormat } from './anthropic'

lib/fetch-wrapper/index.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { Logger } from "../logger"
33
import type { FetchHandlerContext } from "./types"
44
import type { ToolTracker } from "./types"
55
import type { PluginConfig } from "../config"
6-
import { openaiChatFormat, openaiResponsesFormat, geminiFormat, bedrockFormat } from "./formats"
6+
import { openaiChatFormat, openaiResponsesFormat, geminiFormat, bedrockFormat, anthropicFormat } from "./formats"
77
import { handleFormat } from "./handler"
88
import { runStrategies } from "../core/strategies"
99
import { accumulateGCStats } from "./gc-tracker"
@@ -17,7 +17,7 @@ export type { FetchHandlerContext, FetchHandlerResult } from "./types"
1717
*
1818
* Supports five API formats:
1919
* 1. OpenAI Chat Completions (body.messages with role='tool')
20-
* 2. Anthropic (body.messages with role='user' containing tool_result)
20+
* 2. Anthropic Messages API (body.system + body.messages with tool_result)
2121
* 3. Google/Gemini (body.contents with functionResponse parts)
2222
* 4. OpenAI Responses API (body.input with function_call_output items)
2323
* 5. AWS Bedrock Converse API (body.system + body.messages with toolResult blocks)
@@ -56,8 +56,12 @@ export function installFetchWrapper(
5656
const toolIdsBefore = new Set(state.toolParameters.keys())
5757

5858
// Mutually exclusive format handlers
59-
// Note: bedrockFormat must be checked before openaiChatFormat since both have messages[]
60-
// but Bedrock has distinguishing system[] array and inferenceConfig
59+
// Order matters: More specific formats first to avoid incorrect detection
60+
// 1. OpenAI Responses API: has body.input (not body.messages)
61+
// 2. Bedrock: has body.system + body.inferenceConfig + body.messages
62+
// 3. Anthropic: has body.system + body.messages (no inferenceConfig)
63+
// 4. OpenAI Chat: has body.messages (no top-level system)
64+
// 5. Gemini: has body.contents
6165
if (openaiResponsesFormat.detect(body)) {
6266
const result = await handleFormat(body, ctx, inputUrl, openaiResponsesFormat)
6367
if (result.modified) {
@@ -70,6 +74,12 @@ export function installFetchWrapper(
7074
modified = true
7175
}
7276
}
77+
else if (anthropicFormat.detect(body)) {
78+
const result = await handleFormat(body, ctx, inputUrl, anthropicFormat)
79+
if (result.modified) {
80+
modified = true
81+
}
82+
}
7383
else if (openaiChatFormat.detect(body)) {
7484
const result = await handleFormat(body, ctx, inputUrl, openaiChatFormat)
7585
if (result.modified) {

0 commit comments

Comments
 (0)