Compare commits

..
Author SHA1 Message Date
Beatrix Woo ba56062ffc Capture token usage for conversation turns in telementry
Adds capture of token usage data for conversation turns in the telemetry service. This includes tokensIn, tokensOut, cacheWriteTokens, cacheReadTokens, and totalCost.

The changes involve:

- Modifying the `captureConversationTurnEvent` method in `TelemetryService.ts` to accept and include token usage data in the captured event properties.
- Updating the `Task` class in `src/core/task/index.ts` to pass token usage information when capturing conversation turn events. This ensures that token usage is tracked for both regular and cached responses.
- Refactor capture event to use object destructuring for easier readability
2025-07-07 14:45:02 -07:00
celestial-vaultandSaoud Rizwan 7ab6189595 mark welcomeViewCompleted as true after auth redirect (#4699)
* mark welcomeViewCompleted as true after auth redirect

* Create real-ravens-nail.md

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2025-07-07 13:14:48 -07:00
celestial-vault 4beaa2a086 sync local search state after timeout (#4698) 2025-07-07 11:16:08 -07:00
Ara 5f90018ab5 Revert "remove blur (#4664)" (#4677) 2025-07-06 12:05:30 -06:00
celestial-vault 9e761cd1f0 lazily initialize provider sdks with error catching (#4661) 2025-07-06 11:41:16 -05:00
27 changed files with 552 additions and 199 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix logging in with cline not getting past welcome screen
+6 -3
View File
@@ -45,8 +45,10 @@ describe("OllamaHandler", () => {
this.skip()
}
this.timeout(5000)
// Ensure client is initialized
const client = (handler as any).ensureClient()
// Mock the Ollama client's chat method
const chatStub = sinon.stub(handler["client"], "chat").resolves({
const chatStub = sinon.stub(client, "chat").resolves({
[Symbol.asyncIterator]: async function* () {
yield {
message: { content: "Hello, world!" },
@@ -139,8 +141,9 @@ describe("OllamaHandler", () => {
// Restore real timers for this test
clock.restore()
// Mock the Ollama client's chat method to fail on first call and succeed on second
const chatStub = sinon.stub(handler["client"], "chat")
// Ensure client is initialized and mock the Ollama client's chat method to fail on first call and succeed on second
const client = (handler as any).ensureClient()
const chatStub = sinon.stub(client, "chat")
// First call throws an error
chatStub.onFirstCall().rejects(new Error("API Error"))
+22 -7
View File
@@ -7,18 +7,33 @@ import { ApiStream } from "../transform/stream"
export class AnthropicHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Anthropic
private client: Anthropic | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new Anthropic({
apiKey: this.options.apiKey,
baseURL: this.options.anthropicBaseUrl || undefined,
})
}
private ensureClient(): Anthropic {
if (!this.client) {
if (!this.options.apiKey) {
throw new Error("Anthropic API key is required")
}
try {
this.client = new Anthropic({
apiKey: this.options.apiKey,
baseURL: this.options.anthropicBaseUrl || undefined,
})
} catch (error) {
throw new Error(`Error creating Anthropic client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
const modelId = model.id
@@ -44,7 +59,7 @@ export class AnthropicHandler implements ApiHandler {
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.client.messages.create(
stream = await client.messages.create(
{
model: modelId,
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
@@ -118,7 +133,7 @@ export class AnthropicHandler implements ApiHandler {
break
}
default: {
stream = await this.client.messages.create({
stream = await client.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
+22 -11
View File
@@ -7,26 +7,37 @@ import { ApiStream } from "@api/transform/stream"
export class CerebrasHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Cerebras
private client: Cerebras | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
}
// Clean and validate the API key
const cleanApiKey = this.options.cerebrasApiKey?.trim()
private ensureClient(): Cerebras {
if (!this.client) {
// Clean and validate the API key
const cleanApiKey = this.options.cerebrasApiKey?.trim()
if (!cleanApiKey) {
throw new Error("Cerebras API key is required")
if (!cleanApiKey) {
throw new Error("Cerebras API key is required")
}
try {
this.client = new Cerebras({
apiKey: cleanApiKey,
timeout: 30000, // 30 second timeout
})
} catch (error) {
throw new Error(`Error creating Cerebras client: ${error.message}`)
}
}
this.client = new Cerebras({
apiKey: cleanApiKey,
timeout: 30000, // 30 second timeout
})
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
// Convert Anthropic messages to Cerebras format
const cerebrasMessages: Array<{
role: "system" | "user" | "assistant"
@@ -81,7 +92,7 @@ export class CerebrasHandler implements ApiHandler {
}
try {
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: this.getModel().id,
messages: cerebrasMessages,
temperature: 0,
+25 -11
View File
@@ -10,28 +10,42 @@ import { withRetry } from "../retry"
export class ClineHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
lastGenerationId?: string
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.cline.bot/v1",
apiKey: this.options.clineApiKey || "",
defaultHeaders: {
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on cline.bot rankings.
"X-Title": "Cline", // Optional. Shows in rankings on cline.bot.
"X-Task-ID": this.options.taskId || "", // Include the task ID in the request headers
},
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.clineApiKey) {
throw new Error("You don't seem to be logged in to a Cline account.")
}
try {
this.client = new OpenAI({
baseURL: "https://api.cline.bot/v1",
apiKey: this.options.clineApiKey || "",
defaultHeaders: {
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on cline.bot rankings.
"X-Title": "Cline", // Optional. Shows in rankings on cline.bot.
"X-Task-ID": this.options.taskId || "", // Include the task ID in the request headers
},
})
} catch (error) {
throw new Error(`Error creating Cline client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
this.lastGenerationId = undefined
const stream = await createOpenRouterStream(
this.client,
client,
systemPrompt,
messages,
this.getModel(),
+20 -6
View File
@@ -10,14 +10,27 @@ import { convertToR1Format } from "../transform/r1-format"
export class DeepSeekHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.deepseek.com/v1",
apiKey: this.options.deepSeekApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.deepSeekApiKey) {
throw new Error("DeepSeek API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.deepseek.com/v1",
apiKey: this.options.deepSeekApiKey,
})
} catch (error) {
throw new Error(`Error creating DeepSeek client: ${error.message}`)
}
}
return this.client
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
@@ -54,6 +67,7 @@ export class DeepSeekHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const isDeepseekReasoner = model.id.includes("deepseek-reasoner")
@@ -67,7 +81,7 @@ export class DeepSeekHandler implements ApiHandler {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
+20 -6
View File
@@ -8,13 +8,26 @@ import { withRetry } from "../retry"
export class DoubaoHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
apiKey: this.options.doubaoApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.doubaoApiKey) {
throw new Error("Doubao API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://ark.cn-beijing.volces.com/api/v3/",
apiKey: this.options.doubaoApiKey,
})
} catch (error) {
throw new Error(`Error creating Doubao client: ${error.message}`)
}
}
return this.client
}
getModel(): { id: DoubaoModelId; info: ModelInfo } {
@@ -31,12 +44,13 @@ export class DoubaoHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
+20 -6
View File
@@ -15,18 +15,32 @@ import { ApiStream } from "../transform/stream"
export class FireworksHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.fireworks.ai/inference/v1",
apiKey: this.options.fireworksApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.fireworksApiKey) {
throw new Error("Fireworks API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.fireworks.ai/inference/v1",
apiKey: this.options.fireworksApiKey,
})
} catch (error) {
throw new Error(`Error creating Fireworks client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const modelId = this.options.fireworksModelId ?? ""
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -34,7 +48,7 @@ export class FireworksHandler implements ApiHandler {
...convertToOpenAiMessages(messages),
]
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: modelId,
...(this.options.fireworksModelMaxCompletionTokens
? { max_completion_tokens: this.options.fireworksModelMaxCompletionTokens }
+35 -18
View File
@@ -38,30 +38,45 @@ interface GeminiHandlerOptions extends ApiHandlerOptions {
*/
export class GeminiHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: GoogleGenAI
private client: GoogleGenAI | undefined
constructor(options: GeminiHandlerOptions) {
// Store the options
this.options = options
}
if (options.isVertex) {
// Initialize with Vertex AI configuration
const project = this.options.vertexProjectId ?? "not-provided"
const location = this.options.vertexRegion ?? "not-provided"
private ensureClient(): GoogleGenAI {
if (!this.client) {
const options = this.options as GeminiHandlerOptions
this.client = new GoogleGenAI({
vertexai: true,
project,
location,
})
} else {
// Initialize with standard API key
if (!options.geminiApiKey) {
throw new Error("API key is required for Google Gemini when not using Vertex AI")
if (options.isVertex) {
// Initialize with Vertex AI configuration
const project = this.options.vertexProjectId ?? "not-provided"
const location = this.options.vertexRegion ?? "not-provided"
try {
this.client = new GoogleGenAI({
vertexai: true,
project,
location,
})
} catch (error) {
throw new Error(`Error creating Gemini Vertex AI client: ${error.message}`)
}
} else {
// Initialize with standard API key
if (!options.geminiApiKey) {
throw new Error("API key is required for Google Gemini when not using Vertex AI")
}
try {
this.client = new GoogleGenAI({ apiKey: options.geminiApiKey })
} catch (error) {
throw new Error(`Error creating Gemini client: ${error.message}`)
}
}
this.client = new GoogleGenAI({ apiKey: options.geminiApiKey })
}
return this.client
}
/**
@@ -80,6 +95,7 @@ export class GeminiHandler implements ApiHandler {
maxDelay: 15000,
})
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const { id: modelId, info } = this.getModel()
const contents = messages.map(convertAnthropicMessageToGemini)
@@ -117,7 +133,7 @@ export class GeminiHandler implements ApiHandler {
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
try {
const result = await this.client.models.generateContentStream({
const result = await client.models.generateContentStream({
model: modelId,
contents: contents,
config: {
@@ -351,6 +367,7 @@ export class GeminiHandler implements ApiHandler {
*/
async countTokens(content: Array<any>): Promise<number> {
try {
const client = this.ensureClient()
const { id: model } = this.getModel()
// Convert content to Gemini format
@@ -362,7 +379,7 @@ export class GeminiHandler implements ApiHandler {
})
// Use Gemini's token counting API
const response = await this.client.models.countTokens({
const response = await client.models.countTokens({
model,
contents: [{ parts: geminiContent }],
})
+22 -7
View File
@@ -8,21 +8,35 @@ import { withRetry } from "../retry"
export class LiteLlmHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: this.options.liteLlmBaseUrl || "http://localhost:4000",
apiKey: this.options.liteLlmApiKey || "noop",
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.liteLlmApiKey) {
throw new Error("LiteLLM API key is required")
}
try {
this.client = new OpenAI({
baseURL: this.options.liteLlmBaseUrl || "http://localhost:4000",
apiKey: this.options.liteLlmApiKey || "noop",
})
} catch (error) {
throw new Error(`Error creating LiteLLM client: ${error.message}`)
}
}
return this.client
}
async calculateCost(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
const client = this.ensureClient()
const modelId = this.options.liteLlmModelId || liteLlmDefaultModelId
try {
const response = await fetch(`${this.client.baseURL}/spend/calculate`, {
const response = await fetch(`${client.baseURL}/spend/calculate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -54,6 +68,7 @@ export class LiteLlmHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const formattedMessages = convertToOpenAiMessages(messages)
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
role: "system",
@@ -101,7 +116,7 @@ export class LiteLlmHandler implements ApiHandler {
return message
})
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: this.options.liteLlmModelId || liteLlmDefaultModelId,
messages: [enhancedSystemMessage, ...enhancedMessages],
temperature,
+17 -6
View File
@@ -8,25 +8,36 @@ import { withRetry } from "../retry"
export class LmStudioHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
apiKey: "noop",
})
}
private ensureClient(): OpenAI {
if (!this.client) {
try {
this.client = new OpenAI({
baseURL: (this.options.lmStudioBaseUrl || "http://localhost:1234") + "/v1",
apiKey: "noop",
})
} catch (error) {
throw new Error(`Error creating LM Studio client: ${error.message}`)
}
}
return this.client
}
@withRetry({ retryAllErrors: true })
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
try {
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
stream: true,
+19 -5
View File
@@ -8,18 +8,32 @@ import { ApiStream } from "../transform/stream"
export class MistralHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Mistral
private client: Mistral | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new Mistral({
apiKey: this.options.mistralApiKey,
})
}
private ensureClient(): Mistral {
if (!this.client) {
if (!this.options.mistralApiKey) {
throw new Error("Mistral API key is required")
}
try {
this.client = new Mistral({
apiKey: this.options.mistralApiKey,
})
} catch (error) {
throw new Error(`Error creating Mistral client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const stream = await this.client.chat
const client = this.ensureClient()
const stream = await client.chat
.stream({
model: this.getModel().id,
// max_completion_tokens: this.getModel().info.maxTokens,
+20 -7
View File
@@ -8,24 +8,37 @@ import { convertToR1Format } from "../transform/r1-format"
import { nebiusDefaultModelId, nebiusModels, type ModelInfo, type ApiHandlerOptions, type NebiusModelId } from "../../shared/api"
export class NebiusHandler implements ApiHandler {
private client: OpenAI
private client: OpenAI | undefined
constructor(private readonly options: ApiHandlerOptions) {
this.client = new OpenAI({
baseURL: "https://api.studio.nebius.ai/v1",
apiKey: this.options.nebiusApiKey,
})
constructor(private readonly options: ApiHandlerOptions) {}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.nebiusApiKey) {
throw new Error("Nebius API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.studio.nebius.ai/v1",
apiKey: this.options.nebiusApiKey,
})
} catch (error) {
throw new Error(`Error creating Nebius client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = model.id.includes("DeepSeek-R1")
? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)]
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
messages: openAiMessages,
temperature: 0,
+14 -3
View File
@@ -8,15 +8,26 @@ import { withRetry } from "../retry"
export class OllamaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Ollama
private client: Ollama | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
}
private ensureClient(): Ollama {
if (!this.client) {
try {
this.client = new Ollama({ host: this.options.ollamaBaseUrl || "http://localhost:11434" })
} catch (error) {
throw new Error(`Error creating Ollama client: ${error.message}`)
}
}
return this.client
}
@withRetry({ retryAllErrors: true })
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
try {
@@ -27,7 +38,7 @@ export class OllamaHandler implements ApiHandler {
})
// Create the actual API request promise
const apiPromise = this.client.chat({
const apiPromise = client.chat({
model: this.getModel().id,
messages: ollamaMessages,
stream: true,
+21 -7
View File
@@ -10,13 +10,26 @@ import type { ChatCompletionReasoningEffort } from "openai/resources/chat/comple
export class OpenAiNativeHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
apiKey: this.options.openAiNativeApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.openAiNativeApiKey) {
throw new Error("OpenAI API key is required")
}
try {
this.client = new OpenAI({
apiKey: this.options.openAiNativeApiKey,
})
} catch (error: any) {
throw new Error(`Error creating OpenAI client: ${error.message}`)
}
}
return this.client
}
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
@@ -38,6 +51,7 @@ export class OpenAiNativeHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
switch (model.id) {
@@ -45,7 +59,7 @@ export class OpenAiNativeHandler implements ApiHandler {
case "o1-preview":
case "o1-mini": {
// o1 doesn't support streaming, non-1 temp, or system prompt
const response = await this.client.chat.completions.create({
const response = await client.chat.completions.create({
model: model.id,
messages: [{ role: "user", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
})
@@ -61,7 +75,7 @@ export class OpenAiNativeHandler implements ApiHandler {
case "o4-mini":
case "o3":
case "o3-mini": {
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
messages: [{ role: "developer", content: systemPrompt }, ...convertToOpenAiMessages(messages)],
stream: true,
@@ -85,7 +99,7 @@ export class OpenAiNativeHandler implements ApiHandler {
break
}
default: {
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
// max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
+36 -22
View File
@@ -10,35 +10,49 @@ import type { ChatCompletionReasoningEffort } from "openai/resources/chat/comple
export class OpenAiHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
if (
this.options.azureApiVersion ||
((this.options.openAiBaseUrl?.toLowerCase().includes("azure.com") ||
this.options.openAiBaseUrl?.toLowerCase().includes("azure.us")) &&
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
) {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: this.options.openAiHeaders,
})
} else {
this.client = new OpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
defaultHeaders: this.options.openAiHeaders,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.openAiApiKey) {
throw new Error("OpenAI API key is required")
}
try {
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
if (
this.options.azureApiVersion ||
((this.options.openAiBaseUrl?.toLowerCase().includes("azure.com") ||
this.options.openAiBaseUrl?.toLowerCase().includes("azure.us")) &&
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
) {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: this.options.openAiHeaders,
})
} else {
this.client = new OpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
defaultHeaders: this.options.openAiHeaders,
})
}
} catch (error: any) {
throw new Error(`Error creating OpenAI client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const modelId = this.options.openAiModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
const isR1FormatRequired = this.options.openAiModelInfo?.isR1FormatRequired ?? false
@@ -68,7 +82,7 @@ export class OpenAiHandler implements ApiHandler {
reasoningEffort = (this.options.reasoningEffort as ChatCompletionReasoningEffort) || "medium"
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: modelId,
messages: openAiMessages,
temperature,
+24 -10
View File
@@ -11,27 +11,41 @@ import { OpenRouterErrorResponse } from "./types"
export class OpenRouterHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
lastGenerationId?: string
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: this.options.openRouterApiKey,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on openrouter.ai rankings.
"X-Title": "Cline", // Optional. Shows in rankings on openrouter.ai.
},
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.openRouterApiKey) {
throw new Error("OpenRouter API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: this.options.openRouterApiKey,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on openrouter.ai rankings.
"X-Title": "Cline", // Optional. Shows in rankings on openrouter.ai.
},
})
} catch (error: any) {
throw new Error(`Error creating OpenRouter client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
this.lastGenerationId = undefined
const stream = await createOpenRouterStream(
this.client,
client,
systemPrompt,
messages,
this.getModel(),
+23 -9
View File
@@ -18,17 +18,30 @@ import { withRetry } from "../retry"
export class QwenHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL:
this.options.qwenApiLine === "china"
? "https://dashscope.aliyuncs.com/compatible-mode/v1"
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
apiKey: this.options.qwenApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.qwenApiKey) {
throw new Error("Alibaba API key is required")
}
try {
this.client = new OpenAI({
baseURL:
this.options.qwenApiLine === "china"
? "https://dashscope.aliyuncs.com/compatible-mode/v1"
: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
apiKey: this.options.qwenApiKey,
})
} catch (error: any) {
throw new Error(`Error creating Alibaba client: ${error.message}`)
}
}
return this.client
}
getModel(): { id: MainlandQwenModelId | InternationalQwenModelId; info: ModelInfo } {
@@ -51,6 +64,7 @@ export class QwenHandler implements ApiHandler {
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const isDeepseekReasoner = model.id.includes("deepseek-r1")
const isReasoningModelFamily = model.id.includes("qwen3") || ["qwen-plus-latest", "qwen-turbo-latest"].includes(model.id)
@@ -76,7 +90,7 @@ export class QwenHandler implements ApiHandler {
temperature = undefined
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
max_completion_tokens: model.info.maxTokens,
messages: openAiMessages,
+24 -10
View File
@@ -19,22 +19,36 @@ interface RequestyUsage extends OpenAI.CompletionUsage {
export class RequestyHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://router.requesty.ai/v1",
apiKey: this.options.requestyApiKey,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
},
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.requestyApiKey) {
throw new Error("Requesty API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://router.requesty.ai/v1",
apiKey: this.options.requestyApiKey,
defaultHeaders: {
"HTTP-Referer": "https://cline.bot",
"X-Title": "Cline",
},
})
} catch (error: any) {
throw new Error(`Error creating Requesty client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -57,7 +71,7 @@ export class RequestyHandler implements ApiHandler {
: {}
// @ts-ignore-next-line
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: model.info.maxTokens || undefined,
messages: openAiMessages,
+20 -6
View File
@@ -9,18 +9,32 @@ import { convertToR1Format } from "@api/transform/r1-format"
export class SambanovaHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.sambanova.ai/v1",
apiKey: this.options.sambanovaApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.sambanovaApiKey) {
throw new Error("SambaNova API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.sambanova.ai/v1",
apiKey: this.options.sambanovaApiKey,
})
} catch (error: any) {
throw new Error(`Error creating SambaNova client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const model = this.getModel()
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
@@ -34,7 +48,7 @@ export class SambanovaHandler implements ApiHandler {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: this.getModel().id,
messages: openAiMessages,
temperature: 0,
+20 -6
View File
@@ -9,18 +9,32 @@ import { convertToR1Format } from "@api/transform/r1-format"
export class TogetherHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.together.xyz/v1",
apiKey: this.options.togetherApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.togetherApiKey) {
throw new Error("Together API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.together.xyz/v1",
apiKey: this.options.togetherApiKey,
})
} catch (error: any) {
throw new Error(`Error creating Together client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const modelId = this.options.togetherModelId ?? ""
const isDeepseekReasoner = modelId.includes("deepseek-reasoner")
@@ -33,7 +47,7 @@ export class TogetherHandler implements ApiHandler {
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: modelId,
messages: openAiMessages,
temperature: 0,
+43 -16
View File
@@ -7,25 +7,49 @@ import { ApiStream } from "@api/transform/stream"
import { GeminiHandler } from "./gemini"
export class VertexHandler implements ApiHandler {
private geminiHandler: GeminiHandler
private clientAnthropic: AnthropicVertex
private geminiHandler: GeminiHandler | undefined
private clientAnthropic: AnthropicVertex | undefined
private options: ApiHandlerOptions
constructor(options: ApiHandlerOptions) {
this.options = options
}
// Create a GeminiHandler with isVertex flag for Gemini models
this.geminiHandler = new GeminiHandler({
...options,
isVertex: true,
})
private ensureGeminiHandler(): GeminiHandler {
if (!this.geminiHandler) {
try {
// Create a GeminiHandler with isVertex flag for Gemini models
this.geminiHandler = new GeminiHandler({
...this.options,
isVertex: true,
})
} catch (error: any) {
throw new Error(`Error creating Vertex AI Gemini handler: ${error.message}`)
}
}
return this.geminiHandler
}
// Initialize Anthropic client for Claude models
this.clientAnthropic = new AnthropicVertex({
projectId: this.options.vertexProjectId,
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
region: this.options.vertexRegion,
})
private ensureAnthropicClient(): AnthropicVertex {
if (!this.clientAnthropic) {
if (!this.options.vertexProjectId) {
throw new Error("Vertex AI project ID is required")
}
if (!this.options.vertexRegion) {
throw new Error("Vertex AI region is required")
}
try {
// Initialize Anthropic client for Claude models
this.clientAnthropic = new AnthropicVertex({
projectId: this.options.vertexProjectId,
// https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#regions
region: this.options.vertexRegion,
})
} catch (error: any) {
throw new Error(`Error creating Vertex AI Anthropic client: ${error.message}`)
}
}
return this.clientAnthropic
}
@withRetry()
@@ -35,10 +59,13 @@ export class VertexHandler implements ApiHandler {
// For Gemini models, use the GeminiHandler
if (!modelId.includes("claude")) {
yield* this.geminiHandler.createMessage(systemPrompt, messages)
const geminiHandler = this.ensureGeminiHandler()
yield* geminiHandler.createMessage(systemPrompt, messages)
return
}
const clientAnthropic = this.ensureAnthropicClient()
// Claude implementation
let budget_tokens = this.options.thinkingBudgetTokens || 0
const reasoningOn =
@@ -63,7 +90,7 @@ export class VertexHandler implements ApiHandler {
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
stream = await this.clientAnthropic.beta.messages.create(
stream = await clientAnthropic.beta.messages.create(
{
model: modelId,
max_tokens: model.info.maxTokens || 8192,
@@ -125,7 +152,7 @@ export class VertexHandler implements ApiHandler {
break
}
default: {
stream = await this.clientAnthropic.beta.messages.create({
stream = await clientAnthropic.beta.messages.create({
model: modelId,
max_tokens: model.info.maxTokens || 8192,
temperature: 0,
+20 -6
View File
@@ -9,18 +9,32 @@ import { withRetry } from "../retry"
export class XAIHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
private client: OpenAI | undefined
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://api.x.ai/v1",
apiKey: this.options.xaiApiKey,
})
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.xaiApiKey) {
throw new Error("xAI API key is required")
}
try {
this.client = new OpenAI({
baseURL: "https://api.x.ai/v1",
apiKey: this.options.xaiApiKey,
})
} catch (error: any) {
throw new Error(`Error creating xAI client: ${error.message}`)
}
}
return this.client
}
@withRetry()
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
const client = this.ensureClient()
const modelId = this.getModel().id
// ensure reasoning effort is either "low" or "high" for grok-3-mini
let reasoningEffort: ChatCompletionReasoningEffort | undefined
@@ -30,7 +44,7 @@ export class XAIHandler implements ApiHandler {
reasoningEffort = undefined
}
}
const stream = await this.client.chat.completions.create({
const stream = await client.chat.completions.create({
model: modelId,
max_completion_tokens: this.getModel().info.maxTokens,
temperature: 0,
+3
View File
@@ -472,6 +472,9 @@ export class Controller {
const clineProvider: ApiProvider = "cline"
await updateGlobalState(this.context, "apiProvider", clineProvider)
// Mark welcome view as completed since user has successfully logged in
await updateGlobalState(this.context, "welcomeViewCompleted", true)
// Update API configuration with the new provider and API key
const { apiConfiguration } = await getAllExtensionState(this.context)
const updatedConfig = {
+14
View File
@@ -2124,6 +2124,13 @@ export class Task {
this.api.getModel().id,
"assistant",
true,
{
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
},
)
// signals to provider that it can retrieve the saved messages from disk, as abortTask can not be awaited on in nature
@@ -2297,6 +2304,13 @@ export class Task {
this.api.getModel().id,
"assistant",
true,
{
tokensIn: inputTokens,
tokensOut: outputTokens,
cacheWriteTokens,
cacheReadTokens,
totalCost,
},
)
await this.messageStateHandler.addToApiConversationHistory({
@@ -202,26 +202,23 @@ class TelemetryService {
const propertiesWithVersion = this.addProperties(event.properties)
const capturedEvent = {
event: event.event,
properties: propertiesWithVersion,
}
if (collect && taskId) {
const existingTask = this.collectedTasks.find((task) => task.taskId === taskId)
if (existingTask) {
existingTask.collection.push({
event: event.event,
properties: propertiesWithVersion,
})
existingTask.collection.push(capturedEvent)
} else {
this.collectedTasks.push({
taskId,
collection: [
{
event: event.event,
properties: propertiesWithVersion,
},
],
collection: [capturedEvent],
})
}
} else {
this.client.capture({ distinctId: this.distinctId, event: event.event, properties: propertiesWithVersion })
this.client.capture({ ...capturedEvent, distinctId: this.distinctId })
}
}
@@ -288,6 +285,8 @@ class TelemetryService {
* @param provider The API provider (e.g., OpenAI, Anthropic)
* @param model The specific model used (e.g., GPT-4, Claude)
* @param source The source of the message ("user" | "model"). Used to track message patterns and identify when users need to correct the model's responses.
* @param collect If true, collect event instead of sending
* @param tokenUsage Optional token usage data
*/
public captureConversationTurnEvent(
taskId: string,
@@ -295,6 +294,13 @@ class TelemetryService {
model: string = "unknown",
source: "user" | "assistant",
collect: boolean = false,
tokenUsage: {
tokensIn?: number
tokensOut?: number
cacheWriteTokens?: number
cacheReadTokens?: number
totalCost?: number
} = {},
) {
// Ensure required parameters are provided
if (!taskId || !provider || !model || !source) {
@@ -308,6 +314,7 @@ class TelemetryService {
model,
source,
timestamp: new Date().toISOString(), // Add timestamp for message sequencing
...tokenUsage,
}
this.capture(
@@ -64,6 +64,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
const { handleFieldsChange } = useApiConfigurationHandlers()
const { apiConfiguration, openRouterModels, refreshOpenRouterModels } = useExtensionState()
const [searchTerm, setSearchTerm] = useState(apiConfiguration?.openRouterModelId || openRouterDefaultModelId)
const [isSearchInputDirty, setIsSearchInputDirty] = useState(false)
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const dropdownRef = useRef<HTMLDivElement>(null)
@@ -87,6 +88,23 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
useMount(refreshOpenRouterModels)
// Sync external changes only when user isn't actively typing
useEffect(() => {
if (!isSearchInputDirty) {
const currentModelId = apiConfiguration?.openRouterModelId || openRouterDefaultModelId
setSearchTerm(currentModelId)
}
}, [apiConfiguration?.openRouterModelId, isSearchInputDirty])
// Reset dirty flag after user stops typing (1 second timeout)
useEffect(() => {
if (!isSearchInputDirty) return
const timeout = setTimeout(() => {
setIsSearchInputDirty(false)
}, 1000)
return () => clearTimeout(timeout)
}, [searchTerm, isSearchInputDirty])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
@@ -243,6 +261,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
placeholder="Search and select a model..."
value={searchTerm}
onInput={(e) => {
setIsSearchInputDirty(true)
handleModelChange((e.target as HTMLInputElement)?.value?.toLowerCase())
setIsDropdownVisible(true)
}}