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>
This commit is contained in:
Chris Estreich 2025-04-23 06:45:57 -07:00 committed by GitHub
parent e53d299acf
commit a9ca17717c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 545 additions and 505 deletions

View file

@ -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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).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<typeof OpenAI>).prototype.chat = {
completions: { create: mockCreate },
} as any
await expect(handler.completePrompt("test prompt")).rejects.toThrow("Unexpected error")
})
})
})

View file

@ -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<ApiStreamChunk> {
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<OpenAI.Chat.Completions.ChatCompletionChunk>) {
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
}

View file

@ -1,4 +1,5 @@
export type ApiStream = AsyncGenerator<ApiStreamChunk>
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
}

View file

@ -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 (
<div className="bg-vscode-editor-background border rounded-xs p-1 overflow-hidden whitespace-pre-wrap word-break-break-word overflow-wrap-anywhere">
<div className="flex justify-between gap-2">
<div className="flex-grow px-2 py-1">{highlightMentions(message.text)}</div>
<div className="flex-grow px-2 py-1">
<Mention text={message.text} withShadow />
</div>
<Button
variant="ghost"
size="icon"

View file

@ -0,0 +1,90 @@
import { useMemo } from "react"
import { useTranslation } from "react-i18next"
import { formatLargeNumber } from "@/utils/format"
import { calculateTokenDistribution } from "@/utils/model-utils"
interface ContextWindowProgressProps {
contextWindow: number
contextTokens: number
maxTokens?: number
}
export 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 (
<>
<div className="flex items-center gap-2 flex-1 whitespace-nowrap px-2">
<div data-testid="context-tokens-count">{formatLargeNumber(safeContextTokens)}</div>
<div className="flex-1 relative">
{/* Invisible overlay for hover area */}
<div
className="absolute w-full h-4 -top-[7px] z-5"
title={t("chat:tokenProgress.availableSpace", { amount: formatLargeNumber(availableSize) })}
data-testid="context-available-space"
/>
{/* Main progress bar container */}
<div className="flex items-center h-1 rounded-[2px] overflow-hidden w-full bg-[color-mix(in_srgb,var(--vscode-foreground)_20%,transparent)]">
{/* Current tokens container */}
<div className="relative h-full" style={{ width: `${currentPercent}%` }}>
{/* Invisible overlay for current tokens section */}
<div
className="absolute h-4 -top-[7px] w-full z-6"
title={t("chat:tokenProgress.tokensUsed", {
used: formatLargeNumber(safeContextTokens),
total: formatLargeNumber(safeContextWindow),
})}
data-testid="context-tokens-used"
/>
{/* Current tokens used - darkest */}
<div className="h-full w-full bg-[var(--vscode-foreground)] transition-width duration-300 ease-out" />
</div>
{/* Container for reserved tokens */}
<div className="relative h-full" style={{ width: `${reservedPercent}%` }}>
{/* Invisible overlay for reserved section */}
<div
className="absolute h-4 -top-[7px] w-full z-6"
title={t("chat:tokenProgress.reservedForResponse", {
amount: formatLargeNumber(reservedForOutput),
})}
data-testid="context-reserved-tokens"
/>
{/* Reserved for output section - medium gray */}
<div className="h-full w-full bg-[color-mix(in_srgb,var(--vscode-foreground)_30%,transparent)] transition-width duration-300 ease-out" />
</div>
{/* Empty section (if any) */}
{availablePercent > 0 && (
<div className="relative h-full" style={{ width: `${availablePercent}%` }}>
{/* Invisible overlay for available space */}
<div
className="absolute h-4 -top-[7px] w-full z-6"
title={t("chat:tokenProgress.availableSpace", {
amount: formatLargeNumber(availableSize),
})}
data-testid="context-available-space-section"
/>
</div>
)}
</div>
</div>
<div data-testid="context-window-size">{formatLargeNumber(safeContextWindow)}</div>
</div>
</>
)
}

View file

@ -0,0 +1,33 @@
import { mentionRegexGlobal } from "@roo/shared/context-mentions"
import { vscode } from "../../utils/vscode"
interface MentionProps {
text?: string
withShadow?: boolean
}
export const Mention = ({ text, withShadow = false }: MentionProps) => {
if (!text) {
return <>{text}</>
}
const parts = text.split(mentionRegexGlobal).map((part, index) => {
if (index % 2 === 0) {
// This is regular text.
return part
} else {
// This is a mention.
return (
<span
key={index}
className={`${withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"} cursor-pointer`}
onClick={() => vscode.postMessage({ type: "openMention", text: part })}>
@{part}
</span>
)
}
})
return <>{parts}</>
}

