fix: Claude Code Opus support

This commit is contained in:
Hannes Rudolph 2025-12-13 21:18:04 -07:00
parent a3b258ad62
commit 537ef5424a
23 changed files with 3286 additions and 945 deletions

View file

@ -23,18 +23,20 @@ describe("convertModelNameForVertex", () => {
describe("getClaudeCodeModelId", () => {
test("should return original model when useVertex is false", () => {
expect(getClaudeCodeModelId("claude-sonnet-4-20250514", false)).toBe("claude-sonnet-4-20250514")
expect(getClaudeCodeModelId("claude-opus-4-20250514", false)).toBe("claude-opus-4-20250514")
expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", false)).toBe("claude-3-7-sonnet-20250219")
// Use valid ClaudeCodeModelId values - they don't have date suffixes
expect(getClaudeCodeModelId("claude-sonnet-4-5", false)).toBe("claude-sonnet-4-5")
expect(getClaudeCodeModelId("claude-opus-4-5", false)).toBe("claude-opus-4-5")
expect(getClaudeCodeModelId("claude-haiku-4-5", false)).toBe("claude-haiku-4-5")
})
test("should return converted model when useVertex is true", () => {
expect(getClaudeCodeModelId("claude-sonnet-4-20250514", true)).toBe("claude-sonnet-4@20250514")
expect(getClaudeCodeModelId("claude-opus-4-20250514", true)).toBe("claude-opus-4@20250514")
expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", true)).toBe("claude-3-7-sonnet@20250219")
test("should return same model when useVertex is true (no date suffix to convert)", () => {
// Valid ClaudeCodeModelIds don't have 8-digit date suffixes, so no conversion happens
expect(getClaudeCodeModelId("claude-sonnet-4-5", true)).toBe("claude-sonnet-4-5")
expect(getClaudeCodeModelId("claude-opus-4-5", true)).toBe("claude-opus-4-5")
expect(getClaudeCodeModelId("claude-haiku-4-5", true)).toBe("claude-haiku-4-5")
})
test("should default to useVertex false when parameter not provided", () => {
expect(getClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-20250514")
expect(getClaudeCodeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5")
})
})

View file

@ -1,5 +1,41 @@
import type { ModelInfo } from "../model.js"
import { anthropicModels } from "./anthropic.js"
/**
* Rate limit information from Claude Code API
*/
export interface ClaudeCodeRateLimitInfo {
// 5-hour limit info
fiveHour: {
status: string
utilization: number
resetTime: number // Unix timestamp
}
// 7-day (weekly) limit info (Sonnet-specific)
weekly?: {
status: string
utilization: number
resetTime: number // Unix timestamp
}
// 7-day unified limit info
weeklyUnified?: {
status: string
utilization: number
resetTime: number // Unix timestamp
}
// Representative claim type
representativeClaim?: string
// Overage status
overage?: {
status: string
disabledReason?: string
}
// Fallback percentage
fallbackPercentage?: number
// Organization ID
organizationId?: string
// Timestamp when this was fetched
fetchedAt: number
}
// Regex pattern to match 8-digit date at the end of model names
const VERTEX_DATE_PATTERN = /-(\d{8})$/
@ -19,11 +55,29 @@ export function convertModelNameForVertex(modelName: string): string {
return modelName.replace(VERTEX_DATE_PATTERN, "@$1")
}
// Claude Code
// Claude Code - Only models that work with Claude Code OAuth tokens
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5"
export const CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS = 16000
/**
* Reasoning effort configuration for Claude Code thinking mode.
* Maps reasoning effort level to budget_tokens for the thinking process.
*
* Note: With interleaved thinking (enabled via beta header), budget_tokens
* can exceed max_tokens as the token limit becomes the entire context window.
* The max_tokens is drawn from the model's maxTokens definition.
*
* @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking
*/
export const claudeCodeReasoningConfig = {
low: { budgetTokens: 16_000 },
medium: { budgetTokens: 32_000 },
high: { budgetTokens: 64_000 },
} as const
export type ClaudeCodeReasoningLevel = keyof typeof claudeCodeReasoningConfig
/**
* Gets the appropriate model ID based on whether Vertex AI is being used.
*
@ -39,116 +93,41 @@ export function getClaudeCodeModelId(baseModelId: ClaudeCodeModelId, useVertex =
return useVertex ? convertModelNameForVertex(baseModelId) : baseModelId
}
// Models that work with Claude Code OAuth tokens
// See: https://docs.anthropic.com/en/docs/claude-code
// NOTE: Claude Code is subscription-based with no per-token cost - pricing fields are 0
export const claudeCodeModels = {
"claude-haiku-4-5": {
maxTokens: 32768,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsNativeTools: true,
defaultToolProtocol: "native",
supportsReasoningEffort: ["disable", "low", "medium", "high"],
reasoningEffort: "medium",
description: "Claude Haiku 4.5 - Fast and efficient with thinking",
},
"claude-sonnet-4-5": {
...anthropicModels["claude-sonnet-4-5"],
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
maxTokens: 32768,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsNativeTools: true,
defaultToolProtocol: "native",
supportsReasoningEffort: ["disable", "low", "medium", "high"],
reasoningEffort: "medium",
description: "Claude Sonnet 4.5 - Balanced performance with thinking",
},
"claude-sonnet-4-5-20250929[1m]": {
...anthropicModels["claude-sonnet-4-5"],
contextWindow: 1_000_000, // 1M token context window (requires [1m] suffix)
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
},
"claude-sonnet-4-20250514": {
...anthropicModels["claude-sonnet-4-20250514"],
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
},
"claude-opus-4-5-20251101": {
...anthropicModels["claude-opus-4-5-20251101"],
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
},
"claude-opus-4-1-20250805": {
...anthropicModels["claude-opus-4-1-20250805"],
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
},
"claude-opus-4-20250514": {
...anthropicModels["claude-opus-4-20250514"],
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
},
"claude-3-7-sonnet-20250219": {
...anthropicModels["claude-3-7-sonnet-20250219"],
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
},
"claude-3-5-sonnet-20241022": {
...anthropicModels["claude-3-5-sonnet-20241022"],
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
},
"claude-3-5-haiku-20241022": {
...anthropicModels["claude-3-5-haiku-20241022"],
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
},
"claude-haiku-4-5-20251001": {
...anthropicModels["claude-haiku-4-5-20251001"],
supportsImages: false,
supportsPromptCache: true, // Claude Code does report cache tokens
supportsReasoningEffort: false,
supportsReasoningBudget: false,
requiredReasoningBudget: false,
// Claude Code manages its own tools and temperature via the CLI
supportsNativeTools: false,
supportsTemperature: false,
"claude-opus-4-5": {
maxTokens: 32768,
contextWindow: 200_000,
supportsImages: true,
supportsPromptCache: true,
supportsNativeTools: true,
defaultToolProtocol: "native",
supportsReasoningEffort: ["disable", "low", "medium", "high"],
reasoningEffort: "medium",
description: "Claude Opus 4.5 - Most capable with thinking",
},
} as const satisfies Record<string, ModelInfo>

View file

@ -1,21 +1,30 @@
import { ClaudeCodeHandler } from "../claude-code"
import { ApiHandlerOptions } from "../../../shared/api"
import { ClaudeCodeMessage } from "../../../integrations/claude-code/types"
import type { StreamChunk } from "../../../integrations/claude-code/streaming-client"
// Mock the runClaudeCode function
vi.mock("../../../integrations/claude-code/run", () => ({
runClaudeCode: vi.fn(),
// Mock the OAuth manager
vi.mock("../../../integrations/claude-code/oauth", () => ({
claudeCodeOAuthManager: {
getAccessToken: vi.fn(),
getEmail: vi.fn(),
loadCredentials: vi.fn(),
saveCredentials: vi.fn(),
clearCredentials: vi.fn(),
isAuthenticated: vi.fn(),
},
generateUserId: vi.fn(() => "user_abc123_account_def456_session_ghi789"),
}))
// Mock the message filter
vi.mock("../../../integrations/claude-code/message-filter", () => ({
filterMessagesForClaudeCode: vi.fn((messages) => messages),
// Mock the streaming client
vi.mock("../../../integrations/claude-code/streaming-client", () => ({
createStreamingMessage: vi.fn(),
}))
const { runClaudeCode } = await import("../../../integrations/claude-code/run")
const { filterMessagesForClaudeCode } = await import("../../../integrations/claude-code/message-filter")
const mockRunClaudeCode = vi.mocked(runClaudeCode)
const mockFilterMessages = vi.mocked(filterMessagesForClaudeCode)
const { claudeCodeOAuthManager } = await import("../../../integrations/claude-code/oauth")
const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client")
const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken)
const mockCreateStreamingMessage = vi.mocked(createStreamingMessage)
describe("ClaudeCodeHandler", () => {
let handler: ClaudeCodeHandler
@ -23,22 +32,20 @@ describe("ClaudeCodeHandler", () => {
beforeEach(() => {
vi.clearAllMocks()
const options: ApiHandlerOptions = {
claudeCodePath: "claude",
apiModelId: "claude-3-5-sonnet-20241022",
apiModelId: "claude-sonnet-4-5",
}
handler = new ClaudeCodeHandler(options)
})
test("should create handler with correct model configuration", () => {
const model = handler.getModel()
expect(model.id).toBe("claude-3-5-sonnet-20241022")
expect(model.info.supportsImages).toBe(false)
expect(model.info.supportsPromptCache).toBe(true) // Claude Code now supports prompt caching
expect(model.id).toBe("claude-sonnet-4-5")
expect(model.info.supportsImages).toBe(true)
expect(model.info.supportsPromptCache).toBe(true)
})
test("should use default model when invalid model provided", () => {
const options: ApiHandlerOptions = {
claudeCodePath: "claude",
apiModelId: "invalid-model",
}
const handlerWithInvalidModel = new ClaudeCodeHandler(options)
@ -47,44 +54,53 @@ describe("ClaudeCodeHandler", () => {
expect(model.id).toBe("claude-sonnet-4-5") // default model
})
test("should override maxTokens when claudeCodeMaxOutputTokens is provided", () => {
test("should return model maxTokens from model definition", () => {
const options: ApiHandlerOptions = {
claudeCodePath: "claude",
apiModelId: "claude-sonnet-4-20250514",
claudeCodeMaxOutputTokens: 8000,
apiModelId: "claude-opus-4-5",
}
const handlerWithMaxTokens = new ClaudeCodeHandler(options)
const model = handlerWithMaxTokens.getModel()
const handlerWithModel = new ClaudeCodeHandler(options)
const model = handlerWithModel.getModel()
expect(model.id).toBe("claude-sonnet-4-20250514")
expect(model.info.maxTokens).toBe(8000) // Should use the configured value, not the default 64000
expect(model.id).toBe("claude-opus-4-5")
// Model maxTokens is 32768 as defined in claudeCodeModels for opus
expect(model.info.maxTokens).toBe(32768)
})
test("should override maxTokens for default model when claudeCodeMaxOutputTokens is provided", () => {
test("should support reasoning effort configuration", () => {
const options: ApiHandlerOptions = {
claudeCodePath: "claude",
apiModelId: "invalid-model", // Will fall back to default
claudeCodeMaxOutputTokens: 16384,
apiModelId: "claude-sonnet-4-5",
}
const handlerWithMaxTokens = new ClaudeCodeHandler(options)
const model = handlerWithMaxTokens.getModel()
const handler = new ClaudeCodeHandler(options)
const model = handler.getModel()
expect(model.id).toBe("claude-sonnet-4-5") // default model
expect(model.info.maxTokens).toBe(16384) // Should use the configured value
// Default model has supportsReasoningEffort
expect(model.info.supportsReasoningEffort).toEqual(["disable", "low", "medium", "high"])
expect(model.info.reasoningEffort).toBe("medium")
})
test("should filter messages and call runClaudeCode", async () => {
test("should throw error when not authenticated", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
const filteredMessages = [{ role: "user" as const, content: "Hello (filtered)" }]
mockFilterMessages.mockReturnValue(filteredMessages)
mockGetAccessToken.mockResolvedValue(null)
const stream = handler.createMessage(systemPrompt, messages)
const iterator = stream[Symbol.asyncIterator]()
await expect(iterator.next()).rejects.toThrow(/not authenticated/i)
})
test("should call createStreamingMessage with thinking enabled by default", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
mockGetAccessToken.mockResolvedValue("test-access-token")
// Mock empty async generator
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
// Empty generator for basic test
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
@ -92,86 +108,155 @@ describe("ClaudeCodeHandler", () => {
const iterator = stream[Symbol.asyncIterator]()
await iterator.next()
// Verify message filtering was called
expect(mockFilterMessages).toHaveBeenCalledWith(messages)
// Verify runClaudeCode was called with filtered messages
expect(mockRunClaudeCode).toHaveBeenCalledWith({
// Verify createStreamingMessage was called with correct parameters
// Default model has reasoning effort of "medium" so thinking should be enabled
// With interleaved thinking, maxTokens comes from model definition, not reasoning config
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
accessToken: "test-access-token",
model: "claude-sonnet-4-5",
systemPrompt,
messages: filteredMessages,
path: "claude",
modelId: "claude-3-5-sonnet-20241022",
maxOutputTokens: undefined, // No maxOutputTokens configured in this test
messages,
maxTokens: 16384, // model's maxTokens (interleaved thinking uses context window)
thinking: {
type: "enabled",
budget_tokens: 32000, // medium reasoning budget_tokens
},
tools: undefined,
toolChoice: undefined,
metadata: {
user_id: "user_abc123_account_def456_session_ghi789",
},
})
})
test("should pass maxOutputTokens to runClaudeCode when configured", async () => {
test("should disable thinking when reasoningEffort is set to disable", async () => {
const options: ApiHandlerOptions = {
claudeCodePath: "claude",
apiModelId: "claude-3-5-sonnet-20241022",
claudeCodeMaxOutputTokens: 16384,
apiModelId: "claude-sonnet-4-5",
reasoningEffort: "disable",
}
const handlerWithMaxTokens = new ClaudeCodeHandler(options)
const handlerNoThinking = new ClaudeCodeHandler(options)
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
const filteredMessages = [{ role: "user" as const, content: "Hello (filtered)" }]
mockFilterMessages.mockReturnValue(filteredMessages)
mockGetAccessToken.mockResolvedValue("test-access-token")
// Mock empty async generator
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
// Empty generator for basic test
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handlerWithMaxTokens.createMessage(systemPrompt, messages)
const stream = handlerNoThinking.createMessage(systemPrompt, messages)
// Need to start iterating to trigger the call
const iterator = stream[Symbol.asyncIterator]()
await iterator.next()
// Verify runClaudeCode was called with maxOutputTokens
expect(mockRunClaudeCode).toHaveBeenCalledWith({
// Verify createStreamingMessage was called with thinking disabled
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
accessToken: "test-access-token",
model: "claude-sonnet-4-5",
systemPrompt,
messages: filteredMessages,
path: "claude",
modelId: "claude-3-5-sonnet-20241022",
maxOutputTokens: 16384,
messages,
maxTokens: 16384, // model default maxTokens
thinking: { type: "disabled" },
tools: undefined,
toolChoice: undefined,
metadata: {
user_id: "user_abc123_account_def456_session_ghi789",
},
})
})
test("should handle thinking content properly", async () => {
test("should use high reasoning config when reasoningEffort is high", async () => {
const options: ApiHandlerOptions = {
apiModelId: "claude-sonnet-4-5",
reasoningEffort: "high",
}
const handlerHighThinking = new ClaudeCodeHandler(options)
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator that yields thinking content
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "thinking",
thinking: "I need to think about this carefully...",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
mockGetAccessToken.mockResolvedValue("test-access-token")
// Mock empty async generator
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
// Empty generator for basic test
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handlerHighThinking.createMessage(systemPrompt, messages)
// Need to start iterating to trigger the call
const iterator = stream[Symbol.asyncIterator]()
await iterator.next()
// Verify createStreamingMessage was called with high thinking config
// With interleaved thinking, maxTokens comes from model definition, not reasoning config
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
accessToken: "test-access-token",
model: "claude-sonnet-4-5",
systemPrompt,
messages,
maxTokens: 16384, // model's maxTokens (interleaved thinking uses context window)
thinking: {
type: "enabled",
budget_tokens: 64000, // high reasoning budget_tokens
},
tools: undefined,
toolChoice: undefined,
metadata: {
user_id: "user_abc123_account_def456_session_ghi789",
},
})
})
test("should handle text content from streaming", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
mockGetAccessToken.mockResolvedValue("test-access-token")
// Mock async generator that yields text chunks
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Hello " }
yield { type: "text", text: "there!" }
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
type: "text",
text: "Hello ",
})
expect(results[1]).toEqual({
type: "text",
text: "there!",
})
})
test("should handle reasoning content from streaming", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
mockGetAccessToken.mockResolvedValue("test-access-token")
// Mock async generator that yields reasoning chunks
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "reasoning", text: "I need to think about this carefully..." }
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
@ -187,86 +272,19 @@ describe("ClaudeCodeHandler", () => {
})
})
test("should handle redacted thinking content", async () => {
test("should handle mixed content types from streaming", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator that yields redacted thinking content
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "redacted_thinking",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: "reasoning",
text: "[Redacted thinking block]",
})
})
test("should handle mixed content types", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
mockGetAccessToken.mockResolvedValue("test-access-token")
// Mock async generator that yields mixed content
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "thinking",
thinking: "Let me think about this...",
},
{
type: "text",
text: "Here's my response!",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "reasoning", text: "Let me think about this..." }
yield { type: "text", text: "Here's my response!" }
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
@ -286,17 +304,20 @@ describe("ClaudeCodeHandler", () => {
})
})
test("should handle string chunks from generator", async () => {
test("should handle tool call partial chunks from streaming", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator that yields string chunks
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield "This is a string chunk"
yield "Another string chunk"
mockGetAccessToken.mockResolvedValue("test-access-token")
// Mock async generator that yields tool call partial chunks
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "tool_call_partial", index: 0, id: "tool_123", name: "read_file", arguments: undefined }
yield { type: "tool_call_partial", index: 0, id: undefined, name: undefined, arguments: '{"path":' }
yield { type: "tool_call_partial", index: 0, id: undefined, name: undefined, arguments: '"test.txt"}' }
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
@ -305,74 +326,49 @@ describe("ClaudeCodeHandler", () => {
results.push(chunk)
}
expect(results).toHaveLength(2)
expect(results).toHaveLength(3)
expect(results[0]).toEqual({
type: "text",
text: "This is a string chunk",
type: "tool_call_partial",
index: 0,
id: "tool_123",
name: "read_file",
arguments: undefined,
})
expect(results[1]).toEqual({
type: "text",
text: "Another string chunk",
type: "tool_call_partial",
index: 0,
id: undefined,
name: undefined,
arguments: '{"path":',
})
expect(results[2]).toEqual({
type: "tool_call_partial",
index: 0,
id: undefined,
name: undefined,
arguments: '"test.txt"}',
})
})
test("should handle usage and cost tracking with paid usage", async () => {
test("should handle usage and cost tracking from streaming", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator with init, assistant, and result messages
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
// Init message indicating paid usage
yield {
type: "system" as const,
subtype: "init" as const,
session_id: "session_123",
tools: [],
mcp_servers: [],
apiKeySource: "/login managed key",
}
mockGetAccessToken.mockResolvedValue("test-access-token")
// Assistant message
// Mock async generator with text and usage
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Hello there!" }
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: "Hello there!",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
cache_read_input_tokens: 5,
cache_creation_input_tokens: 3,
},
} as any,
session_id: "session_123",
}
// Result message
yield {
type: "result" as const,
subtype: "success" as const,
total_cost_usd: 0.05,
is_error: false,
duration_ms: 1000,
duration_api_ms: 800,
num_turns: 1,
result: "success",
session_id: "session_123",
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: 5,
cacheWriteTokens: 3,
}
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
@ -387,71 +383,34 @@ describe("ClaudeCodeHandler", () => {
type: "text",
text: "Hello there!",
})
// Claude Code is subscription-based, no per-token cost
expect(results[1]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: 5,
cacheWriteTokens: 3,
totalCost: 0.05, // Paid usage, so cost is included
totalCost: 0,
})
})
test("should handle usage tracking with subscription (free) usage", async () => {
test("should handle usage without cache tokens", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator with subscription usage
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
// Init message indicating subscription usage
yield {
type: "system" as const,
subtype: "init" as const,
session_id: "session_123",
tools: [],
mcp_servers: [],
apiKeySource: "none", // Subscription usage
}
mockGetAccessToken.mockResolvedValue("test-access-token")
// Assistant message
// Mock async generator with usage without cache tokens
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Hello there!" }
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: "Hello there!",
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
// Result message
yield {
type: "result" as const,
subtype: "success" as const,
total_cost_usd: 0.05,
is_error: false,
duration_ms: 1000,
duration_api_ms: 800,
num_turns: 1,
result: "success",
session_id: "session_123",
type: "usage",
inputTokens: 10,
outputTokens: 20,
}
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
@ -460,95 +419,50 @@ describe("ClaudeCodeHandler", () => {
results.push(chunk)
}
// Should have text chunk and usage chunk
// Claude Code is subscription-based, no per-token cost
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
type: "text",
text: "Hello there!",
})
expect(results[1]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0, // Subscription usage, so cost is 0
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
totalCost: 0,
})
})
test("should handle API errors properly", async () => {
test("should handle API errors from streaming", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// Mock async generator that yields an API error
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "text",
text: 'API Error: 400 {"error":{"message":"Invalid model name"}}',
},
],
stop_reason: "stop_sequence",
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
mockGetAccessToken.mockResolvedValue("test-access-token")
// Mock async generator that yields an error
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "error", error: "Invalid model name" }
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const iterator = stream[Symbol.asyncIterator]()
// Should throw an error
await expect(iterator.next()).rejects.toThrow()
await expect(iterator.next()).rejects.toThrow("Invalid model name")
})
test("should log warning for unsupported tool_use content", async () => {
test("should handle authentication refresh and continue streaming", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
// Mock async generator that yields tool_use content
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
yield {
type: "assistant" as const,
message: {
id: "msg_123",
type: "message",
role: "assistant",
model: "claude-3-5-sonnet-20241022",
content: [
{
type: "tool_use",
id: "tool_123",
name: "test_tool",
input: { test: "data" },
},
],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: 10,
output_tokens: 20,
},
} as any,
session_id: "session_123",
}
// First call returns a valid token
mockGetAccessToken.mockResolvedValue("refreshed-token")
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Response after refresh" }
}
mockRunClaudeCode.mockReturnValue(mockGenerator())
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
@ -557,9 +471,16 @@ describe("ClaudeCodeHandler", () => {
results.push(chunk)
}
// Should log error for unsupported tool_use
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("tool_use is not supported yet"))
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: "text",
text: "Response after refresh",
})
consoleSpy.mockRestore()
expect(mockCreateStreamingMessage).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: "refreshed-token",
}),
)
})
})

View file

@ -1,143 +1,270 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import {
claudeCodeDefaultModelId,
type ClaudeCodeModelId,
claudeCodeModels,
claudeCodeReasoningConfig,
type ClaudeCodeReasoningLevel,
type ModelInfo,
getClaudeCodeModelId,
} from "@roo-code/types"
import { type ApiHandler, ApiHandlerCreateMessageMetadata } from ".."
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
import { runClaudeCode } from "../../integrations/claude-code/run"
import { filterMessagesForClaudeCode } from "../../integrations/claude-code/message-filter"
import { claudeCodeOAuthManager, generateUserId } from "../../integrations/claude-code/oauth"
import {
createStreamingMessage,
type StreamChunk,
type ThinkingConfig,
} from "../../integrations/claude-code/streaming-client"
import { t } from "../../i18n"
import { ApiHandlerOptions } from "../../shared/api"
import { countTokens } from "../../utils/countTokens"
import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters"
/**
* Converts OpenAI tool_choice to Anthropic ToolChoice format
* @param toolChoice - OpenAI tool_choice parameter
* @param parallelToolCalls - When true, allows parallel tool calls. When false (default), disables parallel tool calls.
*/
function convertOpenAIToolChoice(
toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"],
parallelToolCalls?: boolean,
): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined {
// Anthropic allows parallel tool calls by default. When parallelToolCalls is false or undefined,
// we disable parallel tool use to ensure one tool call at a time.
const disableParallelToolUse = !parallelToolCalls
if (!toolChoice) {
// Default to auto with parallel tool use control
return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
}
if (typeof toolChoice === "string") {
switch (toolChoice) {
case "none":
return undefined // Anthropic doesn't have "none", just omit tools
case "auto":
return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
case "required":
return { type: "any", disable_parallel_tool_use: disableParallelToolUse }
default:
return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
}
}
// Handle object form { type: "function", function: { name: string } }
if (typeof toolChoice === "object" && "function" in toolChoice) {
return {
type: "tool",
name: toolChoice.function.name,
disable_parallel_tool_use: disableParallelToolUse,
}
}
return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
}
export class ClaudeCodeHandler implements ApiHandler {
private options: ApiHandlerOptions
/**
* Store the last thinking block signature for interleaved thinking with tool use.
* This is captured from thinking_complete events during streaming and
* must be passed back to the API when providing tool results.
* Similar to Gemini's thoughtSignature pattern.
*/
private lastThinkingSignature?: string
constructor(options: ApiHandlerOptions) {
this.options = options
}
/**
* Get the thinking signature from the last response.
* Used by Task.addToApiConversationHistory to persist the signature
* so it can be passed back to the API for tool use continuations.
* This follows the same pattern as Gemini's getThoughtSignature().
*/
public getThoughtSignature(): string | undefined {
return this.lastThinkingSignature
}
/**
* Gets the reasoning effort level for the current request.
* Returns the effective reasoning level (low/medium/high) or null if disabled.
*/
private getReasoningEffort(modelInfo: ModelInfo): ClaudeCodeReasoningLevel | null {
// Check if reasoning is explicitly disabled
if (this.options.enableReasoningEffort === false) {
return null
}
// Get the selected effort from settings or model default
const selectedEffort = this.options.reasoningEffort ?? modelInfo.reasoningEffort
// "disable" or no selection means no reasoning
if (!selectedEffort || selectedEffort === "disable") {
return null
}
// Only allow valid levels for Claude Code
if (selectedEffort === "low" || selectedEffort === "medium" || selectedEffort === "high") {
return selectedEffort
}
return null
}
async *createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
_metadata?: ApiHandlerCreateMessageMetadata,
metadata?: ApiHandlerCreateMessageMetadata,
): ApiStream {
// Filter out image blocks since Claude Code doesn't support them
const filteredMessages = filterMessagesForClaudeCode(messages)
// Reset per-request state that we persist into apiConversationHistory
this.lastThinkingSignature = undefined
// Get access token from OAuth manager
const accessToken = await claudeCodeOAuthManager.getAccessToken()
if (!accessToken) {
throw new Error(
t("common:errors.claudeCode.notAuthenticated", {
defaultValue:
"Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.",
}),
)
}
// Get user email for generating user_id metadata
const email = await claudeCodeOAuthManager.getEmail()
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1"
const model = this.getModel()
// Validate that the model ID is a valid ClaudeCodeModelId
const modelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId
const claudeProcess = runClaudeCode({
systemPrompt,
messages: filteredMessages,
path: this.options.claudeCodePath,
modelId: getClaudeCodeModelId(modelId, useVertex),
maxOutputTokens: this.options.claudeCodeMaxOutputTokens,
})
// Generate user_id metadata in the format required by Claude Code API
const userId = generateUserId(email || undefined)
// Usage is included with assistant messages,
// but cost is included in the result chunk
const usage: ApiStreamUsageChunk = {
type: "usage",
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
// Convert OpenAI tools to Anthropic format if provided and protocol is native
// Exclude tools when tool_choice is "none" since that means "don't use tools"
const shouldIncludeNativeTools =
metadata?.tools &&
metadata.tools.length > 0 &&
metadata?.toolProtocol !== "xml" &&
metadata?.tool_choice !== "none"
const anthropicTools = shouldIncludeNativeTools ? convertOpenAIToolsToAnthropic(metadata.tools!) : undefined
const anthropicToolChoice = shouldIncludeNativeTools
? convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls)
: undefined
// Determine reasoning effort and thinking configuration
const reasoningLevel = this.getReasoningEffort(model.info)
let thinking: ThinkingConfig
// With interleaved thinking (enabled via beta header), budget_tokens can exceed max_tokens
// as the token limit becomes the entire context window. We use the model's maxTokens.
// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking
const maxTokens = model.info.maxTokens ?? 16384
if (reasoningLevel) {
// Use thinking mode with budget_tokens from config
const config = claudeCodeReasoningConfig[reasoningLevel]
thinking = {
type: "enabled",
budget_tokens: config.budgetTokens,
}
} else {
// Explicitly disable thinking
thinking = { type: "disabled" }
}
let isPaidUsage = true
// Create streaming request using OAuth
const stream = createStreamingMessage({
accessToken,
model: modelId,
systemPrompt,
messages,
maxTokens,
thinking,
tools: anthropicTools,
toolChoice: anthropicToolChoice,
metadata: {
user_id: userId,
},
})
for await (const chunk of claudeProcess) {
if (typeof chunk === "string") {
yield {
type: "text",
text: chunk,
}
// Track usage for cost calculation
let inputTokens = 0
let outputTokens = 0
let cacheReadTokens = 0
let cacheWriteTokens = 0
continue
}
if (chunk.type === "system" && chunk.subtype === "init") {
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
isPaidUsage = chunk.apiKeySource !== "none"
continue
}
if (chunk.type === "assistant" && "message" in chunk) {
const message = chunk.message
if (message.stop_reason !== null) {
const content = "text" in message.content[0] ? message.content[0] : undefined
const isError = content && content.text.startsWith(`API Error`)
if (isError) {
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
const errorMessageStart = content.text.indexOf("{")
const errorMessage = content.text.slice(errorMessageStart)
const error = this.attemptParse(errorMessage)
if (!error) {
throw new Error(content.text)
}
if (error.error.message.includes("Invalid model name")) {
throw new Error(
content.text + `\n\n${t("common:errors.claudeCode.apiKeyModelPlanMismatch")}`,
)
}
throw new Error(errorMessage)
for await (const chunk of stream) {
switch (chunk.type) {
case "text":
yield {
type: "text",
text: chunk.text,
}
}
break
for (const content of message.content) {
switch (content.type) {
case "text":
yield {
type: "text",
text: content.text,
}
break
case "thinking":
yield {
type: "reasoning",
text: content.thinking || "",
}
break
case "redacted_thinking":
yield {
type: "reasoning",
text: "[Redacted thinking block]",
}
break
case "tool_use":
console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`)
break
case "reasoning":
yield {
type: "reasoning",
text: chunk.text,
}
break
case "thinking_complete":
// Capture the signature for persistence in api_conversation_history
// This enables tool use continuations where thinking blocks must be passed back
if (chunk.signature) {
this.lastThinkingSignature = chunk.signature
}
// Emit a complete thinking block with signature
// This is critical for interleaved thinking with tool use
// The signature must be included when passing thinking blocks back to the API
yield {
type: "reasoning",
text: chunk.thinking,
signature: chunk.signature,
}
break
case "tool_call_partial":
yield {
type: "tool_call_partial",
index: chunk.index,
id: chunk.id,
name: chunk.name,
arguments: chunk.arguments,
}
break
case "usage": {
inputTokens = chunk.inputTokens
outputTokens = chunk.outputTokens
cacheReadTokens = chunk.cacheReadTokens || 0
cacheWriteTokens = chunk.cacheWriteTokens || 0
// Claude Code is subscription-based, no per-token cost
const usageChunk: ApiStreamUsageChunk = {
type: "usage",
inputTokens,
outputTokens,
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
totalCost: 0,
}
yield usageChunk
break
}
// Accumulate usage across streaming chunks
usage.inputTokens += message.usage.input_tokens
usage.outputTokens += message.usage.output_tokens
usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (message.usage.cache_read_input_tokens || 0)
usage.cacheWriteTokens =
(usage.cacheWriteTokens || 0) + (message.usage.cache_creation_input_tokens || 0)
continue
}
if (chunk.type === "result" && "result" in chunk) {
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
yield usage
case "error":
throw new Error(chunk.error)
}
}
}
@ -146,26 +273,12 @@ export class ClaudeCodeHandler implements ApiHandler {
const modelId = this.options.apiModelId
if (modelId && modelId in claudeCodeModels) {
const id = modelId as ClaudeCodeModelId
const modelInfo: ModelInfo = { ...claudeCodeModels[id] }
// Override maxTokens with the configured value if provided
if (this.options.claudeCodeMaxOutputTokens !== undefined) {
modelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens
}
return { id, info: modelInfo }
}
const defaultModelInfo: ModelInfo = { ...claudeCodeModels[claudeCodeDefaultModelId] }
// Override maxTokens with the configured value if provided
if (this.options.claudeCodeMaxOutputTokens !== undefined) {
defaultModelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens
return { id, info: { ...claudeCodeModels[id] } }
}
return {
id: claudeCodeDefaultModelId,
info: defaultModelInfo,
info: { ...claudeCodeModels[claudeCodeDefaultModelId] },
}
}
@ -175,12 +288,4 @@ export class ClaudeCodeHandler implements ApiHandler {
}
return countTokens(content, { useWorker: true })
}
private attemptParse(str: string) {
try {
return JSON.parse(str)
} catch (err) {
return null
}
}
}

View file

@ -4,6 +4,7 @@ export type ApiStreamChunk =
| ApiStreamTextChunk
| ApiStreamUsageChunk
| ApiStreamReasoningChunk
| ApiStreamThinkingCompleteChunk
| ApiStreamGroundingChunk
| ApiStreamToolCallChunk
| ApiStreamToolCallStartChunk
@ -23,9 +24,35 @@ export interface ApiStreamTextChunk {
text: string
}
/**
* Reasoning/thinking chunk from the API stream.
* For Anthropic extended thinking, this may include a signature field
* which is required for passing thinking blocks back to the API during tool use.
*/
export interface ApiStreamReasoningChunk {
type: "reasoning"
text: string
/**
* Signature for the thinking block (Anthropic extended thinking).
* When present, this indicates a complete thinking block that should be
* preserved for tool use continuations. The signature is used to verify
* that thinking blocks were generated by Claude.
*/
signature?: string
}
/**
* Signals completion of a thinking block with its verification signature.
* Used by Anthropic extended thinking to pass the signature needed for
* tool use continuations and caching.
*/
export interface ApiStreamThinkingCompleteChunk {
type: "thinking_complete"
/**
* Cryptographic signature that verifies this thinking block was generated by Claude.
* Must be preserved and passed back to the API when continuing conversations with tool use.
*/
signature: string
}
export interface ApiStreamUsageChunk {

View file

@ -751,9 +751,30 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
messageWithTs.reasoning_details = reasoningDetails
}
// Store reasoning: plain text (most providers) or encrypted (OpenAI Native)
// Store reasoning: Anthropic thinking (with signature), plain text (most providers), or encrypted (OpenAI Native)
// Skip if reasoning_details already contains the reasoning (to avoid duplication)
if (reasoning && !reasoningDetails) {
if (reasoning && thoughtSignature && !reasoningDetails) {
// Anthropic provider with extended thinking: Store as proper `thinking` block
// This format passes through anthropic-filter.ts and is properly round-tripped
// for interleaved thinking with tool use (required by Anthropic API)
const thinkingBlock = {
type: "thinking",
thinking: reasoning,
signature: thoughtSignature,
}
if (typeof messageWithTs.content === "string") {
messageWithTs.content = [
thinkingBlock,
{ type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam,
]
} else if (Array.isArray(messageWithTs.content)) {
messageWithTs.content = [thinkingBlock, ...messageWithTs.content]
} else if (!messageWithTs.content) {
messageWithTs.content = [thinkingBlock]
}
} else if (reasoning && !reasoningDetails) {
// Other providers (non-Anthropic): Store as generic reasoning block
const reasoningBlock = {
type: "reasoning",
text: reasoning,
@ -791,9 +812,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
// If we have a thought signature, append it as a dedicated content block
// so it can be round-tripped in api_history.json and re-sent on subsequent calls.
if (thoughtSignature) {
// If we have a thought signature WITHOUT reasoning text (edge case),
// append it as a dedicated content block for non-Anthropic providers (e.g., Gemini).
// Note: For Anthropic, the signature is already included in the thinking block above.
if (thoughtSignature && !reasoning) {
const thoughtSignatureBlock = {
type: "thoughtSignature",
thoughtSignature,

View file

@ -2071,6 +2071,14 @@ export class ClineProvider
openRouterImageGenerationSelectedModel,
openRouterUseMiddleOutTransform,
featureRoomoteControlEnabled,
claudeCodeIsAuthenticated: await (async () => {
try {
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
return await claudeCodeOAuthManager.isAuthenticated()
} catch {
return false
}
})(),
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
}
}

View file

@ -2268,6 +2268,45 @@ export const webviewMessageHandler = async (
break
}
case "claudeCodeSignIn": {
try {
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
const authUrl = claudeCodeOAuthManager.startAuthorizationFlow()
// Open the authorization URL in the browser
await vscode.env.openExternal(vscode.Uri.parse(authUrl))
// Wait for the callback in a separate promise (non-blocking)
claudeCodeOAuthManager
.waitForCallback()
.then(async () => {
vscode.window.showInformationMessage("Successfully signed in to Claude Code")
await provider.postStateToWebview()
})
.catch((error) => {
provider.log(`Claude Code OAuth callback failed: ${error}`)
if (!String(error).includes("timed out")) {
vscode.window.showErrorMessage(`Claude Code sign in failed: ${error.message || error}`)
}
})
} catch (error) {
provider.log(`Claude Code OAuth failed: ${error}`)
vscode.window.showErrorMessage("Claude Code sign in failed.")
}
break
}
case "claudeCodeSignOut": {
try {
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
await claudeCodeOAuthManager.clearCredentials()
vscode.window.showInformationMessage("Signed out from Claude Code")
await provider.postStateToWebview()
} catch (error) {
provider.log(`Claude Code sign out failed: ${error}`)
vscode.window.showErrorMessage("Claude Code sign out failed.")
}
break
}
case "rooCloudManualUrl": {
try {
if (!message.text) {
@ -3042,6 +3081,37 @@ export const webviewMessageHandler = async (
break
}
case "requestClaudeCodeRateLimits": {
try {
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
const accessToken = await claudeCodeOAuthManager.getAccessToken()
if (!accessToken) {
provider.postMessageToWebview({
type: "claudeCodeRateLimits",
error: "Not authenticated with Claude Code",
})
break
}
const { fetchRateLimitInfo } = await import("../../integrations/claude-code/streaming-client")
const rateLimits = await fetchRateLimitInfo(accessToken)
provider.postMessageToWebview({
type: "claudeCodeRateLimits",
values: rateLimits,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
provider.log(`Error fetching Claude Code rate limits: ${errorMessage}`)
provider.postMessageToWebview({
type: "claudeCodeRateLimits",
error: errorMessage,
})
}
break
}
case "openDebugApiHistory":
case "openDebugUiHistory": {
const currentTask = provider.getCurrentTask()

View file

@ -25,6 +25,7 @@ import { ContextProxy } from "./core/config/ContextProxy"
import { ClineProvider } from "./core/webview/ClineProvider"
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
import { claudeCodeOAuthManager } from "./integrations/claude-code/oauth"
import { McpServerManager } from "./services/mcp/McpServerManager"
import { CodeIndexManager } from "./services/code-index/manager"
import { MdmService } from "./services/mdm/MdmService"
@ -90,6 +91,9 @@ export async function activate(context: vscode.ExtensionContext) {
// Initialize terminal shell execution handlers.
TerminalRegistry.initialize()
// Initialize Claude Code OAuth manager for direct API access.
claudeCodeOAuthManager.initialize(context)
// Get default commands from configuration.
const defaultCommands = vscode.workspace.getConfiguration(Package.name).get<string[]>("allowedCommands") || []

View file

@ -1,263 +0,0 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import { filterMessagesForClaudeCode } from "../message-filter"
describe("filterMessagesForClaudeCode", () => {
test("should pass through string messages unchanged", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello, this is a simple text message",
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual(messages)
})
test("should pass through text-only content blocks unchanged", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "This is a text block",
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual(messages)
})
test("should replace image blocks with text placeholders", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "Here's an image:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
},
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual([
{
role: "user",
content: [
{
type: "text",
text: "Here's an image:",
},
{
type: "text",
text: "[Image (base64): image/png not supported by Claude Code]",
},
],
},
])
})
test("should handle image blocks with unknown source types", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "image",
source: undefined as any,
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual([
{
role: "user",
content: [
{
type: "text",
text: "[Image (unknown): unknown not supported by Claude Code]",
},
],
},
])
})
test("should handle mixed content with multiple images", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "Compare these images:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/jpeg",
data: "base64data1",
},
},
{
type: "text",
text: "and",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/gif",
data: "base64data2",
},
},
{
type: "text",
text: "What do you think?",
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual([
{
role: "user",
content: [
{
type: "text",
text: "Compare these images:",
},
{
type: "text",
text: "[Image (base64): image/jpeg not supported by Claude Code]",
},
{
type: "text",
text: "and",
},
{
type: "text",
text: "[Image (base64): image/gif not supported by Claude Code]",
},
{
type: "text",
text: "What do you think?",
},
],
},
])
})
test("should handle multiple messages with images", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "First message with text only",
},
{
role: "assistant",
content: [
{
type: "text",
text: "I can help with that.",
},
],
},
{
role: "user",
content: [
{
type: "text",
text: "Here's an image:",
},
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "imagedata",
},
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual([
{
role: "user",
content: "First message with text only",
},
{
role: "assistant",
content: [
{
type: "text",
text: "I can help with that.",
},
],
},
{
role: "user",
content: [
{
type: "text",
text: "Here's an image:",
},
{
type: "text",
text: "[Image (base64): image/png not supported by Claude Code]",
},
],
},
])
})
test("should preserve other content block types unchanged", () => {
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: [
{
type: "text",
text: "Regular text",
},
// This would be some other content type that's not an image
{
type: "tool_use" as any,
id: "tool_123",
name: "test_tool",
input: { test: "data" },
},
],
},
]
const result = filterMessagesForClaudeCode(messages)
expect(result).toEqual(messages)
})
})

View file

@ -0,0 +1,198 @@
import {
generateCodeVerifier,
generateCodeChallenge,
generateState,
generateUserId,
buildAuthorizationUrl,
isTokenExpired,
CLAUDE_CODE_OAUTH_CONFIG,
type ClaudeCodeCredentials,
} from "../oauth"
describe("Claude Code OAuth", () => {
describe("generateCodeVerifier", () => {
test("should generate a base64url encoded verifier", () => {
const verifier = generateCodeVerifier()
// Base64url encoded 32 bytes = 43 characters
expect(verifier).toHaveLength(43)
// Should only contain base64url safe characters
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
})
test("should generate unique verifiers on each call", () => {
const verifier1 = generateCodeVerifier()
const verifier2 = generateCodeVerifier()
expect(verifier1).not.toBe(verifier2)
})
})
describe("generateCodeChallenge", () => {
test("should generate a base64url encoded SHA256 hash", () => {
const verifier = "test-verifier-string"
const challenge = generateCodeChallenge(verifier)
// Base64url encoded SHA256 hash = 43 characters
expect(challenge).toHaveLength(43)
// Should only contain base64url safe characters
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/)
})
test("should generate consistent challenge for same verifier", () => {
const verifier = "test-verifier-string"
const challenge1 = generateCodeChallenge(verifier)
const challenge2 = generateCodeChallenge(verifier)
expect(challenge1).toBe(challenge2)
})
test("should generate different challenges for different verifiers", () => {
const challenge1 = generateCodeChallenge("verifier1")
const challenge2 = generateCodeChallenge("verifier2")
expect(challenge1).not.toBe(challenge2)
})
})
describe("generateState", () => {
test("should generate a 32-character hex string", () => {
const state = generateState()
expect(state).toHaveLength(32) // 16 bytes = 32 hex chars
expect(state).toMatch(/^[0-9a-f]+$/)
})
test("should generate unique states on each call", () => {
const state1 = generateState()
const state2 = generateState()
expect(state1).not.toBe(state2)
})
})
describe("generateUserId", () => {
test("should generate user ID with correct format", () => {
const userId = generateUserId()
// Format: user_<16 hex>_account_<32 hex>_session_<32 hex>
expect(userId).toMatch(/^user_[0-9a-f]{16}_account_[0-9a-f]{32}_session_[0-9a-f]{32}$/)
})
test("should generate unique session IDs on each call", () => {
const userId1 = generateUserId()
const userId2 = generateUserId()
// Full IDs should be different due to random session UUID
expect(userId1).not.toBe(userId2)
})
test("should generate deterministic user hash and account UUID from email", () => {
const email = "test@example.com"
const userId1 = generateUserId(email)
const userId2 = generateUserId(email)
// Extract user and account parts (everything except session)
const userAccount1 = userId1.replace(/_session_[0-9a-f]{32}$/, "")
const userAccount2 = userId2.replace(/_session_[0-9a-f]{32}$/, "")
// User hash and account UUID should be deterministic for same email
expect(userAccount1).toBe(userAccount2)
// But session UUID should be different
const session1 = userId1.match(/_session_([0-9a-f]{32})$/)?.[1]
const session2 = userId2.match(/_session_([0-9a-f]{32})$/)?.[1]
expect(session1).not.toBe(session2)
})
test("should generate different user hash for different emails", () => {
const userId1 = generateUserId("user1@example.com")
const userId2 = generateUserId("user2@example.com")
const userHash1 = userId1.match(/^user_([0-9a-f]{16})_/)?.[1]
const userHash2 = userId2.match(/^user_([0-9a-f]{16})_/)?.[1]
expect(userHash1).not.toBe(userHash2)
})
test("should generate random user hash and account UUID without email", () => {
const userId1 = generateUserId()
const userId2 = generateUserId()
// Without email, even user hash should be different each call
const userHash1 = userId1.match(/^user_([0-9a-f]{16})_/)?.[1]
const userHash2 = userId2.match(/^user_([0-9a-f]{16})_/)?.[1]
// Extremely unlikely to be the same (random 8 bytes)
expect(userHash1).not.toBe(userHash2)
})
})
describe("buildAuthorizationUrl", () => {
test("should build correct authorization URL with all parameters", () => {
const codeChallenge = "test-code-challenge"
const state = "test-state"
const url = buildAuthorizationUrl(codeChallenge, state)
const parsedUrl = new URL(url)
expect(parsedUrl.origin + parsedUrl.pathname).toBe(CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint)
const params = parsedUrl.searchParams
expect(params.get("client_id")).toBe(CLAUDE_CODE_OAUTH_CONFIG.clientId)
expect(params.get("redirect_uri")).toBe(CLAUDE_CODE_OAUTH_CONFIG.redirectUri)
expect(params.get("scope")).toBe(CLAUDE_CODE_OAUTH_CONFIG.scopes)
expect(params.get("code_challenge")).toBe(codeChallenge)
expect(params.get("code_challenge_method")).toBe("S256")
expect(params.get("response_type")).toBe("code")
expect(params.get("state")).toBe(state)
})
})
describe("isTokenExpired", () => {
test("should return false for non-expired token", () => {
const futureDate = new Date(Date.now() + 60 * 60 * 1000) // 1 hour in future
const credentials: ClaudeCodeCredentials = {
type: "claude",
access_token: "test-token",
refresh_token: "test-refresh",
expired: futureDate.toISOString(),
}
expect(isTokenExpired(credentials)).toBe(false)
})
test("should return true for expired token", () => {
const pastDate = new Date(Date.now() - 60 * 60 * 1000) // 1 hour in past
const credentials: ClaudeCodeCredentials = {
type: "claude",
access_token: "test-token",
refresh_token: "test-refresh",
expired: pastDate.toISOString(),
}
expect(isTokenExpired(credentials)).toBe(true)
})
test("should return true for token expiring within 5 minute buffer", () => {
const almostExpired = new Date(Date.now() + 3 * 60 * 1000) // 3 minutes in future (within 5 min buffer)
const credentials: ClaudeCodeCredentials = {
type: "claude",
access_token: "test-token",
refresh_token: "test-refresh",
expired: almostExpired.toISOString(),
}
expect(isTokenExpired(credentials)).toBe(true)
})
test("should return false for token expiring after 5 minute buffer", () => {
const notYetExpiring = new Date(Date.now() + 10 * 60 * 1000) // 10 minutes in future
const credentials: ClaudeCodeCredentials = {
type: "claude",
access_token: "test-token",
refresh_token: "test-refresh",
expired: notYetExpiring.toISOString(),
}
expect(isTokenExpired(credentials)).toBe(false)
})
})
describe("CLAUDE_CODE_OAUTH_CONFIG", () => {
test("should have correct configuration values", () => {
expect(CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint).toBe("https://claude.ai/oauth/authorize")
expect(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint).toBe("https://console.anthropic.com/v1/oauth/token")
expect(CLAUDE_CODE_OAUTH_CONFIG.clientId).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e")
expect(CLAUDE_CODE_OAUTH_CONFIG.redirectUri).toBe("http://localhost:54545/callback")
expect(CLAUDE_CODE_OAUTH_CONFIG.scopes).toBe("org:create_api_key user:profile user:inference")
expect(CLAUDE_CODE_OAUTH_CONFIG.callbackPort).toBe(54545)
})
})
})

View file

@ -0,0 +1,740 @@
import { CLAUDE_CODE_API_CONFIG } from "../streaming-client"
describe("Claude Code Streaming Client", () => {
describe("CLAUDE_CODE_API_CONFIG", () => {
test("should have correct API endpoint", () => {
expect(CLAUDE_CODE_API_CONFIG.endpoint).toBe("https://api.anthropic.com/v1/messages")
})
test("should have correct API version", () => {
expect(CLAUDE_CODE_API_CONFIG.version).toBe("2023-06-01")
})
test("should have correct default betas", () => {
expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("claude-code-20250219")
expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("oauth-2025-04-20")
expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("interleaved-thinking-2025-05-14")
expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("fine-grained-tool-streaming-2025-05-14")
})
test("should have correct user agent", () => {
expect(CLAUDE_CODE_API_CONFIG.userAgent).toBe("claude-cli/1.0.83 (external, cli)")
})
})
describe("createStreamingMessage", () => {
let originalFetch: typeof global.fetch
beforeEach(() => {
originalFetch = global.fetch
})
afterEach(() => {
global.fetch = originalFetch
})
test("should make request with correct headers", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [{ role: "user", content: "Hello" }],
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining(CLAUDE_CODE_API_CONFIG.endpoint),
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
Authorization: "Bearer test-token",
"Content-Type": "application/json",
"Anthropic-Version": CLAUDE_CODE_API_CONFIG.version,
Accept: "text/event-stream",
}),
}),
)
})
test("should include correct body parameters", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [{ role: "user", content: "Hello" }],
maxTokens: 4096,
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
expect(body.model).toBe("claude-3-5-sonnet-20241022")
expect(body.stream).toBe(true)
expect(body.max_tokens).toBe(4096)
// System prompt should have cache_control on the user-provided text
expect(body.system).toEqual([
{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
{ type: "text", text: "You are helpful", cache_control: { type: "ephemeral" } },
])
// Messages should have cache_control on the last user message
expect(body.messages).toEqual([
{
role: "user",
content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }],
},
])
})
test("should add cache breakpoints to last two user messages", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [
{ role: "user", content: "First message" },
{ role: "assistant", content: "Response" },
{ role: "user", content: "Second message" },
{ role: "assistant", content: "Another response" },
{ role: "user", content: "Third message" },
],
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
// Only the last two user messages should have cache_control
expect(body.messages[0].content).toBe("First message") // No cache_control
expect(body.messages[2].content).toEqual([
{ type: "text", text: "Second message", cache_control: { type: "ephemeral" } },
])
expect(body.messages[4].content).toEqual([
{ type: "text", text: "Third message", cache_control: { type: "ephemeral" } },
])
})
test("should filter out non-Anthropic block types", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
{
role: "assistant",
content: [
{ type: "reasoning", text: "Internal reasoning" }, // Should be filtered
{ type: "thoughtSignature", data: "encrypted" }, // Should be filtered
{ type: "text", text: "Response" },
],
},
{
role: "user",
content: [{ type: "text", text: "Follow up" }],
},
] as any,
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
// The assistant message should only have the text block
expect(body.messages[1].content).toEqual([{ type: "text", text: "Response" }])
})
test("should preserve thinking and redacted_thinking blocks", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
{
role: "assistant",
content: [
{ type: "thinking", thinking: "Let me think...", signature: "abc123" },
{ type: "text", text: "Response" },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "123", content: "result" }],
},
] as any,
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
// Thinking blocks should be preserved
expect(body.messages[1].content).toContainEqual({
type: "thinking",
thinking: "Let me think...",
signature: "abc123",
})
// Tool result blocks should be preserved
expect(body.messages[2].content).toContainEqual({
type: "tool_result",
tool_use_id: "123",
content: "result",
})
})
test("should convert reasoning + thoughtSignature to thinking blocks for interleaved thinking (adjacent)", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
{
role: "assistant",
content: [
// Adjacent format (simple case)
{ type: "reasoning", text: "Let me analyze this problem step by step..." },
{
type: "thoughtSignature",
thoughtSignature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve",
},
{ type: "tool_use", id: "tool_123", name: "read_file", input: { path: "/test.txt" } },
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }],
},
] as any,
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
// The reasoning + thoughtSignature should be converted to a proper thinking block
expect(body.messages[1].content).toContainEqual({
type: "thinking",
thinking: "Let me analyze this problem step by step...",
signature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve",
})
// tool_use should be preserved
expect(body.messages[1].content).toContainEqual({
type: "tool_use",
id: "tool_123",
name: "read_file",
input: { path: "/test.txt" },
})
// tool_result should be preserved in user message
expect(body.messages[2].content).toContainEqual({
type: "tool_result",
tool_use_id: "tool_123",
content: "file contents",
})
})
test("should convert reasoning + thoughtSignature to thinking blocks when not adjacent (Task.ts format)", async () => {
// This matches the actual format from Task.ts where:
// - reasoning is PREPENDED (line 769)
// - text/tool_use blocks are in the middle
// - thoughtSignature is APPENDED (line 808)
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [
{
role: "user",
content: [{ type: "text", text: "Hello" }],
},
{
role: "assistant",
content: [
// Task.ts format: reasoning at START, content in MIDDLE, thoughtSignature at END
{ type: "reasoning", text: "Let me analyze this problem step by step..." },
{ type: "text", text: "I'll help you with that." },
{ type: "tool_use", id: "tool_123", name: "read_file", input: { path: "/test.txt" } },
{
type: "thoughtSignature",
thoughtSignature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve",
},
],
},
{
role: "user",
content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }],
},
] as any,
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
// Check the ORDER of blocks - thinking should be FIRST (at reasoning position)
const assistantContent = body.messages[1].content
expect(assistantContent[0]).toEqual({
type: "thinking",
thinking: "Let me analyze this problem step by step...",
signature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve",
})
// text block should be second
expect(assistantContent[1]).toMatchObject({
type: "text",
text: "I'll help you with that.",
})
// tool_use should be third
expect(assistantContent[2]).toEqual({
type: "tool_use",
id: "tool_123",
name: "read_file",
input: { path: "/test.txt" },
})
// thoughtSignature should be filtered out (combined with reasoning)
expect(assistantContent.length).toBe(3)
expect(assistantContent.some((b: { type: string }) => b.type === "thoughtSignature")).toBe(false)
// tool_result should be preserved in user message
expect(body.messages[2].content).toContainEqual({
type: "tool_result",
tool_use_id: "tool_123",
content: "file contents",
})
})
test("should strip reasoning_details from messages (provider switching)", async () => {
// When switching from OpenRouter/Roo to Claude Code, messages may have
// reasoning_details fields that the Anthropic API doesn't accept
// This causes errors like: "messages.3.reasoning_details: Extra inputs are not permitted"
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
// Simulate messages with reasoning_details (added by OpenRouter for Gemini/o-series)
const messagesWithReasoningDetails = [
{ role: "user", content: "Hello" },
{
role: "assistant",
content: [{ type: "text", text: "I'll help with that." }],
// This field is added by OpenRouter/Roo providers for Gemini/OpenAI reasoning
reasoning_details: [{ type: "summary_text", summary: "Thinking about the request" }],
},
{ role: "user", content: "Follow up question" },
]
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: messagesWithReasoningDetails as any,
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
// The assistant message should NOT have reasoning_details
expect(body.messages[1]).not.toHaveProperty("reasoning_details")
// But should still have the content
expect(body.messages[1].content).toContainEqual(
expect.objectContaining({
type: "text",
text: "I'll help with that.",
}),
)
// Only role and content should be present
expect(Object.keys(body.messages[1])).toEqual(["role", "content"])
})
test("should strip other non-standard message fields", async () => {
// Ensure any non-standard fields are stripped from messages
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const messagesWithExtraFields = [
{
role: "user",
content: "Hello",
customField: "should be stripped",
metadata: { foo: "bar" },
},
{
role: "assistant",
content: [{ type: "text", text: "Response" }],
internalId: "123",
timestamp: Date.now(),
},
]
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: messagesWithExtraFields as any,
})
// Consume the stream
for await (const _ of stream) {
// Just consume
}
const call = mockFetch.mock.calls[0]
const body = JSON.parse(call[1].body)
// All messages should only have role and content
body.messages.forEach((msg: Record<string, unknown>) => {
expect(Object.keys(msg).filter((k) => k !== "role" && k !== "content")).toHaveLength(0)
})
})
test("should yield error chunk on non-ok response", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 401,
statusText: "Unauthorized",
text: vi.fn().mockResolvedValue('{"error":{"message":"Invalid API key"}}'),
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "invalid-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [{ role: "user", content: "Hello" }],
})
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toHaveLength(1)
expect(chunks[0].type).toBe("error")
expect((chunks[0] as { type: "error"; error: string }).error).toBe("Invalid API key")
})
test("should yield error chunk when no response body", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: null,
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [{ role: "user", content: "Hello" }],
})
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toHaveLength(1)
expect(chunks[0].type).toBe("error")
expect((chunks[0] as { type: "error"; error: string }).error).toBe("No response body")
})
test("should parse text SSE events correctly", async () => {
const sseData = [
'event: content_block_start\ndata: {"index":0,"content_block":{"type":"text","text":"Hello"}}\n\n',
'event: content_block_delta\ndata: {"index":0,"delta":{"type":"text_delta","text":" world"}}\n\n',
"event: message_stop\ndata: {}\n\n",
]
let readIndex = 0
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockImplementation(() => {
if (readIndex < sseData.length) {
const value = new TextEncoder().encode(sseData[readIndex++])
return Promise.resolve({ done: false, value })
}
return Promise.resolve({ done: true, value: undefined })
}),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [{ role: "user", content: "Hello" }],
})
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Should have text chunks and usage
expect(chunks.some((c) => c.type === "text")).toBe(true)
expect(chunks.filter((c) => c.type === "text")).toEqual([
{ type: "text", text: "Hello" },
{ type: "text", text: " world" },
])
})
test("should parse thinking/reasoning SSE events correctly", async () => {
const sseData = [
'event: content_block_start\ndata: {"index":0,"content_block":{"type":"thinking","thinking":"Let me think..."}}\n\n',
'event: content_block_delta\ndata: {"index":0,"delta":{"type":"thinking_delta","thinking":" more thoughts"}}\n\n',
"event: message_stop\ndata: {}\n\n",
]
let readIndex = 0
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockImplementation(() => {
if (readIndex < sseData.length) {
const value = new TextEncoder().encode(sseData[readIndex++])
return Promise.resolve({ done: false, value })
}
return Promise.resolve({ done: true, value: undefined })
}),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [{ role: "user", content: "Hello" }],
})
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks.filter((c) => c.type === "reasoning")).toEqual([
{ type: "reasoning", text: "Let me think..." },
{ type: "reasoning", text: " more thoughts" },
])
})
test("should track and yield usage from message events", async () => {
const sseData = [
'event: message_start\ndata: {"message":{"usage":{"input_tokens":10,"output_tokens":0,"cache_read_input_tokens":5}}}\n\n',
'event: message_delta\ndata: {"usage":{"output_tokens":20}}\n\n',
"event: message_stop\ndata: {}\n\n",
]
let readIndex = 0
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
body: {
getReader: () => ({
read: vi.fn().mockImplementation(() => {
if (readIndex < sseData.length) {
const value = new TextEncoder().encode(sseData[readIndex++])
return Promise.resolve({ done: false, value })
}
return Promise.resolve({ done: true, value: undefined })
}),
releaseLock: vi.fn(),
}),
},
})
global.fetch = mockFetch
const { createStreamingMessage } = await import("../streaming-client")
const stream = createStreamingMessage({
accessToken: "test-token",
model: "claude-3-5-sonnet-20241022",
systemPrompt: "You are helpful",
messages: [{ role: "user", content: "Hello" }],
})
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage")
expect(usageChunk).toBeDefined()
expect(usageChunk).toMatchObject({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: 5,
})
})
})
})

View file

@ -1,35 +0,0 @@
import type { Anthropic } from "@anthropic-ai/sdk"
/**
* Filters out image blocks from messages since Claude Code doesn't support images.
* Replaces image blocks with text placeholders similar to how VSCode LM provider handles it.
*/
export function filterMessagesForClaudeCode(
messages: Anthropic.Messages.MessageParam[],
): Anthropic.Messages.MessageParam[] {
return messages.map((message) => {
// Handle simple string messages
if (typeof message.content === "string") {
return message
}
// Handle complex message structures
const filteredContent = message.content.map((block) => {
if (block.type === "image") {
// Replace image blocks with text placeholders
const sourceType = block.source?.type || "unknown"
const mediaType = block.source?.media_type || "unknown"
return {
type: "text" as const,
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
}
}
return block
})
return {
...message,
content: filteredContent,
}
})
}

View file

@ -0,0 +1,479 @@
import * as crypto from "crypto"
import * as http from "http"
import { URL } from "url"
import type { ExtensionContext } from "vscode"
import { z } from "zod"
// OAuth Configuration
export const CLAUDE_CODE_OAUTH_CONFIG = {
authorizationEndpoint: "https://claude.ai/oauth/authorize",
tokenEndpoint: "https://console.anthropic.com/v1/oauth/token",
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
redirectUri: "http://localhost:54545/callback",
scopes: "org:create_api_key user:profile user:inference",
callbackPort: 54545,
} as const
// Token storage key
const CLAUDE_CODE_CREDENTIALS_KEY = "claude-code-oauth-credentials"
// Credentials schema
const claudeCodeCredentialsSchema = z.object({
type: z.literal("claude"),
access_token: z.string().min(1),
refresh_token: z.string().min(1),
expired: z.string(), // RFC3339 datetime
email: z.string().optional(),
})
export type ClaudeCodeCredentials = z.infer<typeof claudeCodeCredentialsSchema>
// Token response schema from Anthropic
const tokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_in: z.number(),
email: z.string().optional(),
token_type: z.string().optional(),
})
/**
* Generates a cryptographically random PKCE code verifier
* Must be 43-128 characters long using unreserved characters
*/
export function generateCodeVerifier(): string {
// Generate 32 random bytes and encode as base64url (will be 43 characters)
const buffer = crypto.randomBytes(32)
return buffer.toString("base64url")
}
/**
* Generates the PKCE code challenge from the verifier using S256 method
*/
export function generateCodeChallenge(verifier: string): string {
const hash = crypto.createHash("sha256").update(verifier).digest()
return hash.toString("base64url")
}
/**
* Generates a random state parameter for CSRF protection
*/
export function generateState(): string {
return crypto.randomBytes(16).toString("hex")
}
/**
* Generates a user_id in the format required by Claude Code API
* Format: user_<hash>_account_<uuid>_session_<uuid>
*/
export function generateUserId(email?: string): string {
// Generate user hash from email or random bytes
const userHash = email
? crypto.createHash("sha256").update(email).digest("hex").slice(0, 16)
: crypto.randomBytes(8).toString("hex")
// Generate account UUID (persistent per email or random)
const accountUuid = email
? crypto.createHash("sha256").update(`account:${email}`).digest("hex").slice(0, 32)
: crypto.randomUUID().replace(/-/g, "")
// Generate session UUID (always random for each request)
const sessionUuid = crypto.randomUUID().replace(/-/g, "")
return `user_${userHash}_account_${accountUuid}_session_${sessionUuid}`
}
/**
* Builds the authorization URL for OAuth flow
*/
export function buildAuthorizationUrl(codeChallenge: string, state: string): string {
const params = new URLSearchParams({
client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId,
redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.redirectUri,
scope: CLAUDE_CODE_OAUTH_CONFIG.scopes,
code_challenge: codeChallenge,
code_challenge_method: "S256",
response_type: "code",
state,
})
return `${CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}`
}
/**
* Exchanges the authorization code for tokens
*/
export async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
state: string,
): Promise<ClaudeCodeCredentials> {
const body = {
code,
state,
grant_type: "authorization_code",
client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId,
redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.redirectUri,
code_verifier: codeVerifier,
}
const response = await fetch(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`)
}
const data = await response.json()
const tokenResponse = tokenResponseSchema.parse(data)
// Calculate expiry time
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000)
return {
type: "claude",
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token,
expired: expiresAt.toISOString(),
email: tokenResponse.email,
}
}
/**
* Refreshes the access token using the refresh token
*/
export async function refreshAccessToken(refreshToken: string): Promise<ClaudeCodeCredentials> {
const body = {
grant_type: "refresh_token",
client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId,
refresh_token: refreshToken,
}
const response = await fetch(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`)
}
const data = await response.json()
const tokenResponse = tokenResponseSchema.parse(data)
// Calculate expiry time
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000)
return {
type: "claude",
access_token: tokenResponse.access_token,
refresh_token: tokenResponse.refresh_token,
expired: expiresAt.toISOString(),
email: tokenResponse.email,
}
}
/**
* Checks if the credentials are expired (with 5 minute buffer)
*/
export function isTokenExpired(credentials: ClaudeCodeCredentials): boolean {
const expiryTime = new Date(credentials.expired).getTime()
const bufferMs = 5 * 60 * 1000 // 5 minutes buffer
return Date.now() >= expiryTime - bufferMs
}
/**
* ClaudeCodeOAuthManager - Handles OAuth flow and token management
*/
export class ClaudeCodeOAuthManager {
private context: ExtensionContext | null = null
private credentials: ClaudeCodeCredentials | null = null
private pendingAuth: {
codeVerifier: string
state: string
server?: http.Server
} | null = null
/**
* Initialize the OAuth manager with VS Code extension context
*/
initialize(context: ExtensionContext): void {
this.context = context
}
/**
* Load credentials from storage
*/
async loadCredentials(): Promise<ClaudeCodeCredentials | null> {
if (!this.context) {
return null
}
try {
const credentialsJson = await this.context.secrets.get(CLAUDE_CODE_CREDENTIALS_KEY)
if (!credentialsJson) {
return null
}
const parsed = JSON.parse(credentialsJson)
this.credentials = claudeCodeCredentialsSchema.parse(parsed)
return this.credentials
} catch (error) {
console.error("[claude-code-oauth] Failed to load credentials:", error)
return null
}
}
/**
* Save credentials to storage
*/
async saveCredentials(credentials: ClaudeCodeCredentials): Promise<void> {
if (!this.context) {
throw new Error("OAuth manager not initialized")
}
await this.context.secrets.store(CLAUDE_CODE_CREDENTIALS_KEY, JSON.stringify(credentials))
this.credentials = credentials
}
/**
* Clear credentials from storage
*/
async clearCredentials(): Promise<void> {
if (!this.context) {
return
}
await this.context.secrets.delete(CLAUDE_CODE_CREDENTIALS_KEY)
this.credentials = null
}
/**
* Get a valid access token, refreshing if necessary
*/
async getAccessToken(): Promise<string | null> {
// Try to load credentials if not already loaded
if (!this.credentials) {
await this.loadCredentials()
}
if (!this.credentials) {
return null
}
// Check if token is expired and refresh if needed
if (isTokenExpired(this.credentials)) {
try {
const newCredentials = await refreshAccessToken(this.credentials.refresh_token)
await this.saveCredentials(newCredentials)
} catch (error) {
console.error("[claude-code-oauth] Failed to refresh token:", error)
// Clear invalid credentials
await this.clearCredentials()
return null
}
}
return this.credentials.access_token
}
/**
* Get the user's email from credentials
*/
async getEmail(): Promise<string | null> {
if (!this.credentials) {
await this.loadCredentials()
}
return this.credentials?.email || null
}
/**
* Check if the user is authenticated
*/
async isAuthenticated(): Promise<boolean> {
const token = await this.getAccessToken()
return token !== null
}
/**
* Start the OAuth authorization flow
* Returns the authorization URL to open in browser
*/
startAuthorizationFlow(): string {
// Cancel any existing authorization flow before starting a new one
this.cancelAuthorizationFlow()
const codeVerifier = generateCodeVerifier()
const codeChallenge = generateCodeChallenge(codeVerifier)
const state = generateState()
this.pendingAuth = {
codeVerifier,
state,
}
return buildAuthorizationUrl(codeChallenge, state)
}
/**
* Start a local server to receive the OAuth callback
* Returns a promise that resolves when authentication is complete
*/
async waitForCallback(): Promise<ClaudeCodeCredentials> {
if (!this.pendingAuth) {
throw new Error("No pending authorization flow")
}
// Close any existing server before starting a new one
if (this.pendingAuth.server) {
try {
this.pendingAuth.server.close()
} catch {
// Ignore errors when closing
}
this.pendingAuth.server = undefined
}
return new Promise((resolve, reject) => {
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || "", `http://localhost:${CLAUDE_CODE_OAUTH_CONFIG.callbackPort}`)
if (url.pathname !== "/callback") {
res.writeHead(404)
res.end("Not Found")
return
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
const error = url.searchParams.get("error")
if (error) {
res.writeHead(400)
res.end(`Authentication failed: ${error}`)
reject(new Error(`OAuth error: ${error}`))
server.close()
return
}
if (!code || !state) {
res.writeHead(400)
res.end("Missing code or state parameter")
reject(new Error("Missing code or state parameter"))
server.close()
return
}
if (state !== this.pendingAuth?.state) {
res.writeHead(400)
res.end("State mismatch - possible CSRF attack")
reject(new Error("State mismatch"))
server.close()
return
}
try {
const credentials = await exchangeCodeForTokens(code, this.pendingAuth.codeVerifier, state)
await this.saveCredentials(credentials)
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
res.end(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Authentication Successful</title>
</head>
<body style="font-family: system-ui; text-align: center; padding: 50px;">
<h1>&#10003; Authentication Successful</h1>
<p>You can close this window and return to VS Code.</p>
<script>window.close();</script>
</body>
</html>`)
this.pendingAuth = null
server.close()
resolve(credentials)
} catch (exchangeError) {
res.writeHead(500)
res.end(`Token exchange failed: ${exchangeError}`)
reject(exchangeError)
server.close()
}
} catch (err) {
res.writeHead(500)
res.end("Internal server error")
reject(err)
server.close()
}
})
server.on("error", (err: NodeJS.ErrnoException) => {
this.pendingAuth = null
if (err.code === "EADDRINUSE") {
reject(
new Error(
`Port ${CLAUDE_CODE_OAUTH_CONFIG.callbackPort} is already in use. ` +
`Please close any other applications using this port and try again.`,
),
)
} else {
reject(err)
}
})
// Set a timeout for the callback
const timeout = setTimeout(
() => {
server.close()
reject(new Error("Authentication timed out"))
},
5 * 60 * 1000,
) // 5 minutes
server.listen(CLAUDE_CODE_OAUTH_CONFIG.callbackPort, () => {
if (this.pendingAuth) {
this.pendingAuth.server = server
}
})
// Clear timeout when server closes
server.on("close", () => {
clearTimeout(timeout)
})
})
}
/**
* Cancel any pending authorization flow
*/
cancelAuthorizationFlow(): void {
if (this.pendingAuth?.server) {
this.pendingAuth.server.close()
}
this.pendingAuth = null
}
/**
* Get the current credentials (for display purposes)
*/
getCredentials(): ClaudeCodeCredentials | null {
return this.credentials
}
}
// Singleton instance
export const claudeCodeOAuthManager = new ClaudeCodeOAuthManager()

View file

@ -0,0 +1,883 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import type { ClaudeCodeRateLimitInfo } from "@roo-code/types"
import * as os from "os"
/**
* Set of content block types that are valid for Anthropic API.
* Only these types will be passed through to the API.
* See: https://docs.anthropic.com/en/api/messages
*/
const VALID_ANTHROPIC_BLOCK_TYPES = new Set([
"text",
"image",
"tool_use",
"tool_result",
"thinking",
"redacted_thinking",
"document",
])
/**
* Converts internal content blocks to proper Anthropic format for interleaved thinking.
*
* This handles the conversion of Roo Code's internal block types to Anthropic's API format:
* - `reasoning` blocks (with text) + `thoughtSignature` blocks -> `thinking` blocks with `signature`
*
* According to Anthropic docs:
* - During tool use, you must pass `thinking` blocks back to the API for the last assistant message
* - The `signature` field is used to verify that thinking blocks were generated by Claude
* - Include the complete unmodified block back to the API to maintain reasoning continuity
*
* IMPORTANT: In Task.ts, the message structure is:
* - reasoning block is PREPENDED (at the start)
* - text/tool_use blocks are in the middle
* - thoughtSignature block is APPENDED (at the end)
*
* So we need to:
* 1. Find the reasoning block and thoughtSignature block anywhere in the content
* 2. If both exist, combine them into a thinking block at the REASONING position
* 3. Remove the thoughtSignature block from its position
* 4. Pass through other valid blocks in their original positions
*/
/**
* Internal type for content blocks that may include non-Anthropic types like
* reasoning and thoughtSignature that are used internally by Roo Code.
*/
interface InternalContentBlock {
type: string
text?: string
thinking?: string
signature?: string
thoughtSignature?: string
summary?: unknown[]
[key: string]: unknown
}
function convertToAnthropicThinkingBlocks(
content: Anthropic.Messages.ContentBlockParam[],
): Anthropic.Messages.ContentBlockParam[] {
// First pass: Find reasoning and thoughtSignature blocks (legacy format from older Task.ts)
// Note: New Task.ts stores thinking blocks directly with { type: "thinking", thinking, signature }
// which will pass through unchanged since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES
let reasoningIndex = -1
let reasoningText: string | undefined
let thoughtSignatureIndex = -1
let signature: string | undefined
for (let i = 0; i < content.length; i++) {
const block = content[i] as unknown as InternalContentBlock
// Handle legacy reasoning + thoughtSignature format
if (block.type === "reasoning" && typeof block.text === "string") {
reasoningIndex = i
reasoningText = block.text
} else if (block.type === "thoughtSignature" && typeof block.thoughtSignature === "string") {
thoughtSignatureIndex = i
signature = block.thoughtSignature
}
// Note: thinking blocks with { type: "thinking", thinking: "...", signature: "..." }
// are handled naturally since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES
}
// Second pass: Build result with proper thinking block placement
const result: Anthropic.Messages.ContentBlockParam[] = []
for (let i = 0; i < content.length; i++) {
const block = content[i] as unknown as InternalContentBlock
if (i === reasoningIndex) {
// At the reasoning position, insert a thinking block if we have both reasoning and signature (legacy format)
if (reasoningText && signature) {
result.push({
type: "thinking",
thinking: reasoningText,
signature: signature,
} as unknown as Anthropic.Messages.ContentBlockParam)
}
// If we only have reasoning without signature, skip it (not valid for API)
continue
}
if (i === thoughtSignatureIndex) {
// Skip the thoughtSignature block - it was combined with reasoning above
continue
}
// Pass through valid Anthropic blocks (includes thinking blocks with proper format)
if (VALID_ANTHROPIC_BLOCK_TYPES.has(block.type)) {
result.push(content[i])
}
}
return result
}
/**
* Filters out non-Anthropic content blocks from messages before sending to the API.
* Also converts internal reasoning + thoughtSignature blocks to proper Anthropic thinking blocks.
*
* Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept.
* This automatically filters out:
* - Internal "reasoning" blocks (Roo Code's internal representation) - unless combined with thoughtSignature
* - Gemini's "thoughtSignature" blocks (converted to thinking blocks when paired with reasoning)
* - Any other unknown block types
*
* IMPORTANT: This function also strips message-level fields that are not part of the Anthropic API:
* - `reasoning_details` (added by OpenRouter/Roo providers for Gemini/OpenAI reasoning)
* - Any other non-standard fields added by other providers
*
* We preserve ALL thinking blocks for these reasons:
* 1. Rewind functionality - users need to be able to go back in conversation history
* 2. Claude Opus 4.5+ preserves thinking blocks by default (per Anthropic docs)
* 3. Interleaved thinking requires thinking blocks to be passed back for tool use continuations
*
* The API will handle thinking blocks appropriately based on the model:
* - Claude Opus 4.5+: thinking blocks preserved (enables cache optimization)
* - Older models: thinking blocks stripped from prior turns automatically
*/
function filterNonAnthropicBlocks(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
const result: Anthropic.Messages.MessageParam[] = []
for (const message of messages) {
// Extract ONLY the standard Anthropic message fields (role, content)
// This strips out any extra fields like `reasoning_details` that other providers
// may have added to the messages (e.g., OpenRouter adds reasoning_details for Gemini/o-series)
const { role, content } = message
if (typeof content === "string") {
// Return a clean message with only role and content
result.push({ role, content })
continue
}
// Convert reasoning + thoughtSignature to proper thinking blocks
// and filter out any invalid block types
const convertedContent = convertToAnthropicThinkingBlocks(content)
// If all content was filtered out, skip this message
if (convertedContent.length === 0) {
continue
}
// Return a clean message with only role and content (no extra fields)
result.push({
role,
content: convertedContent,
})
}
return result
}
/**
* Adds cache_control breakpoints to the last two user messages for prompt caching.
* This follows Anthropic's recommended pattern:
* - Cache the system prompt (handled separately)
* - Cache the last text block of the second-to-last user message
* - Cache the last text block of the last user message
*
* According to Anthropic docs:
* - System prompts and tools remain cached despite thinking parameter changes
* - Message cache breakpoints are invalidated when thinking parameters change
* - When using extended thinking, thinking blocks from previous turns are stripped from context
*/
function addMessageCacheBreakpoints(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
// Find indices of user messages
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[],
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
return messages.map((message, index) => {
// Only add cache control to the last two user messages
if (index !== lastUserMsgIndex && index !== secondLastUserMsgIndex) {
return message
}
// Handle string content
if (typeof message.content === "string") {
return {
...message,
content: [
{
type: "text" as const,
text: message.content,
cache_control: { type: "ephemeral" as const },
},
],
}
}
// Handle array content - add cache_control to the last text block
const contentWithCache = message.content.map((block, blockIndex) => {
// Find the last text block index
let lastTextIndex = -1
for (let i = message.content.length - 1; i >= 0; i--) {
if ((message.content[i] as { type: string }).type === "text") {
lastTextIndex = i
break
}
}
// Only add cache_control to text blocks (the last one specifically)
if (blockIndex === lastTextIndex && (block as { type: string }).type === "text") {
const textBlock = block as { type: "text"; text: string }
return {
type: "text" as const,
text: textBlock.text,
cache_control: { type: "ephemeral" as const },
}
}
return block
})
return {
...message,
content: contentWithCache,
}
})
}
// API Configuration
export const CLAUDE_CODE_API_CONFIG = {
endpoint: "https://api.anthropic.com/v1/messages",
version: "2023-06-01",
defaultBetas: [
"prompt-caching-2024-07-31",
"claude-code-20250219",
"oauth-2025-04-20",
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
],
userAgent: "claude-cli/1.0.83 (external, cli)",
} as const
/**
* Get Claude Code CLI headers - includes Stainless SDK headers and special CLI headers
*/
function getClaudeCodeCliHeaders(): Record<string, string> {
const arch = os.arch()
const platform = os.platform()
// Map platform to OS name - must match Claude CLI format exactly
const osMap: Record<string, string> = {
darwin: "MacOS", // Note: Claude CLI uses "MacOS" not "macOS"
linux: "Linux",
win32: "Windows",
}
return {
// Claude Code specific headers
"Anthropic-Dangerous-Direct-Browser-Access": "true",
"X-App": "cli",
// Stainless SDK headers as used by Claude CLI
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.55.1",
"X-Stainless-OS": osMap[platform] || platform,
"X-Stainless-Arch": arch,
"X-Stainless-Runtime": "node",
"X-Stainless-Runtime-Version": "v24.3.0",
"X-Stainless-Helper-Method": "stream",
"X-Stainless-Timeout": "60",
Connection: "keep-alive",
}
}
/**
* SSE Event types from Anthropic streaming API
*/
export type SSEEventType =
| "message_start"
| "content_block_start"
| "content_block_delta"
| "content_block_stop"
| "message_delta"
| "message_stop"
| "ping"
| "error"
export interface SSEEvent {
event: SSEEventType
data: unknown
}
/**
* Thinking configuration for extended thinking mode
*/
export type ThinkingConfig =
| {
type: "enabled"
budget_tokens: number
}
| {
type: "disabled"
}
/**
* Stream message request options
*/
export interface StreamMessageOptions {
accessToken: string
model: string
systemPrompt: string
messages: Anthropic.Messages.MessageParam[]
maxTokens?: number
thinking?: ThinkingConfig
tools?: Anthropic.Messages.Tool[]
toolChoice?: Anthropic.Messages.ToolChoice
metadata?: {
user_id?: string
}
signal?: AbortSignal
}
/**
* SSE Parser state that persists across chunks
* This is necessary because SSE events can be split across multiple chunks
*/
interface SSEParserState {
buffer: string
currentEvent: string | null
currentData: string[]
}
/**
* Creates initial SSE parser state
*/
function createSSEParserState(): SSEParserState {
return {
buffer: "",
currentEvent: null,
currentData: [],
}
}
/**
* Parses SSE lines from a text chunk
* Returns parsed events and updates the state for the next chunk
*
* The state persists across chunks to handle events that span multiple chunks:
* - buffer: incomplete line from previous chunk
* - currentEvent: event type if we've seen "event:" but not the complete event
* - currentData: accumulated data lines for the current event
*/
function parseSSEChunk(chunk: string, state: SSEParserState): { events: SSEEvent[]; state: SSEParserState } {
const events: SSEEvent[] = []
const lines = (state.buffer + chunk).split("\n")
// Start with the accumulated state
let currentEvent = state.currentEvent
let currentData = [...state.currentData]
let remaining = ""
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
// If this is the last line and doesn't end with newline, it might be incomplete
if (i === lines.length - 1 && !chunk.endsWith("\n") && line !== "") {
remaining = line
continue
}
// Empty line signals end of event
if (line === "") {
if (currentEvent && currentData.length > 0) {
try {
const dataStr = currentData.join("\n")
const data = dataStr === "[DONE]" ? null : JSON.parse(dataStr)
events.push({
event: currentEvent as SSEEventType,
data,
})
} catch {
// Skip malformed events
console.error("[claude-code-streaming] Failed to parse SSE data:", currentData.join("\n"))
}
}
currentEvent = null
currentData = []
continue
}
// Parse event type
if (line.startsWith("event: ")) {
currentEvent = line.slice(7)
continue
}
// Parse data
if (line.startsWith("data: ")) {
currentData.push(line.slice(6))
continue
}
}
// Return updated state for next chunk
return {
events,
state: {
buffer: remaining,
currentEvent,
currentData,
},
}
}
/**
* Stream chunk types that the handler can yield
*/
export interface StreamTextChunk {
type: "text"
text: string
}
export interface StreamReasoningChunk {
type: "reasoning"
text: string
}
/**
* A complete thinking block with signature, used for tool use continuations.
* According to Anthropic docs:
* - During tool use, you must pass thinking blocks back to the API for the last assistant message
* - Include the complete unmodified block back to the API to maintain reasoning continuity
* - The signature field is used to verify that thinking blocks were generated by Claude
*/
export interface StreamThinkingCompleteChunk {
type: "thinking_complete"
index: number
thinking: string
signature: string
}
export interface StreamToolCallPartialChunk {
type: "tool_call_partial"
index: number
id?: string
name?: string
arguments?: string
}
export interface StreamUsageChunk {
type: "usage"
inputTokens: number
outputTokens: number
cacheReadTokens?: number
cacheWriteTokens?: number
totalCost?: number
}
export interface StreamErrorChunk {
type: "error"
error: string
}
export type StreamChunk =
| StreamTextChunk
| StreamReasoningChunk
| StreamThinkingCompleteChunk
| StreamToolCallPartialChunk
| StreamUsageChunk
| StreamErrorChunk
/**
* Creates a streaming message request to the Anthropic API using OAuth
*/
export async function* createStreamingMessage(options: StreamMessageOptions): AsyncGenerator<StreamChunk> {
const { accessToken, model, systemPrompt, messages, maxTokens, thinking, tools, toolChoice, metadata, signal } =
options
// Filter out non-Anthropic blocks before processing
const sanitizedMessages = filterNonAnthropicBlocks(messages)
// Add cache breakpoints to the last two user messages
// According to Anthropic docs:
// - System prompts and tools remain cached despite thinking parameter changes
// - Message cache breakpoints are invalidated when thinking parameters change
// - We cache the last two user messages for optimal cache hit rates
const messagesWithCache = addMessageCacheBreakpoints(sanitizedMessages)
// Build request body - match Claude Code format exactly
const body: Record<string, unknown> = {
model,
stream: true,
messages: messagesWithCache,
}
// Only include max_tokens if explicitly provided
if (maxTokens !== undefined) {
body.max_tokens = maxTokens
}
// Add thinking configuration for extended thinking mode
if (thinking) {
body.thinking = thinking
}
// System prompt as array of content blocks (Claude Code format)
// Prepend Claude Code branding as required by the API
// Add cache_control to the last text block for prompt caching
// System prompt caching is preserved even when thinking parameters change
body.system = [
{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
...(systemPrompt ? [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }] : []),
]
// Metadata with user_id is required for Claude Code
if (metadata) {
body.metadata = metadata
}
if (tools && tools.length > 0) {
body.tools = tools
// Default tool_choice to "auto" when tools are provided (as per spec example)
body.tool_choice = toolChoice || { type: "auto" }
} else if (toolChoice) {
body.tool_choice = toolChoice
}
// Build headers - match Claude Code CLI exactly
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"Anthropic-Version": CLAUDE_CODE_API_CONFIG.version,
"Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","),
Accept: "text/event-stream",
"Accept-Encoding": "gzip, deflate, br, zstd",
"User-Agent": CLAUDE_CODE_API_CONFIG.userAgent,
...getClaudeCodeCliHeaders(),
}
// Make the request
const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal,
})
if (!response.ok) {
const errorText = await response.text()
let errorMessage = `API request failed: ${response.status} ${response.statusText}`
try {
const errorJson = JSON.parse(errorText)
if (errorJson.error?.message) {
errorMessage = errorJson.error.message
}
} catch {
if (errorText) {
errorMessage += ` - ${errorText}`
}
}
yield { type: "error", error: errorMessage }
return
}
if (!response.body) {
yield { type: "error", error: "No response body" }
return
}
// Track usage across events
let totalInputTokens = 0
let totalOutputTokens = 0
let cacheReadTokens = 0
let cacheWriteTokens = 0
// Track content blocks by index for proper assembly
// This is critical for interleaved thinking - we need to capture complete thinking blocks
// with their signatures so they can be passed back to the API for tool use continuations
const contentBlocks: Map<
number,
{
type: string
text: string
signature?: string
id?: string
name?: string
arguments?: string
}
> = new Map()
// Read the stream
const reader = response.body.getReader()
const decoder = new TextDecoder()
let sseState = createSSEParserState()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const result = parseSSEChunk(chunk, sseState)
sseState = result.state
const events = result.events
for (const event of events) {
const eventData = event.data as Record<string, unknown> | null
if (!eventData) {
continue
}
switch (event.event) {
case "message_start": {
const message = eventData.message as Record<string, unknown>
if (!message) {
break
}
const usage = message.usage as Record<string, number> | undefined
if (usage) {
totalInputTokens += usage.input_tokens || 0
totalOutputTokens += usage.output_tokens || 0
cacheReadTokens += usage.cache_read_input_tokens || 0
cacheWriteTokens += usage.cache_creation_input_tokens || 0
}
break
}
case "content_block_start": {
const contentBlock = eventData.content_block as Record<string, unknown>
const index = eventData.index as number
if (contentBlock) {
switch (contentBlock.type) {
case "text":
// Initialize text block tracking
contentBlocks.set(index, {
type: "text",
text: (contentBlock.text as string) || "",
})
if (contentBlock.text) {
yield { type: "text", text: contentBlock.text as string }
}
break
case "thinking":
// Initialize thinking block tracking - critical for interleaved thinking
// We need to accumulate the text and capture the signature
contentBlocks.set(index, {
type: "thinking",
text: (contentBlock.thinking as string) || "",
})
if (contentBlock.thinking) {
yield { type: "reasoning", text: contentBlock.thinking as string }
}
break
case "tool_use":
contentBlocks.set(index, {
type: "tool_use",
text: "",
id: contentBlock.id as string,
name: contentBlock.name as string,
arguments: "",
})
yield {
type: "tool_call_partial",
index,
id: contentBlock.id as string,
name: contentBlock.name as string,
arguments: undefined,
}
break
}
}
break
}
case "content_block_delta": {
const delta = eventData.delta as Record<string, unknown>
const index = eventData.index as number
const block = contentBlocks.get(index)
if (delta) {
switch (delta.type) {
case "text_delta":
if (delta.text) {
// Accumulate text
if (block && block.type === "text") {
block.text += delta.text as string
}
yield { type: "text", text: delta.text as string }
}
break
case "thinking_delta":
if (delta.thinking) {
// Accumulate thinking text
if (block && block.type === "thinking") {
block.text += delta.thinking as string
}
yield { type: "reasoning", text: delta.thinking as string }
}
break
case "signature_delta":
// Capture the signature for the thinking block
// This is critical for interleaved thinking - the signature
// must be included when passing thinking blocks back to the API
if (delta.signature && block && block.type === "thinking") {
block.signature = delta.signature as string
}
break
case "input_json_delta":
if (block && block.type === "tool_use") {
block.arguments = (block.arguments || "") + (delta.partial_json as string)
}
yield {
type: "tool_call_partial",
index,
id: undefined,
name: undefined,
arguments: delta.partial_json as string,
}
break
}
}
break
}
case "content_block_stop": {
// When a content block completes, emit complete thinking blocks
// This enables the caller to preserve them for tool use continuations
const index = eventData.index as number
const block = contentBlocks.get(index)
if (block && block.type === "thinking" && block.signature) {
// Emit the complete thinking block with signature
// This is required for interleaved thinking with tool use
yield {
type: "thinking_complete",
index,
thinking: block.text,
signature: block.signature,
}
}
break
}
case "message_delta": {
const usage = eventData.usage as Record<string, number> | undefined
if (usage && usage.output_tokens !== undefined) {
// output_tokens in message_delta is the running total, not a delta
// So we replace rather than add
totalOutputTokens = usage.output_tokens
}
break
}
case "message_stop": {
// Yield final usage chunk
yield {
type: "usage",
inputTokens: totalInputTokens,
outputTokens: totalOutputTokens,
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
}
break
}
case "error": {
const errorData = eventData.error as Record<string, unknown>
yield {
type: "error",
error: (errorData?.message as string) || "Unknown streaming error",
}
break
}
}
}
}
} finally {
reader.releaseLock()
}
}
/**
* Parse rate limit headers from a response into a structured format
*/
function parseRateLimitHeaders(headers: Headers): ClaudeCodeRateLimitInfo {
const getHeader = (name: string): string | null => headers.get(name)
const parseFloat = (val: string | null): number => (val ? Number.parseFloat(val) : 0)
const parseInt = (val: string | null): number => (val ? Number.parseInt(val, 10) : 0)
return {
fiveHour: {
status: getHeader("anthropic-ratelimit-unified-5h-status") || "unknown",
utilization: parseFloat(getHeader("anthropic-ratelimit-unified-5h-utilization")),
resetTime: parseInt(getHeader("anthropic-ratelimit-unified-5h-reset")),
},
weekly: {
status: getHeader("anthropic-ratelimit-unified-7d_sonnet-status") || "unknown",
utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d_sonnet-utilization")),
resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d_sonnet-reset")),
},
weeklyUnified: {
status: getHeader("anthropic-ratelimit-unified-7d-status") || "unknown",
utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d-utilization")),
resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d-reset")),
},
representativeClaim: getHeader("anthropic-ratelimit-unified-representative-claim") || undefined,
overage: {
status: getHeader("anthropic-ratelimit-unified-overage-status") || "unknown",
disabledReason: getHeader("anthropic-ratelimit-unified-overage-disabled-reason") || undefined,
},
fallbackPercentage: parseFloat(getHeader("anthropic-ratelimit-unified-fallback-percentage")) || undefined,
organizationId: getHeader("anthropic-organization-id") || undefined,
fetchedAt: Date.now(),
}
}
/**
* Fetch rate limit information by making a minimal API call
* Uses a small request to get the response headers containing rate limit data
*/
export async function fetchRateLimitInfo(accessToken: string): Promise<ClaudeCodeRateLimitInfo> {
// Build minimal request body - use haiku for speed and lowest cost
const body = {
model: "claude-haiku-4-5",
max_tokens: 1,
system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }],
messages: [{ role: "user", content: "hi" }],
}
// Build headers - match Claude Code CLI exactly
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"Anthropic-Version": CLAUDE_CODE_API_CONFIG.version,
"Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","),
"User-Agent": CLAUDE_CODE_API_CONFIG.userAgent,
...getClaudeCodeCliHeaders(),
}
// Make the request
const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000),
})
if (!response.ok) {
const errorText = await response.text()
let errorMessage = `API request failed: ${response.status} ${response.statusText}`
try {
const errorJson = JSON.parse(errorText)
if (errorJson.error?.message) {
errorMessage = errorJson.error.message
}
} catch {
if (errorText) {
errorMessage += ` - ${errorText}`
}
}
throw new Error(errorMessage)
}
// Parse rate limit headers from the response
return parseRateLimitHeaders(response.headers)
}

View file

@ -132,6 +132,7 @@ export interface ExtensionMessage {
| "interactionRequired"
| "browserSessionUpdate"
| "browserSessionNavigate"
| "claudeCodeRateLimits"
text?: string
payload?: any // Add a generic payload for now, can refine later
// Checkpoint warning message
@ -358,6 +359,7 @@ export type ExtensionState = Pick<
remoteControlEnabled: boolean
taskSyncEnabled: boolean
featureRoomoteControlEnabled: boolean
claudeCodeIsAuthenticated?: boolean
debug?: boolean
}

View file

@ -127,6 +127,8 @@ export interface WebviewMessage {
| "cloudLandingPageSignIn"
| "rooCloudSignOut"
| "rooCloudManualUrl"
| "claudeCodeSignIn"
| "claudeCodeSignOut"
| "switchOrganization"
| "condenseTaskContextRequest"
| "requestIndexingStatus"
@ -176,6 +178,7 @@ export interface WebviewMessage {
| "browserPanelDidLaunch"
| "openDebugApiHistory"
| "openDebugUiHistory"
| "requestClaudeCodeRateLimits"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"

View file

@ -140,7 +140,7 @@ const ApiOptions = ({
setErrorMessage,
}: ApiOptionsProps) => {
const { t } = useAppTranslation()
const { organizationAllowList, cloudIsAuthenticated } = useExtensionState()
const { organizationAllowList, cloudIsAuthenticated, claudeCodeIsAuthenticated } = useExtensionState()
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
const headers = apiConfiguration?.openAiHeaders || {}
@ -567,6 +567,7 @@ const ApiOptions = ({
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
simplifySettings={fromWelcomeView}
claudeCodeIsAuthenticated={claudeCodeIsAuthenticated}
/>
)}
@ -779,7 +780,8 @@ const ApiOptions = ({
<Featherless apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{selectedProviderModels.length > 0 && (
{/* Skip generic model picker for claude-code since it has its own in ClaudeCode.tsx */}
{selectedProviderModels.length > 0 && selectedProvider !== "claude-code" && (
<>
<div>
<label className="block font-medium mb-1">{t("settings:providers.model")}</label>

View file

@ -14,6 +14,7 @@ type ModelInfoViewProps = {
modelInfo?: ModelInfo
isDescriptionExpanded: boolean
setIsDescriptionExpanded: (isExpanded: boolean) => void
hidePricing?: boolean
}
export const ModelInfoView = ({
@ -22,6 +23,7 @@ export const ModelInfoView = ({
modelInfo,
isDescriptionExpanded,
setIsDescriptionExpanded,
hidePricing,
}: ModelInfoViewProps) => {
const { t } = useAppTranslation()
@ -95,7 +97,8 @@ export const ModelInfoView = ({
),
].filter(Boolean)
const infoItems = shouldShowTierPricingTable ? baseInfoItems : [...baseInfoItems, ...priceInfoItems]
// Show pricing info unless hidePricing is set or tier pricing table is shown
const infoItems = shouldShowTierPricingTable || hidePricing ? baseInfoItems : [...baseInfoItems, ...priceInfoItems]
return (
<>
@ -113,7 +116,7 @@ export const ModelInfoView = ({
))}
</div>
{shouldShowTierPricingTable && (
{shouldShowTierPricingTable && !hidePricing && (
<div className="mt-2">
<div className="text-xs text-vscode-descriptionForeground mb-1">
{t("settings:serviceTier.pricingTableTitle")}

View file

@ -51,9 +51,10 @@ interface ModelPickerProps {
value: ProviderSettings[K],
isUserAction?: boolean,
) => void
organizationAllowList: OrganizationAllowList
organizationAllowList?: OrganizationAllowList
errorMessage?: string
simplifySettings?: boolean
hidePricing?: boolean
}
export const ModelPicker = ({
@ -67,6 +68,7 @@ export const ModelPicker = ({
organizationAllowList,
errorMessage,
simplifySettings,
hidePricing,
}: ModelPickerProps) => {
const { t } = useAppTranslation()
@ -262,20 +264,23 @@ export const ModelPicker = ({
modelInfo={selectedModelInfo}
isDescriptionExpanded={isDescriptionExpanded}
setIsDescriptionExpanded={setIsDescriptionExpanded}
hidePricing={hidePricing}
/>
)}
<div className="text-sm text-vscode-descriptionForeground">
<Trans
i18nKey="settings:modelPicker.automaticFetch"
components={{
serviceLink: <VSCodeLink href={serviceUrl} className="text-sm" />,
defaultModelLink: (
<VSCodeLink onClick={() => onSelect(defaultModelId)} className="text-sm" />
),
}}
values={{ serviceName, defaultModelId }}
/>
</div>
{!hidePricing && (
<div className="text-sm text-vscode-descriptionForeground">
<Trans
i18nKey="settings:modelPicker.automaticFetch"
components={{
serviceLink: <VSCodeLink href={serviceUrl} className="text-sm" />,
defaultModelLink: (
<VSCodeLink onClick={() => onSelect(defaultModelId)} className="text-sm" />
),
}}
values={{ serviceName, defaultModelId }}
/>
</div>
)}
</div>
)}
</>

View file

@ -1,63 +1,68 @@
import React from "react"
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import { type ProviderSettings } from "@roo-code/types"
import { type ProviderSettings, claudeCodeDefaultModelId, claudeCodeModels } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { Slider } from "@src/components/ui"
import { Button } from "@src/components/ui"
import { vscode } from "@src/utils/vscode"
import { ModelPicker } from "../ModelPicker"
import { ClaudeCodeRateLimitDashboard } from "./ClaudeCodeRateLimitDashboard"
interface ClaudeCodeProps {
apiConfiguration: ProviderSettings
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
simplifySettings?: boolean
claudeCodeIsAuthenticated?: boolean
}
export const ClaudeCode: React.FC<ClaudeCodeProps> = ({ apiConfiguration, setApiConfigurationField }) => {
export const ClaudeCode: React.FC<ClaudeCodeProps> = ({
apiConfiguration,
setApiConfigurationField,
simplifySettings,
claudeCodeIsAuthenticated = false,
}) => {
const { t } = useAppTranslation()
const handleInputChange = (e: Event | React.FormEvent<HTMLElement>) => {
const element = e.target as HTMLInputElement
setApiConfigurationField("claudeCodePath", element.value)
}
const maxOutputTokens = apiConfiguration?.claudeCodeMaxOutputTokens || 8000
return (
<div className="flex flex-col gap-4">
<div>
<VSCodeTextField
value={apiConfiguration?.claudeCodePath || ""}
style={{ width: "100%", marginTop: 3 }}
type="text"
onInput={handleInputChange}
placeholder={t("settings:providers.claudeCode.placeholder")}>
{t("settings:providers.claudeCode.pathLabel")}
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
{t("settings:providers.claudeCode.description")}
</p>
{/* Authentication Section */}
<div className="flex flex-col gap-2">
{claudeCodeIsAuthenticated ? (
<div className="flex justify-end">
<Button
variant="secondary"
size="sm"
onClick={() => vscode.postMessage({ type: "claudeCodeSignOut" })}>
{t("settings:providers.claudeCode.signOutButton", {
defaultValue: "Sign Out",
})}
</Button>
</div>
) : (
<Button
variant="primary"
onClick={() => vscode.postMessage({ type: "claudeCodeSignIn" })}
className="w-fit">
{t("settings:providers.claudeCode.signInButton", {
defaultValue: "Sign in to Claude Code",
})}
</Button>
)}
</div>
<div className="flex flex-col gap-1">
<div className="font-medium">{t("settings:providers.claudeCode.maxTokensLabel")}</div>
<div className="flex items-center gap-1">
<Slider
min={8000}
max={64000}
step={1024}
value={[maxOutputTokens]}
onValueChange={([value]) => setApiConfigurationField("claudeCodeMaxOutputTokens", value)}
/>
<div className="w-16 text-sm text-center">{maxOutputTokens}</div>
</div>
<p className="text-sm text-vscode-descriptionForeground mt-1">
{t("settings:providers.claudeCode.maxTokensDescription")}
</p>
</div>
{/* Rate Limit Dashboard - only shown when authenticated */}
<ClaudeCodeRateLimitDashboard isAuthenticated={claudeCodeIsAuthenticated} />
{/* Model Picker */}
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
defaultModelId={claudeCodeDefaultModelId}
models={claudeCodeModels}
modelIdKey="apiModelId"
serviceName="Claude Code"
serviceUrl="https://claude.ai"
simplifySettings={simplifySettings}
hidePricing
/>
</div>
)
}

View file

@ -0,0 +1,181 @@
import React, { useEffect, useState, useCallback } from "react"
import type { ClaudeCodeRateLimitInfo } from "@roo-code/types"
import { vscode } from "@src/utils/vscode"
interface ClaudeCodeRateLimitDashboardProps {
isAuthenticated: boolean
}
/**
* Formats a Unix timestamp reset time into a human-readable duration
*/
function formatResetTime(resetTimestamp: number): string {
if (!resetTimestamp) return "N/A"
const now = Date.now() / 1000 // Current time in seconds
const diff = resetTimestamp - now
if (diff <= 0) return "Now"
const hours = Math.floor(diff / 3600)
const minutes = Math.floor((diff % 3600) / 60)
if (hours > 24) {
const days = Math.floor(hours / 24)
const remainingHours = hours % 24
return `${days}d ${remainingHours}h`
}
if (hours > 0) {
return `${hours}h ${minutes}m`
}
return `${minutes}m`
}
/**
* Formats utilization as a percentage
*/
function formatUtilization(utilization: number): string {
return `${(utilization * 100).toFixed(1)}%`
}
/**
* Progress bar component for displaying usage
*/
const UsageProgressBar: React.FC<{ utilization: number; label: string }> = ({ utilization, label }) => {
const percentage = Math.min(utilization * 100, 100)
const isWarning = percentage >= 70
const isCritical = percentage >= 90
return (
<div className="w-full">
<div className="text-xs text-vscode-descriptionForeground mb-1">{label}</div>
<div className="w-full bg-vscode-input-background rounded-sm h-2 overflow-hidden">
<div
className={`h-full transition-all duration-300 ${
isCritical
? "bg-vscode-errorForeground"
: isWarning
? "bg-vscode-editorWarning-foreground"
: "bg-vscode-button-background"
}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
}
export const ClaudeCodeRateLimitDashboard: React.FC<ClaudeCodeRateLimitDashboardProps> = ({ isAuthenticated }) => {
const [rateLimits, setRateLimits] = useState<ClaudeCodeRateLimitInfo | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const fetchRateLimits = useCallback(() => {
if (!isAuthenticated) {
setRateLimits(null)
setError(null)
return
}
setIsLoading(true)
setError(null)
vscode.postMessage({ type: "requestClaudeCodeRateLimits" })
}, [isAuthenticated])
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "claudeCodeRateLimits") {
setIsLoading(false)
if (message.error) {
setError(message.error)
setRateLimits(null)
} else if (message.values) {
setRateLimits(message.values)
setError(null)
}
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [])
// Fetch rate limits when authenticated
useEffect(() => {
if (isAuthenticated) {
fetchRateLimits()
}
}, [isAuthenticated, fetchRateLimits])
if (!isAuthenticated) {
return null
}
if (isLoading && !rateLimits) {
return (
<div className="bg-vscode-editor-background border border-vscode-panel-border rounded-md p-3">
<div className="text-sm text-vscode-descriptionForeground">Loading rate limits...</div>
</div>
)
}
if (error) {
return (
<div className="bg-vscode-editor-background border border-vscode-panel-border rounded-md p-3">
<div className="flex items-center justify-between">
<div className="text-sm text-vscode-errorForeground">Failed to load rate limits</div>
<button
onClick={fetchRateLimits}
className="text-xs text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground cursor-pointer bg-transparent border-none">
Retry
</button>
</div>
</div>
)
}
if (!rateLimits) {
return null
}
return (
<div className="bg-vscode-editor-background border border-vscode-panel-border rounded-md p-3">
<div className="mb-3">
<div className="text-sm font-medium text-vscode-foreground">Usage Limits</div>
</div>
<div className="space-y-3">
{/* 5-hour limit */}
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="text-vscode-foreground">
Limit: {rateLimits.representativeClaim || "5-hour"}
</span>
<span className="text-vscode-descriptionForeground">
{formatUtilization(rateLimits.fiveHour.utilization)} used resets in{" "}
{formatResetTime(rateLimits.fiveHour.resetTime)}
</span>
</div>
<UsageProgressBar utilization={rateLimits.fiveHour.utilization} label="" />
</div>
{/* Weekly limit (if available) */}
{rateLimits.weeklyUnified && rateLimits.weeklyUnified.utilization > 0 && (
<div className="flex flex-col gap-1">
<div className="flex items-center justify-between text-xs">
<span className="text-vscode-foreground">Weekly</span>
<span className="text-vscode-descriptionForeground">
{formatUtilization(rateLimits.weeklyUnified.utilization)} used resets in{" "}
{formatResetTime(rateLimits.weeklyUnified.resetTime)}
</span>
</div>
<UsageProgressBar utilization={rateLimits.weeklyUnified.utilization} label="" />
</div>
)}
</div>
</div>
)
}

View file

@ -22,7 +22,7 @@ function SelectTrigger({ className, children, ...props }: React.ComponentProps<t
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(
"data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:border-destructive flex h-7 w-fit items-center justify-between gap-2 rounded-xs px-3 py-2 whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
"data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:border-destructive flex h-7 w-fit items-center justify-between gap-2 rounded-full px-3 py-2 whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
"border border-vscode-dropdown-border aria-expanded:border-vscode-focusBorder focus-visible:border-vscode-focusBorder",
"bg-vscode-dropdown-background hover:bg-transparent",
"text-vscode-dropdown-foreground",