Skip to content

Commit 8cc7759

Browse files
authored
Fix bug where OpenRouter/Cline providers generation endpoint failed (RooCodeInc#2262)
* Fix openrouter/cline provider cost endpoint bug * Increase generation endpoint timeout
1 parent f9d70ce commit 8cc7759

File tree

5 files changed

+143
-72
lines changed

5 files changed

+143
-72
lines changed

src/api/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { OllamaHandler } from "./providers/ollama"
99
import { LmStudioHandler } from "./providers/lmstudio"
1010
import { GeminiHandler } from "./providers/gemini"
1111
import { OpenAiNativeHandler } from "./providers/openai-native"
12-
import { ApiStream } from "./transform/stream"
12+
import { ApiStream, ApiStreamUsageChunk } from "./transform/stream"
1313
import { DeepSeekHandler } from "./providers/deepseek"
1414
import { RequestyHandler } from "./providers/requesty"
1515
import { TogetherHandler } from "./providers/together"
@@ -25,6 +25,7 @@ import { SambanovaHandler } from "./providers/sambanova"
2525
export interface ApiHandler {
2626
createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream
2727
getModel(): { id: string; info: ModelInfo }
28+
getApiStreamUsage?(): Promise<ApiStreamUsageChunk | undefined>
2829
}
2930

3031
export interface SingleCompletionHandler {

src/api/providers/cline.ts

Lines changed: 67 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@ import { Anthropic } from "@anthropic-ai/sdk"
22
import OpenAI from "openai"
33
import { ApiHandler } from "../"
44
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
5-
import { streamOpenRouterFormatRequest } from "../transform/openrouter-stream"
6-
import { ApiStream } from "../transform/stream"
5+
import { createOpenRouterStream } from "../transform/openrouter-stream"
6+
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
77
import axios from "axios"
8+
import { OpenRouterErrorResponse } from "./types"
89

910
export class ClineHandler implements ApiHandler {
1011
private options: ApiHandlerOptions
1112
private client: OpenAI
13+
lastGenerationId?: string
1214

1315
constructor(options: ApiHandlerOptions) {
1416
this.options = options
@@ -19,36 +21,78 @@ export class ClineHandler implements ApiHandler {
1921
}
2022

2123
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
22-
const model = this.getModel()
23-
const genId = yield* streamOpenRouterFormatRequest(
24+
this.lastGenerationId = undefined
25+
26+
const stream = await createOpenRouterStream(
2427
this.client,
2528
systemPrompt,
2629
messages,
27-
model,
30+
this.getModel(),
2831
this.options.o3MiniReasoningEffort,
2932
this.options.thinkingBudgetTokens,
3033
)
3134

32-
try {
33-
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${genId}`, {
34-
headers: {
35-
Authorization: `Bearer ${this.options.clineApiKey}`,
36-
},
37-
timeout: 5_000, // this request hangs sometimes
38-
})
39-
40-
const generation = response.data
41-
console.log("cline generation details:", generation)
42-
yield {
43-
type: "usage",
44-
inputTokens: generation?.native_tokens_prompt || 0,
45-
outputTokens: generation?.native_tokens_completion || 0,
46-
totalCost: generation?.total_cost || 0,
35+
for await (const chunk of stream) {
36+
// openrouter returns an error object instead of the openai sdk throwing an error
37+
if ("error" in chunk) {
38+
const error = chunk.error as OpenRouterErrorResponse["error"]
39+
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
40+
// Include metadata in the error message if available
41+
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
42+
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
43+
}
44+
45+
if (!this.lastGenerationId && chunk.id) {
46+
this.lastGenerationId = chunk.id
47+
}
48+
49+
const delta = chunk.choices[0]?.delta
50+
if (delta?.content) {
51+
yield {
52+
type: "text",
53+
text: delta.content,
54+
}
55+
}
56+
57+
// Reasoning tokens are returned separately from the content
58+
if ("reasoning" in delta && delta.reasoning) {
59+
yield {
60+
type: "reasoning",
61+
// @ts-ignore-next-line
62+
reasoning: delta.reasoning,
63+
}
64+
}
65+
}
66+
67+
const apiStreamUsage = await this.getApiStreamUsage()
68+
if (apiStreamUsage) {
69+
yield apiStreamUsage
70+
}
71+
}
72+
73+
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
74+
if (this.lastGenerationId) {
75+
try {
76+
const response = await axios.get(`https://api.cline.bot/v1/generation?id=${this.lastGenerationId}`, {
77+
headers: {
78+
Authorization: `Bearer ${this.options.clineApiKey}`,
79+
},
80+
timeout: 15_000, // this request hangs sometimes
81+
})
82+
83+
const generation = response.data
84+
return {
85+
type: "usage",
86+
inputTokens: generation?.native_tokens_prompt || 0,
87+
outputTokens: generation?.native_tokens_completion || 0,
88+
totalCost: generation?.total_cost || 0,
89+
}
90+
} catch (error) {
91+
// ignore if fails
92+
console.error("Error fetching cline generation details:", error)
4793
}
48-
} catch (error) {
49-
// ignore if fails
50-
console.error("Error fetching cline generation details:", error)
5194
}
95+
return undefined
5296
}
5397

5498
getModel(): { id: string; info: ModelInfo } {

src/api/providers/openrouter.ts

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@ import { Anthropic } from "@anthropic-ai/sdk"
22
import axios from "axios"
33
import delay from "delay"
44
import OpenAI from "openai"
5-
import { withRetry } from "../retry"
65
import { ApiHandler } from "../"
76
import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api"
8-
import { streamOpenRouterFormatRequest } from "../transform/openrouter-stream"
9-
import { ApiStream } from "../transform/stream"
10-
import { convertToR1Format } from "../transform/r1-format"
7+
import { withRetry } from "../retry"
8+
import { createOpenRouterStream } from "../transform/openrouter-stream"
9+
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
1110
import { OpenRouterErrorResponse } from "./types"
1211

1312
export class OpenRouterHandler implements ApiHandler {
1413
private options: ApiHandlerOptions
1514
private client: OpenAI
15+
lastGenerationId?: string
1616

1717
constructor(options: ApiHandlerOptions) {
1818
this.options = options
@@ -28,23 +28,63 @@ export class OpenRouterHandler implements ApiHandler {
2828

2929
@withRetry()
3030
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
31-
const model = this.getModel()
32-
const genId = yield* streamOpenRouterFormatRequest(
31+
this.lastGenerationId = undefined
32+
33+
const stream = await createOpenRouterStream(
3334
this.client,
3435
systemPrompt,
3536
messages,
36-
model,
37+
this.getModel(),
3738
this.options.o3MiniReasoningEffort,
3839
this.options.thinkingBudgetTokens,
3940
)
4041

41-
if (genId) {
42+
for await (const chunk of stream) {
43+
// openrouter returns an error object instead of the openai sdk throwing an error
44+
if ("error" in chunk) {
45+
const error = chunk.error as OpenRouterErrorResponse["error"]
46+
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
47+
// Include metadata in the error message if available
48+
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
49+
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
50+
}
51+
52+
if (!this.lastGenerationId && chunk.id) {
53+
this.lastGenerationId = chunk.id
54+
}
55+
56+
const delta = chunk.choices[0]?.delta
57+
if (delta?.content) {
58+
yield {
59+
type: "text",
60+
text: delta.content,
61+
}
62+
}
63+
64+
// Reasoning tokens are returned separately from the content
65+
if ("reasoning" in delta && delta.reasoning) {
66+
yield {
67+
type: "reasoning",
68+
// @ts-ignore-next-line
69+
reasoning: delta.reasoning,
70+
}
71+
}
72+
}
73+
74+
const apiStreamUsage = await this.getApiStreamUsage()
75+
if (apiStreamUsage) {
76+
yield apiStreamUsage
77+
}
78+
}
79+
80+
async getApiStreamUsage(): Promise<ApiStreamUsageChunk | undefined> {
81+
if (this.lastGenerationId) {
4282
await delay(500) // FIXME: necessary delay to ensure generation endpoint is ready
4383
try {
44-
const generationIterator = this.fetchGenerationDetails(genId)
84+
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
4585
const generation = (await generationIterator.next()).value
4686
// console.log("OpenRouter generation details:", generation)
47-
yield {
87+
return {
4888
type: "usage",
4989
// cacheWriteTokens: 0,
5090
// cacheReadTokens: 0,
@@ -58,6 +98,7 @@ export class OpenRouterHandler implements ApiHandler {
5898
console.error("Error fetching OpenRouter generation details:", error)
5999
}
60100
}
101+
return undefined
61102
}
62103

63104
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
@@ -68,7 +109,7 @@ export class OpenRouterHandler implements ApiHandler {
68109
headers: {
69110
Authorization: `Bearer ${this.options.openRouterApiKey}`,
70111
},
71-
timeout: 5_000, // this request hangs sometimes
112+
timeout: 15_000, // this request hangs sometimes
72113
})
73114
yield response.data?.data
74115
} catch (error) {

src/api/transform/openrouter-stream.ts

Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ import { Anthropic } from "@anthropic-ai/sdk"
66
import OpenAI from "openai"
77
import { OpenRouterErrorResponse } from "../providers/types"
88

9-
export async function* streamOpenRouterFormatRequest(
9+
export async function createOpenRouterStream(
1010
client: OpenAI,
1111
systemPrompt: string,
1212
messages: Anthropic.Messages.MessageParam[],
1313
model: { id: string; info: ModelInfo },
1414
o3MiniReasoningEffort?: string,
1515
thinkingBudgetTokens?: number,
16-
): AsyncGenerator<ApiStreamChunk, string | undefined, unknown> {
16+
) {
1717
// Convert Anthropic messages to OpenAI format
1818
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
1919
{ role: "system", content: systemPrompt },
@@ -147,39 +147,5 @@ export async function* streamOpenRouterFormatRequest(
147147
...(reasoning ? { reasoning } : {}),
148148
})
149149

150-
let genId: string | undefined
151-
152-
for await (const chunk of stream) {
153-
// openrouter returns an error object instead of the openai sdk throwing an error
154-
if ("error" in chunk) {
155-
const error = chunk.error as OpenRouterErrorResponse["error"]
156-
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
157-
// Include metadata in the error message if available
158-
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
159-
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
160-
}
161-
162-
if (!genId && chunk.id) {
163-
genId = chunk.id
164-
}
165-
166-
const delta = chunk.choices[0]?.delta
167-
if (delta?.content) {
168-
yield {
169-
type: "text",
170-
text: delta.content,
171-
}
172-
}
173-
174-
// Reasoning tokens are returned separately from the content
175-
if ("reasoning" in delta && delta.reasoning) {
176-
yield {
177-
type: "reasoning",
178-
// @ts-ignore-next-line
179-
reasoning: delta.reasoning,
180-
}
181-
}
182-
}
183-
184-
return genId
150+
return stream
185151
}

src/core/Cline.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3218,13 +3218,15 @@ export class Cline {
32183218
let assistantMessage = ""
32193219
let reasoningMessage = ""
32203220
this.isStreaming = true
3221+
let didReceiveUsageChunk = false
32213222
try {
32223223
for await (const chunk of stream) {
32233224
if (!chunk) {
32243225
continue
32253226
}
32263227
switch (chunk.type) {
32273228
case "usage":
3229+
didReceiveUsageChunk = true
32283230
inputTokens += chunk.inputTokens
32293231
outputTokens += chunk.outputTokens
32303232
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
@@ -3294,6 +3296,23 @@ export class Cline {
32943296
this.isStreaming = false
32953297
}
32963298

3299+
// OpenRouter/Cline may not return token usage as part of the stream (since it may abort early), so we fetch after the stream is finished
3300+
// (updateApiReq below will update the api_req_started message with the usage details. we do this async so it updates the api_req_started message in the background)
3301+
if (!didReceiveUsageChunk) {
3302+
this.api.getApiStreamUsage?.().then(async (apiStreamUsage) => {
3303+
if (apiStreamUsage) {
3304+
inputTokens += apiStreamUsage.inputTokens
3305+
outputTokens += apiStreamUsage.outputTokens
3306+
cacheWriteTokens += apiStreamUsage.cacheWriteTokens ?? 0
3307+
cacheReadTokens += apiStreamUsage.cacheReadTokens ?? 0
3308+
totalCost = apiStreamUsage.totalCost
3309+
}
3310+
updateApiReqMsg()
3311+
await this.saveClineMessages()
3312+
await this.providerRef.deref()?.postStateToWebview()
3313+
})
3314+
}
3315+
32973316
// need to call here in case the stream was aborted
32983317
if (this.abort) {
32993318
throw new Error("Cline instance aborted")

0 commit comments

Comments
 (0)