Skip to content

Commit 0cd2e69

Browse files
committed
Revert "Revert "Fix bug where OpenRouter/Cline providers generation endpoint failed (RooCodeInc#2262)""
This reverts commit 2ca4738.
1 parent 4b19010 commit 0cd2e69

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,37 +21,79 @@ 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
this.options.openRouterProviderSorting,
3134
)
3235

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

5599
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,24 +28,64 @@ 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
this.options.openRouterProviderSorting,
4041
)
4142

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

64105
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
@@ -69,7 +110,7 @@ export class OpenRouterHandler implements ApiHandler {
69110
headers: {
70111
Authorization: `Bearer ${this.options.openRouterApiKey}`,
71112
},
72-
timeout: 5_000, // this request hangs sometimes
113+
timeout: 15_000, // this request hangs sometimes
73114
})
74115
yield response.data?.data
75116
} catch (error) {

src/api/transform/openrouter-stream.ts

Lines changed: 3 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@ 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,
1616
openRouterProviderSorting?: string,
17-
): AsyncGenerator<ApiStreamChunk, string | undefined, unknown> {
17+
) {
1818
// Convert Anthropic messages to OpenAI format
1919
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
2020
{ role: "system", content: systemPrompt },
@@ -149,39 +149,5 @@ export async function* streamOpenRouterFormatRequest(
149149
...(openRouterProviderSorting ? { provider: { sort: openRouterProviderSorting } } : {}),
150150
})
151151

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

src/core/Cline.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3374,13 +3374,15 @@ export class Cline {
33743374
let assistantMessage = ""
33753375
let reasoningMessage = ""
33763376
this.isStreaming = true
3377+
let didReceiveUsageChunk = false
33773378
try {
33783379
for await (const chunk of stream) {
33793380
if (!chunk) {
33803381
continue
33813382
}
33823383
switch (chunk.type) {
33833384
case "usage":
3385+
didReceiveUsageChunk = true
33843386
inputTokens += chunk.inputTokens
33853387
outputTokens += chunk.outputTokens
33863388
cacheWriteTokens += chunk.cacheWriteTokens ?? 0
@@ -3450,6 +3452,23 @@ export class Cline {
34503452
this.isStreaming = false
34513453
}
34523454

3455+
// 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
3456+
// (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)
3457+
if (!didReceiveUsageChunk) {
3458+
this.api.getApiStreamUsage?.().then(async (apiStreamUsage) => {
3459+
if (apiStreamUsage) {
3460+
inputTokens += apiStreamUsage.inputTokens
3461+
outputTokens += apiStreamUsage.outputTokens
3462+
cacheWriteTokens += apiStreamUsage.cacheWriteTokens ?? 0
3463+
cacheReadTokens += apiStreamUsage.cacheReadTokens ?? 0
3464+
totalCost = apiStreamUsage.totalCost
3465+
}
3466+
updateApiReqMsg()
3467+
await this.saveClineMessages()
3468+
await this.providerRef.deref()?.postStateToWebview()
3469+
})
3470+
}
3471+
34533472
// need to call here in case the stream was aborted
34543473
if (this.abort) {
34553474
throw new Error("Cline instance aborted")

0 commit comments

Comments
 (0)