-
Notifications
You must be signed in to change notification settings - Fork 212
Expand file tree
/
Copy pathlite-llm.ts
More file actions
351 lines (308 loc) · 12.1 KB
/
Copy pathlite-llm.ts
File metadata and controls
351 lines (308 loc) · 12.1 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
import OpenAI from "openai"
import { Anthropic } from "@anthropic-ai/sdk" // Keep for type usage only
import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types"
import { calculateApiCostOpenAI } from "../../shared/cost"
import { ApiHandlerOptions } from "../../shared/api"
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
import { RouterProvider } from "./router-provider"
import { extractReasoningFromDelta } from "./utils/extract-reasoning"
/**
* LiteLLM provider handler
*
* This handler uses the LiteLLM API to proxy requests to various LLM providers.
* It follows the OpenAI API format for compatibility.
*/
export class LiteLLMHandler extends RouterProvider implements SingleCompletionHandler {
constructor(options: ApiHandlerOptions) {
super({
options,
name: "litellm",
baseURL: `${options.litellmBaseUrl || "http://localhost:4000"}`,
apiKey: options.litellmApiKey || "dummy-key",
modelId: options.litellmModelId,
defaultModelId: litellmDefaultModelId,
defaultModelInfo: litellmDefaultModelInfo,
})
}
private isGpt5(modelId: string): boolean {
// Match gpt-5, gpt5, and variants like gpt-5o, gpt-5-turbo, gpt5-preview, gpt-5.1
// Avoid matching gpt-50, gpt-500, etc.
return /\bgpt-?5(?!\d)/i.test(modelId)
}
/**
* Detect if the model is a Gemini model that requires thought signature handling.
* Gemini 3 models validate thought signatures for tool/function calling steps.
*/
private isGeminiModel(modelId: string): boolean {
// Match various Gemini model patterns:
// - gemini-3-pro, gemini-3-flash, gemini-3-*
// - gemini 3 pro, Gemini 3 Pro (space-separated, case-insensitive)
// - gemini/gemini-3-*, google/gemini-3-*
// - vertex_ai/gemini-3-*, vertex/gemini-3-*
// Also match Gemini 2.5+ models which use similar validation
const lowerModelId = modelId.toLowerCase()
return (
// Match hyphenated versions: gemini-3, gemini-2.5
lowerModelId.includes("gemini-3") ||
lowerModelId.includes("gemini-2.5") ||
// Match space-separated versions: "gemini 3", "gemini 2.5"
// This handles model names like "Gemini 3 Pro" from LiteLLM model groups
lowerModelId.includes("gemini 3") ||
lowerModelId.includes("gemini 2.5") ||
// Also match provider-prefixed versions
/\b(gemini|google|vertex_ai|vertex)\/gemini[-\s](3|2\.5)/i.test(modelId)
)
}
/**
* Inject thought signatures for Gemini models via provider_specific_fields.
* This is required when switching from other models to Gemini to satisfy API validation
* for function calls that weren't generated by Gemini (and thus lack thought signatures).
*
* Per LiteLLM documentation:
* - Thought signatures are stored in provider_specific_fields.thought_signature of tool calls
* - The dummy signature base64("skip_thought_signature_validator") bypasses validation
*
* We inject the dummy signature on EVERY tool call unconditionally to ensure Gemini
* doesn't complain about missing/corrupted signatures when conversation history
* contains tool calls from other models (like Claude).
*/
private injectThoughtSignatureForGemini(
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
): OpenAI.Chat.ChatCompletionMessageParam[] {
// Base64 encoded "skip_thought_signature_validator" as per LiteLLM docs
const dummySignature = Buffer.from("skip_thought_signature_validator").toString("base64")
return openAiMessages.map((msg) => {
if (msg.role === "assistant") {
const toolCalls = (msg as any).tool_calls as any[] | undefined
// Only process if there are tool calls
if (toolCalls && toolCalls.length > 0) {
// Inject dummy signature into ALL tool calls' provider_specific_fields
// This ensures Gemini doesn't reject tool calls from other models
const updatedToolCalls = toolCalls.map((tc) => ({
...tc,
provider_specific_fields: {
...(tc.provider_specific_fields || {}),
thought_signature: dummySignature,
},
}))
return {
...msg,
tool_calls: updatedToolCalls,
}
}
}
return msg
})
}
override async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
const { id: modelId, info } = await this.fetchModel()
const openAiMessages = convertToOpenAiMessages(messages, {
normalizeToolCallId: sanitizeOpenAiCallId,
})
// Prepare messages with cache control if enabled and supported
let systemMessage: OpenAI.Chat.ChatCompletionMessageParam
let enhancedMessages: OpenAI.Chat.ChatCompletionMessageParam[]
if (this.options.litellmUsePromptCache && info.supportsPromptCache) {
// Create system message with cache control in the proper format
systemMessage = {
role: "system",
content: [
{
type: "text",
text: systemPrompt,
cache_control: { type: "ephemeral" },
} as any,
],
}
// Find the last two user messages to apply caching
const userMsgIndices = openAiMessages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
// Apply cache_control to the last two user messages
enhancedMessages = openAiMessages.map((message, index) => {
if ((index === lastUserMsgIndex || index === secondLastUserMsgIndex) && message.role === "user") {
// Handle both string and array content types
if (typeof message.content === "string") {
return {
...message,
content: [
{
type: "text",
text: message.content,
cache_control: { type: "ephemeral" },
} as any,
],
}
} else if (Array.isArray(message.content)) {
// Apply cache control to the last content item in the array
return {
...message,
content: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? ({
...content,
cache_control: { type: "ephemeral" },
} as any)
: content,
),
}
}
}
return message
})
} else {
// No cache control - use simple format
systemMessage = { role: "system", content: systemPrompt }
enhancedMessages = openAiMessages
}
// Required by some providers; others default to max tokens allowed
const maxTokens: number | undefined = info.maxTokens ?? undefined
// Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens
const isGPT5Model = this.isGpt5(modelId)
// For Gemini models with native protocol: inject fake reasoning.encrypted block for tool calls
// This is required when switching from other models to Gemini to satisfy API validation.
// Gemini 3 models validate thought signatures for function calls, and when conversation
// history contains tool calls from other models (like Claude), they lack the required
// signatures. The "skip_thought_signature_validator" value bypasses this validation.
const isGemini = this.isGeminiModel(modelId)
let processedMessages = enhancedMessages
if (isGemini) {
processedMessages = this.injectThoughtSignatureForGemini(enhancedMessages)
}
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: modelId,
messages: [systemMessage, ...processedMessages],
stream: true,
stream_options: {
include_usage: true,
},
tools: this.convertToolsForOpenAI(metadata?.tools),
tool_choice: metadata?.tool_choice,
}
// GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter
if (isGPT5Model && maxTokens) {
requestOptions.max_completion_tokens = maxTokens
} else if (maxTokens) {
requestOptions.max_tokens = maxTokens
}
if (this.supportsTemperature(modelId)) {
requestOptions.temperature = this.options.modelTemperature ?? 0
}
// LiteLLM recognizes X-<vendor>-Session-ID for per-conversation request correlation.
// This header enables LiteLLM to group related API calls by task for logging and tracing.
// Unlike Zoo gateways (which use X-Zoo-Task-ID to correlate requests across multiple
// models within a single conversation), this header is specific to the LiteLLM provider
// and facilitates provider-level logging and debugging on LiteLLM's admin panel.
// Matches the convention used by Claude Code (x-claude-code-session-id) and
// GitHub Copilot (x-copilot-session-id).
const requestHeaders: Record<string, string> = {}
if (metadata?.taskId) {
requestHeaders["X-Zoo-Session-ID"] = metadata.taskId
}
try {
const { data: completion } = await this.client.chat.completions
.create(requestOptions, { headers: requestHeaders })
.withResponse()
let lastUsage
for await (const chunk of completion) {
const delta = chunk.choices[0]?.delta
const usage = chunk.usage as LiteLLMUsage
if (delta?.content) {
yield { type: "text", text: delta.content }
}
const reasoningText = extractReasoningFromDelta(delta)
if (reasoningText) {
yield { type: "reasoning", text: reasoningText }
}
// Handle tool calls in stream - emit partial chunks for NativeToolCallParser
if (delta?.tool_calls) {
for (const toolCall of delta.tool_calls) {
yield {
type: "tool_call_partial",
index: toolCall.index,
id: toolCall.id,
name: toolCall.function?.name,
arguments: toolCall.function?.arguments,
}
}
}
if (usage) {
lastUsage = usage
}
}
if (lastUsage) {
// Extract cache-related information if available
// LiteLLM may use different field names for cache tokens
const cacheWriteTokens =
lastUsage.cache_creation_input_tokens || (lastUsage as any).prompt_cache_miss_tokens || 0
const cacheReadTokens =
lastUsage.prompt_tokens_details?.cached_tokens ||
(lastUsage as any).cache_read_input_tokens ||
(lastUsage as any).prompt_cache_hit_tokens ||
0
const { totalCost } = calculateApiCostOpenAI(
info,
lastUsage.prompt_tokens || 0,
lastUsage.completion_tokens || 0,
cacheWriteTokens,
cacheReadTokens,
)
const usageData: ApiStreamUsageChunk = {
type: "usage",
inputTokens: lastUsage.prompt_tokens || 0,
outputTokens: lastUsage.completion_tokens || 0,
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
totalCost,
}
yield usageData
}
} catch (error) {
if (error instanceof Error) {
throw new Error(`LiteLLM streaming error: ${error.message}`)
}
throw error
}
}
async completePrompt(prompt: string): Promise<string> {
const { id: modelId, info } = await this.fetchModel()
// Check if this is a GPT-5 model that requires max_completion_tokens instead of max_tokens
const isGPT5Model = this.isGpt5(modelId)
try {
const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = {
model: modelId,
messages: [{ role: "user", content: prompt }],
}
if (this.supportsTemperature(modelId)) {
requestOptions.temperature = this.options.modelTemperature ?? 0
}
// GPT-5 models require max_completion_tokens instead of the deprecated max_tokens parameter
if (isGPT5Model && info.maxTokens) {
requestOptions.max_completion_tokens = info.maxTokens
} else if (info.maxTokens) {
requestOptions.max_tokens = info.maxTokens
}
const response = await this.client.chat.completions.create(requestOptions)
return response.choices[0]?.message.content || ""
} catch (error) {
if (error instanceof Error) {
throw new Error(`LiteLLM completion error: ${error.message}`)
}
throw error
}
}
}
// LiteLLM usage may include an extra field for Anthropic use cases.
interface LiteLLMUsage extends OpenAI.CompletionUsage {
cache_creation_input_tokens?: number
}