mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-10 03:28:03 +00:00
* fix: resolve Claude Code token counting inefficiency and enable caching (#5104) - Remove 1.5x fudge factor from Claude Code token counting - Enable prompt caching support for all Claude Code models - Add comprehensive tests for token counting and caching - Update existing tests to reflect accurate token counting This fixes the extreme token inefficiency where simple messages would jump from ~40k to over 60k tokens, causing API hangs when approaching the artificial 120k limit. Claude Code now properly utilizes its full 200k context window with accurate token counting. * fix: address PR review comments - Extract IMAGE_TOKEN_ESTIMATE as a named constant for clarity - Update token counting tests to use exact counts instead of ranges for deterministic testing - Fix test expectations to match actual tokenizer output * Remove token counting changes, keep only cache support - Removed custom countTokens override from claude-code.ts - Deleted claude-code-token-counting.spec.ts test file - Kept cache token collection and reporting functionality - Kept supportsPromptCache: true for all Claude Code models - Kept claude-code-caching.spec.ts tests This focuses the PR on enabling cache support without modifying token counting behavior. * fix: update webview test to expect supportsPromptCache=true for Claude Code models --------- Co-authored-by: Daniel Riccio <ricciodaniel98@gmail.com>
This commit is contained in:
parent
d1ab71c60e
commit
f9f01b012c
4 changed files with 312 additions and 7 deletions
|
|
@ -8,7 +8,7 @@ export const claudeCodeModels = {
|
|||
"claude-sonnet-4-20250514": {
|
||||
...anthropicModels["claude-sonnet-4-20250514"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
|
|
@ -16,7 +16,7 @@ export const claudeCodeModels = {
|
|||
"claude-opus-4-20250514": {
|
||||
...anthropicModels["claude-opus-4-20250514"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
|
|
@ -24,7 +24,7 @@ export const claudeCodeModels = {
|
|||
"claude-3-7-sonnet-20250219": {
|
||||
...anthropicModels["claude-3-7-sonnet-20250219"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
|
|
@ -32,7 +32,7 @@ export const claudeCodeModels = {
|
|||
"claude-3-5-sonnet-20241022": {
|
||||
...anthropicModels["claude-3-5-sonnet-20241022"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
|
|
@ -40,7 +40,7 @@ export const claudeCodeModels = {
|
|||
"claude-3-5-haiku-20241022": {
|
||||
...anthropicModels["claude-3-5-haiku-20241022"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
|
|
|
|||
305
src/api/providers/__tests__/claude-code-caching.spec.ts
Normal file
305
src/api/providers/__tests__/claude-code-caching.spec.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { ClaudeCodeHandler } from "../claude-code"
|
||||
import { runClaudeCode } from "../../../integrations/claude-code/run"
|
||||
import type { ApiHandlerOptions } from "../../../shared/api"
|
||||
import type { ClaudeCodeMessage } from "../../../integrations/claude-code/types"
|
||||
import type { ApiStreamUsageChunk } from "../../transform/stream"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
// Mock the runClaudeCode function
|
||||
vi.mock("../../../integrations/claude-code/run", () => ({
|
||||
runClaudeCode: vi.fn(),
|
||||
}))
|
||||
|
||||
describe("ClaudeCodeHandler - Caching Support", () => {
|
||||
let handler: ClaudeCodeHandler
|
||||
const mockOptions: ApiHandlerOptions = {
|
||||
apiKey: "test-key",
|
||||
apiModelId: "claude-3-5-sonnet-20241022",
|
||||
claudeCodePath: "/test/path",
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
handler = new ClaudeCodeHandler(mockOptions)
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it("should collect cache read tokens from API response", async () => {
|
||||
const mockStream = async function* (): AsyncGenerator<string | ClaudeCodeMessage> {
|
||||
// Initial system message
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "test-session",
|
||||
tools: [],
|
||||
mcp_servers: [],
|
||||
apiKeySource: "user",
|
||||
} as ClaudeCodeMessage
|
||||
|
||||
// Assistant message with cache tokens
|
||||
const message: Anthropic.Messages.Message = {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [{ type: "text", text: "Hello!", citations: [] }],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 80, // 80 tokens read from cache
|
||||
cache_creation_input_tokens: 20, // 20 new tokens cached
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "assistant",
|
||||
message,
|
||||
session_id: "test-session",
|
||||
} as ClaudeCodeMessage
|
||||
|
||||
// Result with cost
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "success",
|
||||
total_cost_usd: 0.001,
|
||||
is_error: false,
|
||||
duration_ms: 1000,
|
||||
duration_api_ms: 900,
|
||||
num_turns: 1,
|
||||
session_id: "test-session",
|
||||
} as ClaudeCodeMessage
|
||||
}
|
||||
|
||||
vi.mocked(runClaudeCode).mockReturnValue(mockStream())
|
||||
|
||||
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Find the usage chunk
|
||||
const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
|
||||
expect(usageChunk).toBeDefined()
|
||||
expect(usageChunk!.inputTokens).toBe(100)
|
||||
expect(usageChunk!.outputTokens).toBe(50)
|
||||
expect(usageChunk!.cacheReadTokens).toBe(80)
|
||||
expect(usageChunk!.cacheWriteTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("should accumulate cache tokens across multiple messages", async () => {
|
||||
const mockStream = async function* (): AsyncGenerator<string | ClaudeCodeMessage> {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "test-session",
|
||||
tools: [],
|
||||
mcp_servers: [],
|
||||
apiKeySource: "user",
|
||||
} as ClaudeCodeMessage
|
||||
|
||||
// First message chunk
|
||||
const message1: Anthropic.Messages.Message = {
|
||||
id: "msg_1",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [{ type: "text", text: "Part 1", citations: [] }],
|
||||
usage: {
|
||||
input_tokens: 50,
|
||||
output_tokens: 25,
|
||||
cache_read_input_tokens: 40,
|
||||
cache_creation_input_tokens: 10,
|
||||
},
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: message1,
|
||||
session_id: "test-session",
|
||||
} as ClaudeCodeMessage
|
||||
|
||||
// Second message chunk
|
||||
const message2: Anthropic.Messages.Message = {
|
||||
id: "msg_2",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [{ type: "text", text: "Part 2", citations: [] }],
|
||||
usage: {
|
||||
input_tokens: 50,
|
||||
output_tokens: 25,
|
||||
cache_read_input_tokens: 30,
|
||||
cache_creation_input_tokens: 20,
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "assistant",
|
||||
message: message2,
|
||||
session_id: "test-session",
|
||||
} as ClaudeCodeMessage
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "success",
|
||||
total_cost_usd: 0.002,
|
||||
is_error: false,
|
||||
duration_ms: 2000,
|
||||
duration_api_ms: 1800,
|
||||
num_turns: 1,
|
||||
session_id: "test-session",
|
||||
} as ClaudeCodeMessage
|
||||
}
|
||||
|
||||
vi.mocked(runClaudeCode).mockReturnValue(mockStream())
|
||||
|
||||
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
|
||||
expect(usageChunk).toBeDefined()
|
||||
expect(usageChunk!.inputTokens).toBe(100) // 50 + 50
|
||||
expect(usageChunk!.outputTokens).toBe(50) // 25 + 25
|
||||
expect(usageChunk!.cacheReadTokens).toBe(70) // 40 + 30
|
||||
expect(usageChunk!.cacheWriteTokens).toBe(30) // 10 + 20
|
||||
})
|
||||
|
||||
it("should handle missing cache token fields gracefully", async () => {
|
||||
const mockStream = async function* (): AsyncGenerator<string | ClaudeCodeMessage> {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "test-session",
|
||||
tools: [],
|
||||
mcp_servers: [],
|
||||
apiKeySource: "user",
|
||||
} as ClaudeCodeMessage
|
||||
|
||||
// Message without cache tokens
|
||||
const message: Anthropic.Messages.Message = {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [{ type: "text", text: "Hello!", citations: [] }],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: null,
|
||||
cache_creation_input_tokens: null,
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "assistant",
|
||||
message,
|
||||
session_id: "test-session",
|
||||
} as ClaudeCodeMessage
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "success",
|
||||
total_cost_usd: 0.001,
|
||||
is_error: false,
|
||||
duration_ms: 1000,
|
||||
duration_api_ms: 900,
|
||||
num_turns: 1,
|
||||
session_id: "test-session",
|
||||
} as ClaudeCodeMessage
|
||||
}
|
||||
|
||||
vi.mocked(runClaudeCode).mockReturnValue(mockStream())
|
||||
|
||||
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
|
||||
expect(usageChunk).toBeDefined()
|
||||
expect(usageChunk!.inputTokens).toBe(100)
|
||||
expect(usageChunk!.outputTokens).toBe(50)
|
||||
expect(usageChunk!.cacheReadTokens).toBe(0)
|
||||
expect(usageChunk!.cacheWriteTokens).toBe(0)
|
||||
})
|
||||
|
||||
it("should report zero cost for subscription usage", async () => {
|
||||
const mockStream = async function* (): AsyncGenerator<string | ClaudeCodeMessage> {
|
||||
// Subscription usage has apiKeySource: "none"
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "init",
|
||||
session_id: "test-session",
|
||||
tools: [],
|
||||
mcp_servers: [],
|
||||
apiKeySource: "none",
|
||||
} as ClaudeCodeMessage
|
||||
|
||||
const message: Anthropic.Messages.Message = {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [{ type: "text", text: "Hello!", citations: [] }],
|
||||
usage: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
cache_read_input_tokens: 80,
|
||||
cache_creation_input_tokens: 20,
|
||||
},
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
}
|
||||
|
||||
yield {
|
||||
type: "assistant",
|
||||
message,
|
||||
session_id: "test-session",
|
||||
} as ClaudeCodeMessage
|
||||
|
||||
yield {
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
result: "success",
|
||||
total_cost_usd: 0.001, // This should be ignored for subscription usage
|
||||
is_error: false,
|
||||
duration_ms: 1000,
|
||||
duration_api_ms: 900,
|
||||
num_turns: 1,
|
||||
session_id: "test-session",
|
||||
} as ClaudeCodeMessage
|
||||
}
|
||||
|
||||
vi.mocked(runClaudeCode).mockReturnValue(mockStream())
|
||||
|
||||
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
|
||||
expect(usageChunk).toBeDefined()
|
||||
expect(usageChunk!.totalCost).toBe(0) // Should be 0 for subscription usage
|
||||
})
|
||||
})
|
||||
|
|
@ -34,7 +34,7 @@ describe("ClaudeCodeHandler", () => {
|
|||
const model = handler.getModel()
|
||||
expect(model.id).toBe("claude-3-5-sonnet-20241022")
|
||||
expect(model.info.supportsImages).toBe(false)
|
||||
expect(model.info.supportsPromptCache).toBe(false)
|
||||
expect(model.info.supportsPromptCache).toBe(true) // Claude Code now supports prompt caching
|
||||
})
|
||||
|
||||
test("should use default model when invalid model provided", () => {
|
||||
|
|
|
|||
|
|
@ -402,7 +402,7 @@ describe("useSelectedModel", () => {
|
|||
expect(result.current.id).toBe("claude-sonnet-4-20250514")
|
||||
expect(result.current.info).toBeDefined()
|
||||
expect(result.current.info?.supportsImages).toBe(false)
|
||||
expect(result.current.info?.supportsPromptCache).toBe(false)
|
||||
expect(result.current.info?.supportsPromptCache).toBe(true) // Claude Code now supports prompt cache
|
||||
// Verify it inherits other properties from anthropic models
|
||||
expect(result.current.info?.maxTokens).toBe(64_000)
|
||||
expect(result.current.info?.contextWindow).toBe(200_000)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue