mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
e53d299acf
commit
a9ca17717c
8 changed files with 545 additions and 505 deletions
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
90
webview-ui/src/components/chat/ContextWindowProgress.tsx
Normal file
90
webview-ui/src/components/chat/ContextWindowProgress.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
33
webview-ui/src/components/chat/Mention.tsx
Normal file
33
webview-ui/src/components/chat/Mention.tsx
Normal 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}</>
|
||||
}
|
||||
54
webview-ui/src/components/chat/TaskActions.tsx
Normal file
54
webview-ui/src/components/chat/TaskActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue