From a9ca17717c438ffffb22e1776ce20f9e1a85ca7d Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Wed, 23 Apr 2025 06:45:57 -0700 Subject: [PATCH] OpenRouter Gemini caching (#2847) * OpenRouter Gemini caching * Fix tests * Remove unsupported models * Clean up the task header a bit * Update src/api/providers/openrouter.ts Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> * Remove model that doesn't seem to work --------- Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- .../providers/__tests__/openrouter.test.ts | 500 +++++++++--------- src/api/providers/openrouter.ts | 134 +++-- src/api/transform/stream.ts | 4 +- webview-ui/src/components/chat/ChatRow.tsx | 6 +- .../components/chat/ContextWindowProgress.tsx | 90 ++++ webview-ui/src/components/chat/Mention.tsx | 33 ++ .../src/components/chat/TaskActions.tsx | 54 ++ webview-ui/src/components/chat/TaskHeader.tsx | 229 ++------ 8 files changed, 545 insertions(+), 505 deletions(-) create mode 100644 webview-ui/src/components/chat/ContextWindowProgress.tsx create mode 100644 webview-ui/src/components/chat/Mention.tsx create mode 100644 webview-ui/src/components/chat/TaskActions.tsx diff --git a/src/api/providers/__tests__/openrouter.test.ts b/src/api/providers/__tests__/openrouter.test.ts index 996644b07f..d592e6c968 100644 --- a/src/api/providers/__tests__/openrouter.test.ts +++ b/src/api/providers/__tests__/openrouter.test.ts @@ -15,7 +15,7 @@ jest.mock("delay", () => jest.fn(() => Promise.resolve())) const mockOpenRouterModelInfo: ModelInfo = { maxTokens: 1000, contextWindow: 2000, - supportsPromptCache: true, + supportsPromptCache: false, inputPrice: 0.01, outputPrice: 0.02, } @@ -31,9 +31,10 @@ describe("OpenRouterHandler", () => { jest.clearAllMocks() }) - test("constructor initializes with correct options", () => { + it("initializes with correct options", () => { const handler = new OpenRouterHandler(mockOptions) expect(handler).toBeInstanceOf(OpenRouterHandler) + expect(OpenAI).toHaveBeenCalledWith({ baseURL: "https://openrouter.ai/api/v1", apiKey: mockOptions.openRouterApiKey, @@ -44,284 +45,257 @@ describe("OpenRouterHandler", () => { }) }) - test("getModel returns correct model info when options are provided", () => { - const handler = new OpenRouterHandler(mockOptions) - const result = handler.getModel() + describe("getModel", () => { + it("returns correct model info when options are provided", () => { + const handler = new OpenRouterHandler(mockOptions) + const result = handler.getModel() - expect(result).toEqual({ - id: mockOptions.openRouterModelId, - info: mockOptions.openRouterModelInfo, - maxTokens: 1000, - temperature: 0, - thinking: undefined, - topP: undefined, - }) - }) - - test("getModel returns default model info when options are not provided", () => { - const handler = new OpenRouterHandler({}) - const result = handler.getModel() - - expect(result.id).toBe("anthropic/claude-3.7-sonnet") - expect(result.info.supportsPromptCache).toBe(true) - }) - - test("getModel honors custom maxTokens for thinking models", () => { - const handler = new OpenRouterHandler({ - openRouterApiKey: "test-key", - openRouterModelId: "test-model", - openRouterModelInfo: { - ...mockOpenRouterModelInfo, - maxTokens: 128_000, - thinking: true, - }, - modelMaxTokens: 32_768, - modelMaxThinkingTokens: 16_384, - }) - - const result = handler.getModel() - expect(result.maxTokens).toBe(32_768) - expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 16_384 }) - expect(result.temperature).toBe(1.0) - }) - - test("getModel does not honor custom maxTokens for non-thinking models", () => { - const handler = new OpenRouterHandler({ - ...mockOptions, - modelMaxTokens: 32_768, - modelMaxThinkingTokens: 16_384, - }) - - const result = handler.getModel() - expect(result.maxTokens).toBe(1000) - expect(result.thinking).toBeUndefined() - expect(result.temperature).toBe(0) - }) - - test("createMessage generates correct stream chunks", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [ - { - delta: { - content: "test response", - }, - }, - ], - } - // Add usage information in the stream response - yield { - id: "test-id", - choices: [{ delta: {} }], - usage: { - prompt_tokens: 10, - completion_tokens: 20, - cost: 0.001, - }, - } - }, - } - - // Mock OpenAI chat.completions.create - const mockCreate = jest.fn().mockResolvedValue(mockStream) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate }, - } as any - - const systemPrompt = "test system prompt" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "test message" }] - - const generator = handler.createMessage(systemPrompt, messages) - const chunks = [] - - for await (const chunk of generator) { - chunks.push(chunk) - } - - // Verify stream chunks - expect(chunks).toHaveLength(2) // One text chunk and one usage chunk - expect(chunks[0]).toEqual({ - type: "text", - text: "test response", - }) - expect(chunks[1]).toEqual({ - type: "usage", - inputTokens: 10, - outputTokens: 20, - totalCost: 0.001, - }) - - // Verify OpenAI client was called with correct parameters - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: mockOptions.openRouterModelId, + expect(result).toEqual({ + id: mockOptions.openRouterModelId, + info: mockOptions.openRouterModelInfo, + maxTokens: 1000, + reasoning: undefined, + supportsPromptCache: false, temperature: 0, - messages: expect.arrayContaining([ - { role: "system", content: systemPrompt }, - { role: "user", content: "test message" }, - ]), - stream: true, - }), - ) - }) - - test("createMessage with middle-out transform enabled", async () => { - const handler = new OpenRouterHandler({ - ...mockOptions, - openRouterUseMiddleOutTransform: true, + thinking: undefined, + topP: undefined, + }) }) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [ - { - delta: { - content: "test response", - }, - }, - ], - } - }, - } - const mockCreate = jest.fn().mockResolvedValue(mockStream) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate }, - } as any - ;(axios.get as jest.Mock).mockResolvedValue({ data: { data: {} } }) + it("returns default model info when options are not provided", () => { + const handler = new OpenRouterHandler({}) + const result = handler.getModel() - await handler.createMessage("test", []).next() - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - transforms: ["middle-out"], - }), - ) - }) - - test("createMessage with Claude model adds cache control", async () => { - const handler = new OpenRouterHandler({ - ...mockOptions, - openRouterModelId: "anthropic/claude-3.5-sonnet", + expect(result.id).toBe("anthropic/claude-3.7-sonnet") + expect(result.info.supportsPromptCache).toBe(true) }) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - id: "test-id", - choices: [ - { - delta: { - content: "test response", - }, - }, - ], - } - }, - } - const mockCreate = jest.fn().mockResolvedValue(mockStream) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate }, - } as any - ;(axios.get as jest.Mock).mockResolvedValue({ data: { data: {} } }) + it("honors custom maxTokens for thinking models", () => { + const handler = new OpenRouterHandler({ + openRouterApiKey: "test-key", + openRouterModelId: "test-model", + openRouterModelInfo: { + ...mockOpenRouterModelInfo, + maxTokens: 128_000, + thinking: true, + }, + modelMaxTokens: 32_768, + modelMaxThinkingTokens: 16_384, + }) - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "message 1" }, - { role: "assistant", content: "response 1" }, - { role: "user", content: "message 2" }, - ] + const result = handler.getModel() + expect(result.maxTokens).toBe(32_768) + expect(result.thinking).toEqual({ type: "enabled", budget_tokens: 16_384 }) + expect(result.temperature).toBe(1.0) + }) - await handler.createMessage("test system", messages).next() + it("does not honor custom maxTokens for non-thinking models", () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + modelMaxTokens: 32_768, + modelMaxThinkingTokens: 16_384, + }) - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - messages: expect.arrayContaining([ - expect.objectContaining({ - role: "system", - content: expect.arrayContaining([ - expect.objectContaining({ - cache_control: { type: "ephemeral" }, - }), - ]), - }), - ]), - }), - ) - }) - - test("createMessage handles API errors", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockStream = { - async *[Symbol.asyncIterator]() { - yield { - error: { - message: "API Error", - code: 500, - }, - } - }, - } - - const mockCreate = jest.fn().mockResolvedValue(mockStream) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate }, - } as any - - const generator = handler.createMessage("test", []) - await expect(generator.next()).rejects.toThrow("OpenRouter API Error 500: API Error") - }) - - test("completePrompt returns correct response", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockResponse = { choices: [{ message: { content: "test completion" } }] } - - const mockCreate = jest.fn().mockResolvedValue(mockResponse) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate }, - } as any - - const result = await handler.completePrompt("test prompt") - - expect(result).toBe("test completion") - - expect(mockCreate).toHaveBeenCalledWith({ - model: mockOptions.openRouterModelId, - max_tokens: 1000, - thinking: undefined, - temperature: 0, - messages: [{ role: "user", content: "test prompt" }], - stream: false, + const result = handler.getModel() + expect(result.maxTokens).toBe(1000) + expect(result.thinking).toBeUndefined() + expect(result.temperature).toBe(0) }) }) - test("completePrompt handles API errors", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockError = { - error: { - message: "API Error", - code: 500, - }, - } + describe("createMessage", () => { + it("generates correct stream chunks", async () => { + const handler = new OpenRouterHandler(mockOptions) - const mockCreate = jest.fn().mockResolvedValue(mockError) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate }, - } as any + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { content: "test response" } }], + } + yield { + id: "test-id", + choices: [{ delta: {} }], + usage: { prompt_tokens: 10, completion_tokens: 20, cost: 0.001 }, + } + }, + } - await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenRouter API Error 500: API Error") + // Mock OpenAI chat.completions.create + const mockCreate = jest.fn().mockResolvedValue(mockStream) + + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const systemPrompt = "test system prompt" + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user" as const, content: "test message" }] + + const generator = handler.createMessage(systemPrompt, messages) + const chunks = [] + + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Verify stream chunks + expect(chunks).toHaveLength(2) // One text chunk and one usage chunk + expect(chunks[0]).toEqual({ type: "text", text: "test response" }) + expect(chunks[1]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20, totalCost: 0.001 }) + + // Verify OpenAI client was called with correct parameters + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: mockOptions.openRouterModelId, + temperature: 0, + messages: expect.arrayContaining([ + { role: "system", content: systemPrompt }, + { role: "user", content: "test message" }, + ]), + stream: true, + }), + ) + }) + + it("supports the middle-out transform", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterUseMiddleOutTransform: true, + }) + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { content: "test response" } }], + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(mockStream) + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate }, + } as any + ;(axios.get as jest.Mock).mockResolvedValue({ data: { data: {} } }) + + await handler.createMessage("test", []).next() + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ transforms: ["middle-out"] })) + }) + + it("adds cache control for supported models", async () => { + const handler = new OpenRouterHandler({ + ...mockOptions, + openRouterModelInfo: { + ...mockOpenRouterModelInfo, + supportsPromptCache: true, + }, + openRouterModelId: "anthropic/claude-3.5-sonnet", + }) + + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { + id: "test-id", + choices: [{ delta: { content: "test response" } }], + } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(mockStream) + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate }, + } as any + ;(axios.get as jest.Mock).mockResolvedValue({ data: { data: {} } }) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "message 1" }, + { role: "assistant", content: "response 1" }, + { role: "user", content: "message 2" }, + ] + + await handler.createMessage("test system", messages).next() + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.arrayContaining([ + expect.objectContaining({ + role: "system", + content: expect.arrayContaining([ + expect.objectContaining({ cache_control: { type: "ephemeral" } }), + ]), + }), + ]), + }), + ) + }) + + it("handles API errors", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockStream = { + async *[Symbol.asyncIterator]() { + yield { error: { message: "API Error", code: 500 } } + }, + } + + const mockCreate = jest.fn().mockResolvedValue(mockStream) + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const generator = handler.createMessage("test", []) + await expect(generator.next()).rejects.toThrow("OpenRouter API Error 500: API Error") + }) }) - test("completePrompt handles unexpected errors", async () => { - const handler = new OpenRouterHandler(mockOptions) - const mockCreate = jest.fn().mockRejectedValue(new Error("Unexpected error")) - ;(OpenAI as jest.MockedClass).prototype.chat = { - completions: { create: mockCreate }, - } as any + describe("completePrompt", () => { + it("returns correct response", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockResponse = { choices: [{ message: { content: "test completion" } }] } - await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") + const mockCreate = jest.fn().mockResolvedValue(mockResponse) + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate }, + } as any + + const result = await handler.completePrompt("test prompt") + + expect(result).toBe("test completion") + + expect(mockCreate).toHaveBeenCalledWith({ + model: mockOptions.openRouterModelId, + max_tokens: 1000, + thinking: undefined, + temperature: 0, + messages: [{ role: "user", content: "test prompt" }], + stream: false, + }) + }) + + it("handles API errors", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockError = { + error: { + message: "API Error", + code: 500, + }, + } + + const mockCreate = jest.fn().mockResolvedValue(mockError) + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await expect(handler.completePrompt("test prompt")).rejects.toThrow("OpenRouter API Error 500: API Error") + }) + + it("handles unexpected errors", async () => { + const handler = new OpenRouterHandler(mockOptions) + const mockCreate = jest.fn().mockRejectedValue(new Error("Unexpected error")) + ;(OpenAI as jest.MockedClass).prototype.chat = { + completions: { create: mockCreate }, + } as any + + await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error") + }) }) }) diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 665d87542b..ac5d8553e6 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -6,7 +6,7 @@ import OpenAI from "openai" import { ApiHandlerOptions, ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "../../shared/api" import { parseApiPrice } from "../../utils/cost" import { convertToOpenAiMessages } from "../transform/openai-format" -import { ApiStreamChunk, ApiStreamUsageChunk } from "../transform/stream" +import { ApiStreamChunk } from "../transform/stream" import { convertToR1Format } from "../transform/r1-format" import { DEFAULT_HEADERS, DEEP_SEEK_DEFAULT_TEMPERATURE } from "./constants" @@ -28,6 +28,22 @@ type OpenRouterChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { } } +// See `OpenAI.Chat.Completions.ChatCompletionChunk["usage"]` +// `CompletionsAPI.CompletionUsage` +// See also: https://openrouter.ai/docs/use-cases/usage-accounting +interface CompletionUsage { + completion_tokens?: number + completion_tokens_details?: { + reasoning_tokens?: number + } + prompt_tokens?: number + prompt_tokens_details?: { + cached_tokens?: number + } + total_tokens?: number + cost?: number +} + export class OpenRouterHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions private client: OpenAI @@ -46,7 +62,15 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH systemPrompt: string, messages: Anthropic.Messages.MessageParam[], ): AsyncGenerator { - let { id: modelId, maxTokens, thinking, temperature, topP, reasoningEffort } = this.getModel() + let { + id: modelId, + maxTokens, + thinking, + temperature, + supportsPromptCache, + topP, + reasoningEffort, + } = this.getModel() // Convert Anthropic messages to OpenAI format. let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ @@ -59,46 +83,42 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) } - // prompt caching: https://openrouter.ai/docs/prompt-caching - // this is specifically for claude models (some models may 'support prompt caching' automatically without this) - switch (true) { - case modelId.startsWith("anthropic/"): - openAiMessages[0] = { - role: "system", - content: [ - { - type: "text", - text: systemPrompt, - // @ts-ignore-next-line - cache_control: { type: "ephemeral" }, - }, - ], + // Prompt caching: https://openrouter.ai/docs/prompt-caching + // Now with Gemini support: https://openrouter.ai/docs/features/prompt-caching + if (supportsPromptCache) { + openAiMessages[0] = { + role: "system", + content: [ + { + type: "text", + text: systemPrompt, + // @ts-ignore-next-line + cache_control: { type: "ephemeral" }, + }, + ], + } + + // Add cache_control to the last two user messages + // (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message) + const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2) + + lastTwoUserMessages.forEach((msg) => { + if (typeof msg.content === "string") { + msg.content = [{ type: "text", text: msg.content }] } - // Add cache_control to the last two user messages - // (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message) - const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2) + if (Array.isArray(msg.content)) { + // NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end. + let lastTextPart = msg.content.filter((part) => part.type === "text").pop() - lastTwoUserMessages.forEach((msg) => { - if (typeof msg.content === "string") { - msg.content = [{ type: "text", text: msg.content }] + if (!lastTextPart) { + lastTextPart = { type: "text", text: "..." } + msg.content.push(lastTextPart) } - - if (Array.isArray(msg.content)) { - // NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end. - let lastTextPart = msg.content.filter((part) => part.type === "text").pop() - - if (!lastTextPart) { - lastTextPart = { type: "text", text: "..." } - msg.content.push(lastTextPart) - } - // @ts-ignore-next-line - lastTextPart["cache_control"] = { type: "ephemeral" } - } - }) - break - default: - break + // @ts-ignore-next-line + lastTextPart["cache_control"] = { type: "ephemeral" } + } + }) } // https://openrouter.ai/docs/transforms @@ -125,9 +145,9 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const stream = await this.client.chat.completions.create(completionParams) - let lastUsage + let lastUsage: CompletionUsage | undefined = undefined - for await (const chunk of stream as unknown as AsyncIterable) { + for await (const chunk of stream) { // OpenRouter returns an error object instead of the OpenAI SDK throwing an error. if ("error" in chunk) { const error = chunk.error as { message?: string; code?: number } @@ -137,13 +157,13 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const delta = chunk.choices[0]?.delta - if ("reasoning" in delta && delta.reasoning) { - yield { type: "reasoning", text: delta.reasoning } as ApiStreamChunk + if ("reasoning" in delta && delta.reasoning && typeof delta.reasoning === "string") { + yield { type: "reasoning", text: delta.reasoning } } if (delta?.content) { fullResponseText += delta.content - yield { type: "text", text: delta.content } as ApiStreamChunk + yield { type: "text", text: delta.content } } if (chunk.usage) { @@ -152,16 +172,16 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH } if (lastUsage) { - yield this.processUsageMetrics(lastUsage) - } - } - - processUsageMetrics(usage: any): ApiStreamUsageChunk { - return { - type: "usage", - inputTokens: usage?.prompt_tokens || 0, - outputTokens: usage?.completion_tokens || 0, - totalCost: usage?.cost || 0, + yield { + type: "usage", + inputTokens: lastUsage.prompt_tokens || 0, + outputTokens: lastUsage.completion_tokens || 0, + // Waiting on OpenRouter to figure out what this represents in the Gemini case + // and how to best support it. + // cacheReadTokens: lastUsage.prompt_tokens_details?.cached_tokens, + reasoningTokens: lastUsage.completion_tokens_details?.reasoning_tokens, + totalCost: lastUsage.cost || 0, + } } } @@ -171,7 +191,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH let id = modelId ?? openRouterDefaultModelId const info = modelInfo ?? openRouterDefaultModelInfo - + const supportsPromptCache = modelInfo?.supportsPromptCache const isDeepSeekR1 = id.startsWith("deepseek/deepseek-r1") || modelId === "perplexity/sonar-reasoning" const defaultTemperature = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0 const topP = isDeepSeekR1 ? 0.95 : undefined @@ -180,6 +200,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH id, info, ...getModelParams({ options: this.options, model: info, defaultTemperature }), + supportsPromptCache, topP, } } @@ -269,6 +290,11 @@ export async function getOpenRouterModels(options?: ApiHandlerOptions) { modelInfo.cacheReadsPrice = 0.03 modelInfo.maxTokens = 8192 break + case rawModel.id.startsWith("google/gemini-2.5-pro-preview-03-25"): + case rawModel.id.startsWith("google/gemini-2.0-flash-001"): + case rawModel.id.startsWith("google/gemini-flash-1.5"): + modelInfo.supportsPromptCache = true + break default: break } diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index 97751edd90..caa69a09fe 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -1,4 +1,5 @@ export type ApiStream = AsyncGenerator + export type ApiStreamChunk = ApiStreamTextChunk | ApiStreamUsageChunk | ApiStreamReasoningChunk export interface ApiStreamTextChunk { @@ -17,5 +18,6 @@ export interface ApiStreamUsageChunk { outputTokens: number cacheWriteTokens?: number cacheReadTokens?: number - totalCost?: number // openrouter + reasoningTokens?: number + totalCost?: number } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ebcd59e1ee..ec94326b63 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -21,7 +21,7 @@ import { ReasoningBlock } from "./ReasoningBlock" import Thumbnails from "../common/Thumbnails" import McpResourceRow from "../mcp/McpResourceRow" import McpToolRow from "../mcp/McpToolRow" -import { highlightMentions } from "./TaskHeader" +import { Mention } from "./Mention" import { CheckpointSaved } from "./checkpoints/CheckpointSaved" import { FollowUpSuggest } from "./FollowUpSuggest" @@ -867,7 +867,9 @@ export const ChatRowContent = ({ return (
-
{highlightMentions(message.text)}
+
+ +
+ {!!item?.size && item.size > 0 && ( + <> + + {deleteTaskId && ( + !open && setDeleteTaskId(null)} + open + /> + )} + + )} +
+ ) +} diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0dc2aa1a5f..531ec908f0 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -1,23 +1,22 @@ -import React, { memo, useMemo, useRef, useState } from "react" +import { memo, useMemo, useRef, useState } from "react" import { useWindowSize } from "react-use" -import prettyBytes from "pretty-bytes" import { useTranslation } from "react-i18next" - -import { vscode } from "@/utils/vscode" -import { formatLargeNumber } from "@/utils/format" -import { calculateTokenDistribution, getMaxTokensForModel } from "@/utils/model-utils" -import { Button } from "@/components/ui" +import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" +import { CloudUpload, CloudDownload } from "lucide-react" import { ClineMessage } from "@roo/shared/ExtensionMessage" -import { mentionRegexGlobal } from "@roo/shared/context-mentions" -import { HistoryItem } from "@roo/shared/HistoryItem" +import { getMaxTokensForModel } from "@/utils/model-utils" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui" import { useExtensionState } from "@src/context/ExtensionStateContext" + import Thumbnails from "../common/Thumbnails" import { normalizeApiConfiguration } from "../settings/ApiOptions" -import { DeleteTaskDialog } from "../history/DeleteTaskDialog" -import { cn } from "@/lib/utils" -import { VSCodeBadge } from "@vscode/webview-ui-toolkit/react" + +import { TaskActions } from "./TaskActions" +import { ContextWindowProgress } from "./ContextWindowProgress" +import { Mention } from "./Mention" interface TaskHeaderProps { task: ClineMessage @@ -31,7 +30,7 @@ interface TaskHeaderProps { onClose: () => void } -const TaskHeader: React.FC = ({ +const TaskHeader = ({ task, tokensIn, tokensOut, @@ -41,7 +40,7 @@ const TaskHeader: React.FC = ({ totalCost, contextTokens, onClose, -}) => { +}: TaskHeaderProps) => { const { t } = useTranslation() const { apiConfiguration, currentTaskItem } = useExtensionState() const { selectedModelInfo } = useMemo(() => normalizeApiConfiguration(apiConfiguration), [apiConfiguration]) @@ -53,8 +52,6 @@ const TaskHeader: React.FC = ({ const { width: windowWidth } = useWindowSize() - const shouldShowPromptCacheInfo = doesModelSupportPromptCache && apiConfiguration?.apiProvider !== "openrouter" - return (
= ({ {t("chat:task.title")} {!isTaskExpanded && ":"} - {!isTaskExpanded && {highlightMentions(task.text, false)}} + {!isTaskExpanded && ( + + + + )}
{task.images && task.images.length > 0 && } @@ -137,29 +138,37 @@ const TaskHeader: React.FC = ({
{t("chat:task.tokens")} - - - {formatLargeNumber(tokensIn || 0)} - - - - {formatLargeNumber(tokensOut || 0)} - + {typeof tokensIn === "number" && tokensIn > 0 && ( + + + {tokensIn} + + )} + {typeof tokensOut === "number" && tokensOut > 0 && ( + + + {tokensOut} + + )}
{!totalCost && }
- {shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && ( + {doesModelSupportPromptCache && (cacheReads || cacheWrites) && (
{t("chat:task.cache")} - - + - {formatLargeNumber(cacheWrites || 0)} - - - - {formatLargeNumber(cacheReads || 0)} - + {typeof cacheWrites === "number" && cacheWrites > 0 && ( + + + {cacheWrites} + + )} + {typeof cacheReads === "number" && cacheReads > 0 && ( + + + {cacheReads} + + )}
)} @@ -180,154 +189,4 @@ const TaskHeader: React.FC = ({ ) } -export const highlightMentions = (text?: string, withShadow = true) => { - if (!text) return text - const parts = text.split(mentionRegexGlobal) - return parts.map((part, index) => { - if (index % 2 === 0) { - // This is regular text - return part - } else { - // This is a mention - return ( - vscode.postMessage({ type: "openMention", text: part })}> - @{part} - - ) - } - }) -} - -const TaskActions = ({ item }: { item: HistoryItem | undefined }) => { - const [deleteTaskId, setDeleteTaskId] = useState(null) - const { t } = useTranslation() - - return ( -
- - {!!item?.size && item.size > 0 && ( - <> - - {deleteTaskId && ( - !open && setDeleteTaskId(null)} - open - /> - )} - - )} -
- ) -} - -interface ContextWindowProgressProps { - contextWindow: number - contextTokens: number - maxTokens?: number -} - -const ContextWindowProgress = ({ contextWindow, contextTokens, maxTokens }: ContextWindowProgressProps) => { - const { t } = useTranslation() - // Use the shared utility function to calculate all token distribution values - const tokenDistribution = useMemo( - () => calculateTokenDistribution(contextWindow, contextTokens, maxTokens), - [contextWindow, contextTokens, maxTokens], - ) - - // Destructure the values we need - const { currentPercent, reservedPercent, availableSize, reservedForOutput, availablePercent } = tokenDistribution - - // For display purposes - const safeContextWindow = Math.max(0, contextWindow) - const safeContextTokens = Math.max(0, contextTokens) - - return ( - <> -
-
{formatLargeNumber(safeContextTokens)}
-
- {/* Invisible overlay for hover area */} -
- - {/* Main progress bar container */} -
- {/* Current tokens container */} -
- {/* Invisible overlay for current tokens section */} -
- {/* Current tokens used - darkest */} -
-
- - {/* Container for reserved tokens */} -
- {/* Invisible overlay for reserved section */} -
- {/* Reserved for output section - medium gray */} -
-
- - {/* Empty section (if any) */} - {availablePercent > 0 && ( -
- {/* Invisible overlay for available space */} -
-
- )} -
-
-
{formatLargeNumber(safeContextWindow)}
-
- - ) -} - export default memo(TaskHeader)