This repository was archived by the owner on May 15, 2026. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 3.4k
Expand file tree
/
Copy pathopenai-format.ts
More file actions
514 lines (464 loc) · 18.5 KB
/
Copy pathopenai-format.ts
File metadata and controls
514 lines (464 loc) · 18.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
/**
* Type for OpenRouter's reasoning detail elements.
* @see https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response
*/
export type ReasoningDetail = {
/**
* Type of reasoning detail.
* @see https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types
*/
type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string
summary?: string
data?: string // Encrypted reasoning data
signature?: string | null
id?: string | null // Unique identifier for the reasoning detail
/**
* Format of the reasoning detail:
* - "unknown" - Format is not specified
* - "openai-responses-v1" - OpenAI responses format version 1
* - "anthropic-claude-v1" - Anthropic Claude format version 1 (default)
* - "google-gemini-v1" - Google Gemini format version 1
* - "xai-responses-v1" - xAI responses format version 1
*/
format?: string
index?: number // Sequential index of the reasoning detail
}
/**
* Consolidates reasoning_details by grouping by index and type.
* - Filters out corrupted encrypted blocks (missing `data` field)
* - For text blocks: concatenates text, keeps last signature/id/format
* - For encrypted blocks: keeps only the last one per index
*
* @param reasoningDetails - Array of reasoning detail objects
* @returns Consolidated array of reasoning details
* @see https://github.com/cline/cline/issues/8214
*/
export function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] {
if (!reasoningDetails || reasoningDetails.length === 0) {
return []
}
// Group by index
const groupedByIndex = new Map<number, ReasoningDetail[]>()
for (const detail of reasoningDetails) {
// Drop corrupted encrypted reasoning blocks that would otherwise trigger:
// "Invalid input: expected string, received undefined" for reasoning_details.*.data
// See: https://github.com/cline/cline/issues/8214
if (detail.type === "reasoning.encrypted" && !detail.data) {
continue
}
const index = detail.index ?? 0
if (!groupedByIndex.has(index)) {
groupedByIndex.set(index, [])
}
groupedByIndex.get(index)!.push(detail)
}
// Consolidate each group
const consolidated: ReasoningDetail[] = []
for (const [index, details] of groupedByIndex.entries()) {
// Concatenate all text parts
let concatenatedText = ""
let concatenatedSummary = ""
let signature: string | undefined
let id: string | undefined
let format = "unknown"
let type = "reasoning.text"
for (const detail of details) {
if (detail.text) {
concatenatedText += detail.text
}
if (detail.summary) {
concatenatedSummary += detail.summary
}
// Keep the signature from the last item that has one
if (detail.signature) {
signature = detail.signature
}
// Keep the id from the last item that has one
if (detail.id) {
id = detail.id
}
// Keep format and type from any item (they should all be the same)
if (detail.format) {
format = detail.format
}
if (detail.type) {
type = detail.type
}
}
// Create consolidated entry for text
if (concatenatedText) {
const consolidatedEntry: ReasoningDetail = {
type: type,
text: concatenatedText,
signature: signature ?? undefined,
id: id ?? undefined,
format: format,
index: index,
}
consolidated.push(consolidatedEntry)
}
// Create consolidated entry for summary (used by some providers)
if (concatenatedSummary && !concatenatedText) {
const consolidatedEntry: ReasoningDetail = {
type: type,
summary: concatenatedSummary,
signature: signature ?? undefined,
id: id ?? undefined,
format: format,
index: index,
}
consolidated.push(consolidatedEntry)
}
// For encrypted chunks (data), only keep the last one
let lastDataEntry: ReasoningDetail | undefined
for (const detail of details) {
if (detail.data) {
lastDataEntry = {
type: detail.type,
data: detail.data,
signature: detail.signature ?? undefined,
id: detail.id ?? undefined,
format: detail.format,
index: index,
}
}
}
if (lastDataEntry) {
consolidated.push(lastDataEntry)
}
}
return consolidated
}
/**
* Sanitizes OpenAI messages for Gemini models by filtering reasoning_details
* to only include entries that match the tool call IDs.
*
* Gemini models require thought signatures for tool calls. When switching providers
* mid-conversation, historical tool calls may not include Gemini reasoning details,
* which can poison the next request. This function:
* 1. Filters reasoning_details to only include entries matching tool call IDs
* 2. Drops tool_calls that lack any matching reasoning_details
* 3. Removes corresponding tool result messages for dropped tool calls
*
* @param messages - Array of OpenAI chat completion messages
* @param modelId - The model ID to check if sanitization is needed
* @returns Sanitized array of messages (unchanged if not a Gemini model)
* @see https://github.com/cline/cline/issues/8214
*/
export function sanitizeGeminiMessages(
messages: OpenAI.Chat.ChatCompletionMessageParam[],
modelId: string,
): OpenAI.Chat.ChatCompletionMessageParam[] {
// Only sanitize for Gemini models
if (!modelId.includes("gemini")) {
return messages
}
const droppedToolCallIds = new Set<string>()
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const msg of messages) {
if (msg.role === "assistant") {
const anyMsg = msg as any
const toolCalls = anyMsg.tool_calls as OpenAI.Chat.ChatCompletionMessageToolCall[] | undefined
const reasoningDetails = anyMsg.reasoning_details as ReasoningDetail[] | undefined
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
if (!hasReasoningDetails) {
// No reasoning_details at all - drop all tool calls
for (const tc of toolCalls) {
if (tc?.id) {
droppedToolCallIds.add(tc.id)
}
}
// Keep any textual content, but drop the tool_calls themselves
if (anyMsg.content) {
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
}
continue
}
// Filter reasoning_details to only include entries matching tool call IDs
// This prevents mismatched reasoning details from poisoning the request
const validToolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = []
const validReasoningDetails: ReasoningDetail[] = []
for (const tc of toolCalls) {
// Check if there's a reasoning_detail with matching id
const matchingDetails = reasoningDetails.filter((d) => d.id === tc.id)
if (matchingDetails.length > 0) {
validToolCalls.push(tc)
validReasoningDetails.push(...matchingDetails)
} else {
// No matching reasoning_detail - drop this tool call
if (tc?.id) {
droppedToolCallIds.add(tc.id)
}
}
}
// Also include reasoning_details that don't have an id (legacy format)
const detailsWithoutId = reasoningDetails.filter((d) => !d.id)
validReasoningDetails.push(...detailsWithoutId)
// Build the sanitized message
const sanitizedMsg: any = {
role: "assistant",
content: anyMsg.content ?? "",
}
if (validReasoningDetails.length > 0) {
sanitizedMsg.reasoning_details = consolidateReasoningDetails(validReasoningDetails)
}
if (validToolCalls.length > 0) {
sanitizedMsg.tool_calls = validToolCalls
}
sanitized.push(sanitizedMsg)
continue
}
}
if (msg.role === "tool") {
const anyMsg = msg as any
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
// Skip tool result for dropped tool call
continue
}
}
sanitized.push(msg)
}
return sanitized
}
/**
* Options for converting Anthropic messages to OpenAI format.
*/
export interface ConvertToOpenAiMessagesOptions {
/**
* Optional function to normalize tool call IDs for providers with strict ID requirements.
* When provided, this function will be applied to all tool_use IDs and tool_result tool_use_ids.
* This allows callers to declare provider-specific ID format requirements.
*/
normalizeToolCallId?: (id: string) => string
/**
* If true, merge text content after tool_results into the last tool message
* instead of creating a separate user message. This is critical for providers
* with reasoning/thinking models (like DeepSeek-reasoner, GLM-4.7, etc.) where
* a user message after tool results causes the model to drop all previous
* reasoning_content. Default is false for backward compatibility.
*/
mergeToolResultText?: boolean
}
export function convertToOpenAiMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
options?: ConvertToOpenAiMessagesOptions,
): OpenAI.Chat.ChatCompletionMessageParam[] {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
const mapReasoningDetails = (details: unknown): any[] | undefined => {
if (!Array.isArray(details)) {
return undefined
}
return details.map((detail: any) => {
// Strip `id` from openai-responses-v1 blocks because OpenAI's Responses API
// requires `store: true` to persist reasoning blocks. Since we manage
// conversation state client-side, we don't use `store: true`, and sending
// back the `id` field causes a 404 error.
if (detail?.format === "openai-responses-v1" && detail?.id) {
const { id, ...rest } = detail
return rest
}
return detail
})
}
// Use provided normalization function or identity function
const normalizeId = options?.normalizeToolCallId ?? ((id: string) => id)
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
// Some upstream transforms (e.g. [`Task.buildCleanConversationHistory()`](src/core/task/Task.ts:4048))
// will convert a single text block into a string for compactness.
// If a message also contains reasoning_details (Gemini 3 / xAI / o-series, etc.),
// we must preserve it here as well.
const messageWithDetails = anthropicMessage as any
const baseMessage: OpenAI.Chat.ChatCompletionMessageParam & { reasoning_details?: any[] } = {
role: anthropicMessage.role,
content: anthropicMessage.content,
}
if (anthropicMessage.role === "assistant") {
const mapped = mapReasoningDetails(messageWithDetails.reasoning_details)
if (mapped) {
;(baseMessage as any).reasoning_details = mapped
}
}
openAiMessages.push(baseMessage)
} else {
// image_url.url is base64 encoded image data
// ensure it contains the content-type of the image: data:image/png;base64,
/*
{ role: "user", content: "" | { type: "text", text: string } | { type: "image_url", image_url: { url: string } } },
// content required unless tool_calls is present
{ role: "assistant", content?: "" | null, tool_calls?: [{ id: "", function: { name: "", arguments: "" }, type: "function" }] },
{ role: "tool", tool_call_id: "", content: ""}
*/
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
let toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content
} else {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(part)
return "(see following user message for image)"
}
return part.text
})
.join("\n") ?? ""
}
openAiMessages.push({
role: "tool",
tool_call_id: normalizeId(toolMessage.tool_use_id),
// Use "(empty)" placeholder for empty content to satisfy providers like Gemini (via OpenRouter)
content: content || "(empty)",
})
})
// If tool results contain images, send as a separate user message
// I ran into an issue where if I gave feedback for one of many tool uses, the request would fail.
// "Messages following `tool_use` blocks must begin with a matching number of `tool_result` blocks."
// Therefore we need to send these images after the tool result messages
// NOTE: it's actually okay to have multiple user messages in a row, the model will treat them as a continuation of the same input (this way works better than combining them into one message, since the tool result specifically mentions (see following user message for image)
// UPDATE v2.0: we don't use tools anymore, but if we did it's important to note that the openrouter prompt caching mechanism requires one user message at a time, so we would need to add these images to the user content array instead.
// if (toolResultImages.length > 0) {
// openAiMessages.push({
// role: "user",
// content: toolResultImages.map((part) => ({
// type: "image_url",
// image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
// })),
// })
// }
// Process non-tool messages
// Filter out empty text blocks to prevent "must include at least one parts field" error
// from Gemini (via OpenRouter). Images always have content (base64 data).
const filteredNonToolMessages = nonToolMessages.filter(
(part) => part.type === "image" || (part.type === "text" && part.text),
)
if (filteredNonToolMessages.length > 0) {
// Check if we should merge text into the last tool message
// This is critical for reasoning/thinking models where a user message
// after tool results causes the model to drop all previous reasoning_content
const hasOnlyTextContent = filteredNonToolMessages.every((part) => part.type === "text")
const hasToolMessages = toolMessages.length > 0
const shouldMergeIntoToolMessage =
options?.mergeToolResultText && hasToolMessages && hasOnlyTextContent
if (shouldMergeIntoToolMessage) {
// Merge text content into the last tool message
const lastToolMessage = openAiMessages[
openAiMessages.length - 1
] as OpenAI.Chat.ChatCompletionToolMessageParam
if (lastToolMessage?.role === "tool") {
const additionalText = filteredNonToolMessages
.map((part) => (part as Anthropic.TextBlockParam).text)
.join("\n")
lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}`
}
} else {
// Standard behavior: add user message with text/image content
openAiMessages.push({
role: "user",
content: filteredNonToolMessages.map((part) => {
if (part.type === "image") {
return {
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
}
}
return { type: "text", text: part.text }
}),
})
}
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string | undefined
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return "" // impossible as the assistant cannot send images
}
return part.text
})
.join("\n")
}
// Process tool use messages
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
id: normalizeId(toolMessage.id),
type: "function",
function: {
name: toolMessage.name,
// Serialize as JSON, stripping null values to prevent Jinja template
// errors on local models (e.g. "Cannot convert value of type
// Optional<Any> to Jinja Value"). Null in tool args typically means
// "not provided" and should be omitted instead.
arguments: JSON.stringify(toolMessage.input, (_key, value) =>
value === null ? undefined : value,
),
},
}))
// Check if the message has reasoning_details (used by Gemini 3, xAI, etc.)
const messageWithDetails = anthropicMessage as any
// Build message with reasoning_details BEFORE tool_calls to preserve
// the order expected by providers like Roo. Property order matters
// when sending messages back to some APIs.
const baseMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam & {
reasoning_details?: any[]
} = {
role: "assistant",
// Use empty string instead of undefined for providers like Gemini (via OpenRouter)
// that require every message to have content in the "parts" field
content: content ?? "",
}
// Pass through reasoning_details to preserve the original shape from the API.
// The `id` field is stripped from openai-responses-v1 blocks (see mapReasoningDetails).
const mapped = mapReasoningDetails(messageWithDetails.reasoning_details)
if (mapped) {
baseMessage.reasoning_details = mapped
}
// Add tool_calls after reasoning_details
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
if (tool_calls.length > 0) {
baseMessage.tool_calls = tool_calls
}
openAiMessages.push(baseMessage)
}
}
}
return openAiMessages
}