View file

@ -0,0 +1,54 @@
import { useState } from "react"
import prettyBytes from "pretty-bytes"
import { useTranslation } from "react-i18next"
import { vscode } from "@/utils/vscode"
import { Button } from "@/components/ui"
import { HistoryItem } from "@roo/shared/HistoryItem"
import { DeleteTaskDialog } from "../history/DeleteTaskDialog"
export const TaskActions = ({ item }: { item: HistoryItem | undefined }) => {
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
const { t } = useTranslation()
return (
<div className="flex flex-row gap-1">
<Button
variant="ghost"
size="sm"
title={t("chat:task.export")}
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
<span className="codicon codicon-desktop-download" />
</Button>
{!!item?.size && item.size > 0 && (
<>
<Button
variant="ghost"
size="sm"
title={t("chat:task.delete")}
onClick={(e) => {
e.stopPropagation()
if (e.shiftKey) {
vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
} else {
setDeleteTaskId(item.id)
}
}}>
<span className="codicon codicon-trash" />
{prettyBytes(item.size)}
</Button>
{deleteTaskId && (
<DeleteTaskDialog
taskId={deleteTaskId}
onOpenChange={(open) => !open && setDeleteTaskId(null)}
open
/>
)}
</>
)}
</div>
)
}

View file

@ -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<TaskHeaderProps> = ({
const TaskHeader = ({
task,
tokensIn,
tokensOut,
@ -41,7 +40,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
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<TaskHeaderProps> = ({
const { width: windowWidth } = useWindowSize()
const shouldShowPromptCacheInfo = doesModelSupportPromptCache && apiConfiguration?.apiProvider !== "openrouter"
return (
<div className="py-2 px-3">
<div
@ -76,7 +73,11 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{t("chat:task.title")}
{!isTaskExpanded && ":"}
</span>
{!isTaskExpanded && <span className="ml-1">{highlightMentions(task.text, false)}</span>}
{!isTaskExpanded && (
<span className="ml-1">
<Mention text={task.text} />
</span>
)}
</div>
</div>
<Button
@ -113,7 +114,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
WebkitLineClamp: "unset",
WebkitBoxOrient: "vertical",
}}>
{highlightMentions(task.text, false)}
<Mention text={task.text} />
</div>
</div>
{task.images && task.images.length > 0 && <Thumbnails images={task.images} />}
@ -137,29 +138,37 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<div className="flex justify-between items-center h-[20px]">
<div className="flex items-center gap-1 flex-wrap">
<span className="font-bold">{t("chat:task.tokens")}</span>
<span className="flex items-center gap-[3px]">
<i className="codicon codicon-arrow-up text-xs font-bold -mb-0.5" />
{formatLargeNumber(tokensIn || 0)}
</span>
<span className="flex items-center gap-[3px]">
<i className="codicon codicon-arrow-down text-xs font-bold -mb-0.5" />
{formatLargeNumber(tokensOut || 0)}
</span>
{typeof tokensIn === "number" && tokensIn > 0 && (
<span className="flex items-center gap-0.5">
<i className="codicon codicon-arrow-up text-xs font-bold" />
{tokensIn}
</span>
)}
{typeof tokensOut === "number" && tokensOut > 0 && (
<span className="flex items-center gap-0.5">
<i className="codicon codicon-arrow-down text-xs font-bold" />
{tokensOut}
</span>
)}
</div>
{!totalCost && <TaskActions item={currentTaskItem} />}
</div>
{shouldShowPromptCacheInfo && (cacheReads !== undefined || cacheWrites !== undefined) && (
{doesModelSupportPromptCache && (cacheReads || cacheWrites) && (
<div className="flex items-center gap-1 flex-wrap h-[20px]">
<span className="font-bold">{t("chat:task.cache")}</span>
<span className="flex items-center gap-1">
<i className="codicon codicon-database text-xs font-bold" />+
{formatLargeNumber(cacheWrites || 0)}
</span>
<span className="flex items-center gap-1">
<i className="codicon codicon-arrow-right text-xs font-bold" />
{formatLargeNumber(cacheReads || 0)}
</span>
{typeof cacheWrites === "number" && cacheWrites > 0 && (
<span className="flex items-center gap-0.5">
<CloudUpload size={16} />
{cacheWrites}
</span>
)}
{typeof cacheReads === "number" && cacheReads > 0 && (
<span className="flex items-center gap-0.5">
<CloudDownload size={16} />
{cacheReads}
</span>
)}
</div>
)}
@ -180,154 +189,4 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
)
}
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 (
<span
key={index}
className={`${withShadow ? "mention-context-highlight-with-shadow" : "mention-context-highlight"} cursor-pointer`}
onClick={() => vscode.postMessage({ type: "openMention", text: part })}>
@{part}
</span>
)
}
})
}
const TaskActions = ({ item }: { item: HistoryItem | undefined }) => {
const [deleteTaskId, setDeleteTaskId] = useState<string | null>(null)
const { t } = useTranslation()
return (
<div className="flex flex-row gap-1">
<Button
variant="ghost"
size="sm"
title={t("chat:task.export")}
onClick={() => vscode.postMessage({ type: "exportCurrentTask" })}>
<span className="codicon codicon-desktop-download" />
</Button>
{!!item?.size && item.size > 0 && (
<>
<Button
variant="ghost"
size="sm"
title={t("chat:task.delete")}
onClick={(e) => {
e.stopPropagation()
if (e.shiftKey) {
vscode.postMessage({ type: "deleteTaskWithId", text: item.id })
} else {
setDeleteTaskId(item.id)
}
}}>
<span className="codicon codicon-trash" />
{prettyBytes(item.size)}
</Button>
{deleteTaskId && (
<DeleteTaskDialog
taskId={deleteTaskId}
onOpenChange={(open) => !open && setDeleteTaskId(null)}
open
/>
)}
</>
)}
</div>
)
}
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 (
<>
<div className="flex items-center gap-2 flex-1 whitespace-nowrap px-2">
<div data-testid="context-tokens-count">{formatLargeNumber(safeContextTokens)}</div>
<div className="flex-1 relative">
{/* Invisible overlay for hover area */}
<div
className="absolute w-full h-4 -top-[7px] z-5"
title={t("chat:tokenProgress.availableSpace", { amount: formatLargeNumber(availableSize) })}
data-testid="context-available-space"
/>
{/* Main progress bar container */}
<div className="flex items-center h-1 rounded-[2px] overflow-hidden w-full bg-[color-mix(in_srgb,var(--vscode-foreground)_20%,transparent)]">
{/* Current tokens container */}
<div className="relative h-full" style={{ width: `${currentPercent}%` }}>
{/* Invisible overlay for current tokens section */}
<div
className="absolute h-4 -top-[7px] w-full z-6"
title={t("chat:tokenProgress.tokensUsed", {
used: formatLargeNumber(safeContextTokens),
total: formatLargeNumber(safeContextWindow),
})}
data-testid="context-tokens-used"
/>
{/* Current tokens used - darkest */}
<div className="h-full w-full bg-[var(--vscode-foreground)] transition-width duration-300 ease-out" />
</div>
{/* Container for reserved tokens */}
<div className="relative h-full" style={{ width: `${reservedPercent}%` }}>
{/* Invisible overlay for reserved section */}
<div
className="absolute h-4 -top-[7px] w-full z-6"
title={t("chat:tokenProgress.reservedForResponse", {
amount: formatLargeNumber(reservedForOutput),
})}
data-testid="context-reserved-tokens"
/>
{/* Reserved for output section - medium gray */}
<div className="h-full w-full bg-[color-mix(in_srgb,var(--vscode-foreground)_30%,transparent)] transition-width duration-300 ease-out" />
</div>
{/* Empty section (if any) */}
{availablePercent > 0 && (
<div className="relative h-full" style={{ width: `${availablePercent}%` }}>
{/* Invisible overlay for available space */}
<div
className="absolute h-4 -top-[7px] w-full z-6"
title={t("chat:tokenProgress.availableSpace", {
amount: formatLargeNumber(availableSize),
})}
data-testid="context-available-space-section"
/>
</div>
)}
</div>
</div>
<div data-testid="context-window-size">{formatLargeNumber(safeContextWindow)}</div>
</div>
</>
)
}
export default memo(TaskHeader)