feat: ROO-434 - Remove Claude Code provider

- Remove Claude Code integration files (oauth.ts, streaming-client.ts, tests)
- Remove Claude Code API provider handler and tests
- Remove Claude Code UI components
- Remove Claude Code types and models
- Update provider lists and configuration
- Remove Claude Code message handlers from webview
- Remove Claude Code initialization from extension
- Update UI hooks and settings to remove Claude Code support
- Remove Claude Code authentication state tracking
- Remove legacy migration code for Claude Code settings
- Update test files to use qwen-code instead of claude-code
This commit is contained in:
Roo Code 2026-01-09 17:34:11 +00:00
parent 907b94bc40
commit 67b5d3448b
32 changed files with 55 additions and 4130 deletions

View file

@ -7,11 +7,6 @@ describe("getApiProtocol", () => {
expect(getApiProtocol("anthropic", "gpt-4")).toBe("anthropic")
})
it("should return 'anthropic' for claude-code provider", () => {
expect(getApiProtocol("claude-code")).toBe("anthropic")
expect(getApiProtocol("claude-code", "some-model")).toBe("anthropic")
})
it("should return 'anthropic' for bedrock provider", () => {
expect(getApiProtocol("bedrock")).toBe("anthropic")
expect(getApiProtocol("bedrock", "gpt-4")).toBe("anthropic")

View file

@ -7,7 +7,6 @@ import {
basetenModels,
bedrockModels,
cerebrasModels,
claudeCodeModels,
deepSeekModels,
doubaoModels,
featherlessModels,
@ -122,7 +121,6 @@ export const providerNames = [
"bedrock",
"baseten",
"cerebras",
"claude-code",
"doubao",
"deepseek",
"featherless",
@ -200,8 +198,6 @@ const anthropicSchema = apiModelIdProviderModelSchema.extend({
anthropicBeta1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window.
})
const claudeCodeSchema = apiModelIdProviderModelSchema.extend({})
const openRouterSchema = baseProviderSettingsSchema.extend({
openRouterApiKey: z.string().optional(),
openRouterModelId: z.string().optional(),
@ -425,7 +421,6 @@ const defaultSchema = z.object({
export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [
anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })),
claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })),
openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })),
bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })),
vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })),
@ -466,7 +461,6 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
export const providerSettingsSchema = z.object({
apiProvider: providerNamesSchema.optional(),
...anthropicSchema.shape,
...claudeCodeSchema.shape,
...openRouterSchema.shape,
...bedrockSchema.shape,
...vertexSchema.shape,
@ -554,7 +548,6 @@ export const isTypicalProvider = (key: unknown): key is TypicalProvider =>
export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
anthropic: "apiModelId",
"claude-code": "apiModelId",
openrouter: "openRouterModelId",
bedrock: "apiModelId",
vertex: "apiModelId",
@ -593,7 +586,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
*/
// Providers that use Anthropic-style API protocol.
export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code", "bedrock", "minimax"]
export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "bedrock", "minimax"]
export const getApiProtocol = (provider: ProviderName | undefined, modelId?: string): "anthropic" | "openai" => {
if (provider && ANTHROPIC_STYLE_PROVIDERS.includes(provider)) {
@ -640,7 +633,6 @@ export const MODELS_BY_PROVIDER: Record<
label: "Cerebras",
models: Object.keys(cerebrasModels),
},
"claude-code": { id: "claude-code", label: "Claude Code", models: Object.keys(claudeCodeModels) },
deepseek: {
id: "deepseek",
label: "DeepSeek",

View file

@ -1,46 +0,0 @@
import { normalizeClaudeCodeModelId } from "../claude-code.js"
describe("normalizeClaudeCodeModelId", () => {
test("should return valid model IDs unchanged", () => {
expect(normalizeClaudeCodeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5")
expect(normalizeClaudeCodeModelId("claude-opus-4-5")).toBe("claude-opus-4-5")
expect(normalizeClaudeCodeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5")
})
test("should normalize sonnet models with date suffix to claude-sonnet-4-5", () => {
// Sonnet 4.5 with date
expect(normalizeClaudeCodeModelId("claude-sonnet-4-5-20250929")).toBe("claude-sonnet-4-5")
// Sonnet 4 (legacy)
expect(normalizeClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-5")
// Claude 3.7 Sonnet
expect(normalizeClaudeCodeModelId("claude-3-7-sonnet-20250219")).toBe("claude-sonnet-4-5")
// Claude 3.5 Sonnet
expect(normalizeClaudeCodeModelId("claude-3-5-sonnet-20241022")).toBe("claude-sonnet-4-5")
})
test("should normalize opus models with date suffix to claude-opus-4-5", () => {
// Opus 4.5 with date
expect(normalizeClaudeCodeModelId("claude-opus-4-5-20251101")).toBe("claude-opus-4-5")
// Opus 4.1 (legacy)
expect(normalizeClaudeCodeModelId("claude-opus-4-1-20250805")).toBe("claude-opus-4-5")
// Opus 4 (legacy)
expect(normalizeClaudeCodeModelId("claude-opus-4-20250514")).toBe("claude-opus-4-5")
})
test("should normalize haiku models with date suffix to claude-haiku-4-5", () => {
// Haiku 4.5 with date
expect(normalizeClaudeCodeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5")
// Claude 3.5 Haiku
expect(normalizeClaudeCodeModelId("claude-3-5-haiku-20241022")).toBe("claude-haiku-4-5")
})
test("should handle case-insensitive model family matching", () => {
expect(normalizeClaudeCodeModelId("Claude-Sonnet-4-5-20250929")).toBe("claude-sonnet-4-5")
expect(normalizeClaudeCodeModelId("CLAUDE-OPUS-4-5-20251101")).toBe("claude-opus-4-5")
})
test("should fallback to default for unrecognized models", () => {
expect(normalizeClaudeCodeModelId("unknown-model")).toBe("claude-sonnet-4-5")
expect(normalizeClaudeCodeModelId("gpt-4")).toBe("claude-sonnet-4-5")
})
})

View file

@ -1,160 +0,0 @@
import type { ModelInfo } from "../model.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 strip date suffix from model names
const DATE_SUFFIX_PATTERN = /-\d{8}$/
// 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": {
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-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>
// 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"
/**
* Model family patterns for normalization.
* Maps regex patterns to their canonical Claude Code model IDs.
*
* Order matters - more specific patterns should come first.
*/
const MODEL_FAMILY_PATTERNS: Array<{ pattern: RegExp; target: ClaudeCodeModelId }> = [
// Opus models (any version) → claude-opus-4-5
{ pattern: /opus/i, target: "claude-opus-4-5" },
// Haiku models (any version) → claude-haiku-4-5
{ pattern: /haiku/i, target: "claude-haiku-4-5" },
// Sonnet models (any version) → claude-sonnet-4-5
{ pattern: /sonnet/i, target: "claude-sonnet-4-5" },
]
/**
* Normalizes a Claude model ID to a valid Claude Code model ID.
*
* This function handles backward compatibility for legacy model names
* that may include version numbers or date suffixes. It maps:
* - claude-sonnet-4-5-20250929, claude-sonnet-4-20250514, claude-3-7-sonnet-20250219, claude-3-5-sonnet-20241022 claude-sonnet-4-5
* - claude-opus-4-5-20251101, claude-opus-4-1-20250805, claude-opus-4-20250514 claude-opus-4-5
* - claude-haiku-4-5-20251001, claude-3-5-haiku-20241022 claude-haiku-4-5
*
* @param modelId - The model ID to normalize (may be a legacy format)
* @returns A valid ClaudeCodeModelId, or the original ID if already valid
*
* @example
* normalizeClaudeCodeModelId("claude-sonnet-4-5") // returns "claude-sonnet-4-5"
* normalizeClaudeCodeModelId("claude-3-5-sonnet-20241022") // returns "claude-sonnet-4-5"
* normalizeClaudeCodeModelId("claude-opus-4-1-20250805") // returns "claude-opus-4-5"
*/
export function normalizeClaudeCodeModelId(modelId: string): ClaudeCodeModelId {
// If already a valid model ID, return as-is
// Use Object.hasOwn() instead of 'in' operator to avoid matching inherited properties like 'toString'
if (Object.hasOwn(claudeCodeModels, modelId)) {
return modelId as ClaudeCodeModelId
}
// Strip date suffix if present (e.g., -20250514)
const withoutDate = modelId.replace(DATE_SUFFIX_PATTERN, "")
// Check if stripping the date makes it valid
if (Object.hasOwn(claudeCodeModels, withoutDate)) {
return withoutDate as ClaudeCodeModelId
}
// Match by model family
for (const { pattern, target } of MODEL_FAMILY_PATTERNS) {
if (pattern.test(modelId)) {
return target
}
}
// Fallback to default if no match (shouldn't happen with valid Claude models)
return claudeCodeDefaultModelId
}
/**
* 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

View file

@ -3,7 +3,6 @@ export * from "./baseten.js"
export * from "./bedrock.js"
export * from "./cerebras.js"
export * from "./chutes.js"
export * from "./claude-code.js"
export * from "./deepseek.js"
export * from "./doubao.js"
export * from "./featherless.js"
@ -37,7 +36,6 @@ import { basetenDefaultModelId } from "./baseten.js"
import { bedrockDefaultModelId } from "./bedrock.js"
import { cerebrasDefaultModelId } from "./cerebras.js"
import { chutesDefaultModelId } from "./chutes.js"
import { claudeCodeDefaultModelId } from "./claude-code.js"
import { deepSeekDefaultModelId } from "./deepseek.js"
import { doubaoDefaultModelId } from "./doubao.js"
import { featherlessDefaultModelId } from "./featherless.js"
@ -123,8 +121,6 @@ export function getProviderDefaultModelId(
return deepInfraDefaultModelId
case "vscode-lm":
return vscodeLlmDefaultModelId
case "claude-code":
return claudeCodeDefaultModelId
case "cerebras":
return cerebrasDefaultModelId
case "sambanova":

View file

@ -28,7 +28,6 @@ import {
HuggingFaceHandler,
ChutesHandler,
LiteLLMHandler,
ClaudeCodeHandler,
QwenCodeHandler,
SambaNovaHandler,
IOIntelligenceHandler,
@ -122,8 +121,6 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler(options)
case "claude-code":
return new ClaudeCodeHandler(options)
case "openrouter":
return new OpenRouterHandler(options)
case "bedrock":

View file

@ -1,169 +0,0 @@
import { ClaudeCodeHandler } from "../claude-code"
import type { ApiHandlerOptions } from "../../../shared/api"
import type { StreamChunk } from "../../../integrations/claude-code/streaming-client"
import type { ApiStreamUsageChunk } from "../../transform/stream"
// 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 streaming client
vi.mock("../../../integrations/claude-code/streaming-client", () => ({
createStreamingMessage: vi.fn(),
}))
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 - Caching Support", () => {
let handler: ClaudeCodeHandler
const mockOptions: ApiHandlerOptions = {
apiModelId: "claude-sonnet-4-5",
}
beforeEach(() => {
handler = new ClaudeCodeHandler(mockOptions)
vi.clearAllMocks()
mockGetAccessToken.mockResolvedValue("test-access-token")
})
it("should collect cache read tokens from API response", async () => {
const mockStream = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Hello!" }
yield {
type: "usage",
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 80,
cacheWriteTokens: 20,
}
}
mockCreateStreamingMessage.mockReturnValue(mockStream())
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Find the usage chunk
const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
expect(usageChunk).toBeDefined()
expect(usageChunk!.inputTokens).toBe(100)
expect(usageChunk!.outputTokens).toBe(50)
expect(usageChunk!.cacheReadTokens).toBe(80)
expect(usageChunk!.cacheWriteTokens).toBe(20)
})
it("should accumulate cache tokens across multiple messages", async () => {
// Note: The streaming client handles accumulation internally.
// Each usage chunk represents the accumulated totals for that point in the stream.
// This test verifies that we correctly pass through the accumulated values.
const mockStream = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Part 1" }
yield {
type: "usage",
inputTokens: 50,
outputTokens: 25,
cacheReadTokens: 40,
cacheWriteTokens: 10,
}
yield { type: "text", text: "Part 2" }
yield {
type: "usage",
inputTokens: 100, // Accumulated: 50 + 50
outputTokens: 50, // Accumulated: 25 + 25
cacheReadTokens: 70, // Accumulated: 40 + 30
cacheWriteTokens: 30, // Accumulated: 10 + 20
}
}
mockCreateStreamingMessage.mockReturnValue(mockStream())
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
// Get the last usage chunk which should have accumulated totals
const usageChunks = chunks.filter((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk[]
expect(usageChunks.length).toBe(2)
const lastUsageChunk = usageChunks[usageChunks.length - 1]
expect(lastUsageChunk.inputTokens).toBe(100) // 50 + 50
expect(lastUsageChunk.outputTokens).toBe(50) // 25 + 25
expect(lastUsageChunk.cacheReadTokens).toBe(70) // 40 + 30
expect(lastUsageChunk.cacheWriteTokens).toBe(30) // 10 + 20
})
it("should handle missing cache token fields gracefully", async () => {
const mockStream = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Hello!" }
yield {
type: "usage",
inputTokens: 100,
outputTokens: 50,
// No cache tokens provided
}
}
mockCreateStreamingMessage.mockReturnValue(mockStream())
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
expect(usageChunk).toBeDefined()
expect(usageChunk!.inputTokens).toBe(100)
expect(usageChunk!.outputTokens).toBe(50)
expect(usageChunk!.cacheReadTokens).toBeUndefined()
expect(usageChunk!.cacheWriteTokens).toBeUndefined()
})
it("should report zero cost for subscription usage", async () => {
// Claude Code is always subscription-based, cost should always be 0
const mockStream = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Hello!" }
yield {
type: "usage",
inputTokens: 100,
outputTokens: 50,
cacheReadTokens: 80,
cacheWriteTokens: 20,
}
}
mockCreateStreamingMessage.mockReturnValue(mockStream())
const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }])
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined
expect(usageChunk).toBeDefined()
expect(usageChunk!.totalCost).toBe(0) // Should always be 0 for Claude Code (subscription-based)
})
})

View file

@ -1,597 +0,0 @@
import { ClaudeCodeHandler } from "../claude-code"
import { ApiHandlerOptions } from "../../../shared/api"
import type { StreamChunk } from "../../../integrations/claude-code/streaming-client"
// 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 streaming client
vi.mock("../../../integrations/claude-code/streaming-client", () => ({
createStreamingMessage: vi.fn(),
}))
const { claudeCodeOAuthManager } = await import("../../../integrations/claude-code/oauth")
const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client")
const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken)
const mockGetEmail = vi.mocked(claudeCodeOAuthManager.getEmail)
const mockCreateStreamingMessage = vi.mocked(createStreamingMessage)
describe("ClaudeCodeHandler", () => {
let handler: ClaudeCodeHandler
beforeEach(() => {
vi.clearAllMocks()
const options: ApiHandlerOptions = {
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-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 = {
apiModelId: "invalid-model",
}
const handlerWithInvalidModel = new ClaudeCodeHandler(options)
const model = handlerWithInvalidModel.getModel()
expect(model.id).toBe("claude-sonnet-4-5") // default model
})
test("should return model maxTokens from model definition", () => {
const options: ApiHandlerOptions = {
apiModelId: "claude-opus-4-5",
}
const handlerWithModel = new ClaudeCodeHandler(options)
const model = handlerWithModel.getModel()
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 support reasoning effort configuration", () => {
const options: ApiHandlerOptions = {
apiModelId: "claude-sonnet-4-5",
}
const handler = new ClaudeCodeHandler(options)
const model = handler.getModel()
// Default model has supportsReasoningEffort
expect(model.info.supportsReasoningEffort).toEqual(["disable", "low", "medium", "high"])
expect(model.info.reasoningEffort).toBe("medium")
})
test("should throw error when not authenticated", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
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<StreamChunk> {
// Empty generator for basic test
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
// Need to start iterating to trigger the call
const iterator = stream[Symbol.asyncIterator]()
await iterator.next()
// 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 (32768 for claude-sonnet-4-5)
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
accessToken: "test-access-token",
model: "claude-sonnet-4-5",
systemPrompt,
messages,
maxTokens: 32768, // model's maxTokens from claudeCodeModels definition
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 disable thinking when reasoningEffort is set to disable", async () => {
const options: ApiHandlerOptions = {
apiModelId: "claude-sonnet-4-5",
reasoningEffort: "disable",
}
const handlerNoThinking = new ClaudeCodeHandler(options)
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<StreamChunk> {
// Empty generator for basic test
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handlerNoThinking.createMessage(systemPrompt, messages)
// Need to start iterating to trigger the call
const iterator = stream[Symbol.asyncIterator]()
await iterator.next()
// Verify createStreamingMessage was called with thinking disabled
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
accessToken: "test-access-token",
model: "claude-sonnet-4-5",
systemPrompt,
messages,
maxTokens: 32768, // model maxTokens from claudeCodeModels definition
thinking: { type: "disabled" },
tools: undefined,
toolChoice: undefined,
metadata: {
user_id: "user_abc123_account_def456_session_ghi789",
},
})
})
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" }]
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 (32768 for claude-sonnet-4-5)
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
accessToken: "test-access-token",
model: "claude-sonnet-4-5",
systemPrompt,
messages,
maxTokens: 32768, // model's maxTokens from claudeCodeModels definition
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!" }
}
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 = []
for await (const chunk of stream) {
results.push(chunk)
}
expect(results).toHaveLength(1)
expect(results[0]).toEqual({
type: "reasoning",
text: "I need to think about this carefully...",
})
})
test("should handle mixed content types 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 mixed content
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "reasoning", text: "Let me think about this..." }
yield { type: "text", text: "Here's my response!" }
}
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: "reasoning",
text: "Let me think about this...",
})
expect(results[1]).toEqual({
type: "text",
text: "Here's my response!",
})
})
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" }]
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"}' }
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
expect(results).toHaveLength(3)
expect(results[0]).toEqual({
type: "tool_call_partial",
index: 0,
id: "tool_123",
name: "read_file",
arguments: undefined,
})
expect(results[1]).toEqual({
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 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 with text and usage
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Hello there!" }
yield {
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: 5,
cacheWriteTokens: 3,
}
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
// Should have text chunk and usage chunk
expect(results).toHaveLength(2)
expect(results[0]).toEqual({
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,
})
})
test("should handle usage without cache tokens", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
mockGetAccessToken.mockResolvedValue("test-access-token")
// Mock async generator with usage without cache tokens
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Hello there!" }
yield {
type: "usage",
inputTokens: 10,
outputTokens: 20,
}
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const results = []
for await (const chunk of stream) {
results.push(chunk)
}
// Claude Code is subscription-based, no per-token cost
expect(results).toHaveLength(2)
expect(results[1]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 20,
cacheReadTokens: undefined,
cacheWriteTokens: undefined,
totalCost: 0,
})
})
test("should handle API errors 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 an error
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "error", error: "Invalid model name" }
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const stream = handler.createMessage(systemPrompt, messages)
const iterator = stream[Symbol.asyncIterator]()
// Should throw an error
await expect(iterator.next()).rejects.toThrow("Invalid model name")
})
test("should handle authentication refresh and continue streaming", async () => {
const systemPrompt = "You are a helpful assistant"
const messages = [{ role: "user" as const, content: "Hello" }]
// First call returns a valid token
mockGetAccessToken.mockResolvedValue("refreshed-token")
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Response after refresh" }
}
mockCreateStreamingMessage.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: "text",
text: "Response after refresh",
})
expect(mockCreateStreamingMessage).toHaveBeenCalledWith(
expect.objectContaining({
accessToken: "refreshed-token",
}),
)
})
describe("completePrompt", () => {
test("should throw error when not authenticated", async () => {
mockGetAccessToken.mockResolvedValue(null)
await expect(handler.completePrompt("Test prompt")).rejects.toThrow(/not authenticated/i)
})
test("should complete prompt and return text response", async () => {
mockGetAccessToken.mockResolvedValue("test-access-token")
mockGetEmail.mockResolvedValue("test@example.com")
// Mock async generator that yields text chunks
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Hello " }
yield { type: "text", text: "world!" }
yield { type: "usage", inputTokens: 10, outputTokens: 5 }
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const result = await handler.completePrompt("Say hello")
expect(result).toBe("Hello world!")
})
test("should call createStreamingMessage with empty system prompt and thinking disabled", async () => {
mockGetAccessToken.mockResolvedValue("test-access-token")
mockGetEmail.mockResolvedValue("test@example.com")
// Mock empty async generator
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Response" }
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
await handler.completePrompt("Test prompt")
// Verify createStreamingMessage was called with correct parameters
// System prompt is empty because the prompt text contains all context
// createStreamingMessage will still prepend the Claude Code branding
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
accessToken: "test-access-token",
model: "claude-sonnet-4-5",
systemPrompt: "", // Empty - branding is added by createStreamingMessage
messages: [{ role: "user", content: "Test prompt" }],
maxTokens: 32768,
thinking: { type: "disabled" }, // No thinking for simple completions
metadata: {
user_id: "user_abc123_account_def456_session_ghi789",
},
})
})
test("should handle API errors from streaming", async () => {
mockGetAccessToken.mockResolvedValue("test-access-token")
mockGetEmail.mockResolvedValue("test@example.com")
// Mock async generator that yields an error
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "error", error: "API rate limit exceeded" }
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API rate limit exceeded")
})
test("should return empty string when no text chunks received", async () => {
mockGetAccessToken.mockResolvedValue("test-access-token")
mockGetEmail.mockResolvedValue("test@example.com")
// Mock async generator that only yields usage
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "usage", inputTokens: 10, outputTokens: 0 }
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
const result = await handler.completePrompt("Test prompt")
expect(result).toBe("")
})
test("should use opus model maxTokens when configured", async () => {
const options: ApiHandlerOptions = {
apiModelId: "claude-opus-4-5",
}
const handlerOpus = new ClaudeCodeHandler(options)
mockGetAccessToken.mockResolvedValue("test-access-token")
mockGetEmail.mockResolvedValue("test@example.com")
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
yield { type: "text", text: "Response" }
}
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
await handlerOpus.completePrompt("Test prompt")
expect(mockCreateStreamingMessage).toHaveBeenCalledWith(
expect.objectContaining({
model: "claude-opus-4-5",
maxTokens: 32768, // opus model maxTokens
}),
)
})
})
})

View file

@ -1,389 +0,0 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import {
claudeCodeDefaultModelId,
type ClaudeCodeModelId,
claudeCodeModels,
claudeCodeReasoningConfig,
type ClaudeCodeReasoningLevel,
type ModelInfo,
} from "@roo-code/types"
import { type ApiHandler, ApiHandlerCreateMessageMetadata, type SingleCompletionHandler } from ".."
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
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, SingleCompletionHandler {
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,
): ApiStream {
// Reset per-request state that we persist into apiConversationHistory
this.lastThinkingSignature = undefined
const buildNotAuthenticatedError = () =>
new Error(
t("common:errors.claudeCode.notAuthenticated", {
defaultValue:
"Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.",
}),
)
async function* streamOnce(this: ClaudeCodeHandler, accessToken: string): ApiStream {
// Get user email for generating user_id metadata
const email = await claudeCodeOAuthManager.getEmail()
const model = this.getModel()
// Validate that the model ID is a valid ClaudeCodeModelId
const modelId = Object.hasOwn(claudeCodeModels, model.id)
? (model.id as ClaudeCodeModelId)
: claudeCodeDefaultModelId
// Generate user_id metadata in the format required by Claude Code API
const userId = generateUserId(email || undefined)
// 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" }
}
// Create streaming request using OAuth
const stream = createStreamingMessage({
accessToken,
model: modelId,
systemPrompt,
messages,
maxTokens,
thinking,
tools: anthropicTools,
toolChoice: anthropicToolChoice,
metadata: {
user_id: userId,
},
})
// Track usage for cost calculation
let inputTokens = 0
let outputTokens = 0
let cacheReadTokens = 0
let cacheWriteTokens = 0
for await (const chunk of stream) {
switch (chunk.type) {
case "text":
yield {
type: "text",
text: chunk.text,
}
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
}
case "error":
throw new Error(chunk.error)
}
}
}
// Get access token from OAuth manager
let accessToken = await claudeCodeOAuthManager.getAccessToken()
if (!accessToken) {
throw buildNotAuthenticatedError()
}
// Try the request with at most one force-refresh retry on auth failure
for (let attempt = 0; attempt < 2; attempt++) {
try {
yield* streamOnce.call(this, accessToken)
return
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const isAuthFailure = /unauthorized|invalid token|not authenticated|authentication/i.test(message)
// Only retry on auth failure during first attempt
const canRetry = attempt === 0 && isAuthFailure
if (!canRetry) {
throw error
}
// Force refresh the token for retry
const refreshed = await claudeCodeOAuthManager.forceRefreshAccessToken()
if (!refreshed) {
throw buildNotAuthenticatedError()
}
accessToken = refreshed
}
}
// Unreachable: loop always returns on success or throws on failure
throw buildNotAuthenticatedError()
}
getModel(): { id: string; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && Object.hasOwn(claudeCodeModels, modelId)) {
const id = modelId as ClaudeCodeModelId
return { id, info: { ...claudeCodeModels[id] } }
}
return {
id: claudeCodeDefaultModelId,
info: { ...claudeCodeModels[claudeCodeDefaultModelId] },
}
}
async countTokens(content: Anthropic.Messages.ContentBlockParam[]): Promise<number> {
if (content.length === 0) {
return 0
}
return countTokens(content, { useWorker: true })
}
/**
* Completes a prompt using the Claude Code API.
* This is used for context condensing and prompt enhancement.
* The Claude Code branding is automatically prepended by createStreamingMessage.
*/
async completePrompt(prompt: string): Promise<string> {
// 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 model = this.getModel()
// Validate that the model ID is a valid ClaudeCodeModelId
const modelId = Object.hasOwn(claudeCodeModels, model.id)
? (model.id as ClaudeCodeModelId)
: claudeCodeDefaultModelId
// Generate user_id metadata in the format required by Claude Code API
const userId = generateUserId(email || undefined)
// Use maxTokens from model info for completion
const maxTokens = model.info.maxTokens ?? 16384
// Create streaming request using OAuth
// The system prompt is empty here since the prompt itself contains all context
// createStreamingMessage will still prepend the Claude Code branding
const stream = createStreamingMessage({
accessToken,
model: modelId,
systemPrompt: "", // Empty system prompt - the prompt text contains all necessary context
messages: [{ role: "user", content: prompt }],
maxTokens,
thinking: { type: "disabled" }, // No thinking for simple completions
metadata: {
user_id: userId,
},
})
// Collect all text chunks into a single response
let result = ""
for await (const chunk of stream) {
switch (chunk.type) {
case "text":
result += chunk.text
break
case "error":
throw new Error(chunk.error)
}
}
return result
}
}

View file

@ -3,7 +3,6 @@ export { AnthropicHandler } from "./anthropic"
export { AwsBedrockHandler } from "./bedrock"
export { CerebrasHandler } from "./cerebras"
export { ChutesHandler } from "./chutes"
export { ClaudeCodeHandler } from "./claude-code"
export { DeepSeekHandler } from "./deepseek"
export { DoubaoHandler } from "./doubao"
export { MoonshotHandler } from "./moonshot"

View file

@ -316,26 +316,13 @@ export class ContextProxy {
* This prevents schema validation errors for removed providers.
*/
private sanitizeProviderValues(values: RooCodeSettings): RooCodeSettings {
// Remove legacy Claude Code CLI wrapper keys that may still exist in global state.
// These keys were used by a removed local CLI runner and are no longer part of ProviderSettings.
const legacyKeys = ["claudeCodePath", "claudeCodeMaxOutputTokens"] as const
let sanitizedValues = values
for (const key of legacyKeys) {
if (key in sanitizedValues) {
const copy = { ...sanitizedValues } as Record<string, unknown>
delete copy[key as string]
sanitizedValues = copy as RooCodeSettings
}
}
if (values.apiProvider !== undefined && !isProviderName(values.apiProvider)) {
logger.info(`[ContextProxy] Sanitizing invalid provider "${values.apiProvider}" - resetting to undefined`)
// Return a new values object without the invalid apiProvider
const { apiProvider, ...restValues } = sanitizedValues
const { apiProvider, ...restValues } = values
return restValues as RooCodeSettings
}
return sanitizedValues
return values
}
public async setProviderSettings(values: ProviderSettings) {

View file

@ -47,7 +47,6 @@ export const providerProfilesSchema = z.object({
openAiHeadersMigrated: z.boolean().optional(),
consecutiveMistakeLimitMigrated: z.boolean().optional(),
todoListEnabledMigrated: z.boolean().optional(),
claudeCodeLegacySettingsMigrated: z.boolean().optional(),
})
.optional(),
})
@ -72,7 +71,6 @@ export class ProviderSettingsManager {
openAiHeadersMigrated: true, // Mark as migrated on fresh installs
consecutiveMistakeLimitMigrated: true, // Mark as migrated on fresh installs
todoListEnabledMigrated: true, // Mark as migrated on fresh installs
claudeCodeLegacySettingsMigrated: true, // Mark as migrated on fresh installs
},
}
@ -145,7 +143,6 @@ export class ProviderSettingsManager {
openAiHeadersMigrated: false,
consecutiveMistakeLimitMigrated: false,
todoListEnabledMigrated: false,
claudeCodeLegacySettingsMigrated: false,
} // Initialize with default values
isDirty = true
}
@ -180,26 +177,6 @@ export class ProviderSettingsManager {
isDirty = true
}
if (!providerProfiles.migrations.claudeCodeLegacySettingsMigrated) {
// These keys were used by the removed local Claude Code CLI wrapper.
for (const apiConfig of Object.values(providerProfiles.apiConfigs)) {
if (apiConfig.apiProvider !== "claude-code") continue
const config = apiConfig as unknown as Record<string, unknown>
if ("claudeCodePath" in config) {
delete config.claudeCodePath
isDirty = true
}
if ("claudeCodeMaxOutputTokens" in config) {
delete config.claudeCodeMaxOutputTokens
isDirty = true
}
}
providerProfiles.migrations.claudeCodeLegacySettingsMigrated = true
isDirty = true
}
if (isDirty) {
await this.store(providerProfiles)
}

View file

@ -68,9 +68,9 @@ vi.mock("../../../api", () => ({
buildApiHandler: vi.fn().mockImplementation((config) => {
// Return different model info based on the provider and model
const getModelInfo = () => {
if (config.apiProvider === "claude-code") {
if (config.apiProvider === "qwen-code") {
return {
id: config.apiModelId || "claude-sonnet-4-5",
id: config.apiModelId || "qwen3-qwq-32b",
info: {
supportsReasoningBudget: false,
requiredReasoningBudget: false,
@ -483,18 +483,18 @@ describe("importExport", () => {
it("should handle import when reasoning budget fields are missing from config", async () => {
// This test verifies that import works correctly when reasoning budget fields are not present
// Using claude-code provider which doesn't support reasoning budgets
// Using qwen-code provider which doesn't support reasoning budgets
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
const mockFileContent = JSON.stringify({
providerProfiles: {
currentApiConfigName: "claude-code-provider",
currentApiConfigName: "qwen-code-provider",
apiConfigs: {
"claude-code-provider": {
apiProvider: "claude-code" as ProviderName,
apiModelId: "claude-3-5-sonnet-20241022",
id: "claude-code-id",
"qwen-code-provider": {
apiProvider: "qwen-code" as ProviderName,
apiModelId: "qwen3-qwq-32b",
id: "qwen-code-id",
apiKey: "test-key",
// No modelMaxTokens or modelMaxThinkingTokens fields
},
@ -512,7 +512,7 @@ describe("importExport", () => {
mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles)
mockProviderSettingsManager.listConfig.mockResolvedValue([
{ name: "claude-code-provider", id: "claude-code-id", apiProvider: "claude-code" as ProviderName },
{ name: "qwen-code-provider", id: "qwen-code-id", apiProvider: "qwen-code" as ProviderName },
{ name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName },
])
@ -529,21 +529,21 @@ describe("importExport", () => {
expect(mockProviderSettingsManager.export).toHaveBeenCalled()
expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({
currentApiConfigName: "claude-code-provider",
currentApiConfigName: "qwen-code-provider",
apiConfigs: {
default: { apiProvider: "anthropic" as ProviderName, id: "default-id" },
"claude-code-provider": {
apiProvider: "claude-code" as ProviderName,
apiModelId: "claude-3-5-sonnet-20241022",
"qwen-code-provider": {
apiProvider: "qwen-code" as ProviderName,
apiModelId: "qwen3-qwq-32b",
apiKey: "test-key",
id: "claude-code-id",
id: "qwen-code-id",
},
},
modeApiConfigs: {},
})
expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true })
expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "claude-code-provider")
expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "qwen-code-provider")
})
})
@ -1721,27 +1721,27 @@ describe("importExport", () => {
it.each([
{
testCase: "supportsReasoningBudget is false",
providerName: "claude-code-provider",
modelId: "claude-sonnet-4-5",
providerId: "claude-code-id",
providerName: "qwen-code-provider",
modelId: "qwen3-qwq-32b",
providerId: "qwen-code-id",
},
{
testCase: "requiredReasoningBudget is false",
providerName: "claude-code-provider-2",
modelId: "claude-sonnet-4-5",
providerId: "claude-code-id-2",
providerName: "qwen-code-provider-2",
modelId: "qwen3-qwq-32b",
providerId: "qwen-code-id-2",
},
{
testCase: "both supportsReasoningBudget and requiredReasoningBudget are false",
providerName: "claude-code-provider-3",
modelId: "claude-3-5-haiku-20241022",
providerId: "claude-code-id-3",
providerName: "qwen-code-provider-3",
modelId: "qwen3-qwq-32b",
providerId: "qwen-code-id-3",
},
])(
"should exclude modelMaxTokens and modelMaxThinkingTokens when $testCase",
async ({ providerName, modelId, providerId }) => {
// This test verifies that token fields are excluded when model doesn't support reasoning budget
// Using claude-code provider which has supportsReasoningBudget: false and requiredReasoningBudget: false
// Using qwen-code provider which has supportsReasoningBudget: false and requiredReasoningBudget: false
;(vscode.window.showSaveDialog as Mock).mockResolvedValue({
fsPath: "/mock/path/roo-code-settings.json",
@ -1753,9 +1753,9 @@ describe("importExport", () => {
// Wait for initialization to complete
await realProviderSettingsManager.initialize()
// Save a claude-code provider config with token fields
// Save a qwen-code provider config with token fields
await realProviderSettingsManager.saveConfig(providerName, {
apiProvider: "claude-code" as ProviderName,
apiProvider: "qwen-code" as ProviderName,
apiModelId: modelId,
id: providerId,
apiKey: "test-key",

View file

@ -2157,14 +2157,6 @@ export class ClineProvider
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
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

@ -2340,45 +2340,6 @@ 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) {
@ -3160,37 +3121,6 @@ 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

@ -27,7 +27,6 @@ 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"
@ -101,9 +100,6 @@ 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, (message) => outputChannel.appendLine(message))
// Get default commands from configuration.
const defaultCommands = vscode.workspace.getConfiguration(Package.name).get<string[]>("allowedCommands") || []

View file

@ -1,235 +0,0 @@
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)
})
})
describe("refresh token behavior", () => {
afterEach(() => {
vi.unstubAllGlobals()
})
test("refresh responses may omit refresh_token (should be tolerated)", async () => {
const { refreshAccessToken } = await import("../oauth")
// Mock fetch to return a refresh response with no refresh_token
const mockFetch = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
access_token: "new-access",
expires_in: 3600,
// refresh_token intentionally omitted
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
)
vi.stubGlobal("fetch", mockFetch)
const creds: ClaudeCodeCredentials = {
type: "claude" as const,
access_token: "old-access",
refresh_token: "old-refresh",
expired: new Date(Date.now() - 1000).toISOString(),
email: "test@example.com",
}
const refreshed = await refreshAccessToken(creds)
expect(refreshed.access_token).toBe("new-access")
expect(refreshed.refresh_token).toBe("old-refresh")
expect(refreshed.email).toBe("test@example.com")
})
})
})

View file

@ -1,585 +0,0 @@
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).toMatch(/^Roo-Code\/\d+\.\d+\.\d+$/)
})
})
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",
"User-Agent": CLAUDE_CODE_API_CONFIG.userAgent,
}),
}),
)
})
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",
})
})
// Dropped: conversion of internal `reasoning` + `thoughtSignature` blocks into
// Anthropic `thinking` blocks. The Claude Code integration now relies on the
// Anthropic-native `thinking` block format persisted by Task.
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,638 +0,0 @@
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 responses may omit refresh_token (common OAuth behavior). When omitted,
// callers must preserve the existing refresh token.
refresh_token: z.string().min(1).optional(),
expires_in: z.number(),
email: z.string().optional(),
token_type: z.string().optional(),
})
class ClaudeCodeOAuthTokenError extends Error {
public readonly status?: number
public readonly errorCode?: string
constructor(message: string, opts?: { status?: number; errorCode?: string }) {
super(message)
this.name = "ClaudeCodeOAuthTokenError"
this.status = opts?.status
this.errorCode = opts?.errorCode
}
public isLikelyInvalidGrant(): boolean {
if (this.errorCode && /invalid_grant/i.test(this.errorCode)) {
return true
}
if (this.status === 400 || this.status === 401 || this.status === 403) {
return /invalid_grant|revoked|expired|invalid refresh/i.test(this.message)
}
return false
}
}
function parseOAuthErrorDetails(errorText: string): { errorCode?: string; errorMessage?: string } {
try {
const json: unknown = JSON.parse(errorText)
if (!json || typeof json !== "object") {
return {}
}
const obj = json as Record<string, unknown>
const errorField = obj.error
const errorCode: string | undefined =
typeof errorField === "string"
? errorField
: errorField &&
typeof errorField === "object" &&
typeof (errorField as Record<string, unknown>).type === "string"
? ((errorField as Record<string, unknown>).type as string)
: undefined
const errorDescription = obj.error_description
const errorMessageFromError =
errorField && typeof errorField === "object" ? (errorField as Record<string, unknown>).message : undefined
const errorMessage: string | undefined =
typeof errorDescription === "string"
? errorDescription
: typeof errorMessageFromError === "string"
? errorMessageFromError
: typeof obj.message === "string"
? obj.message
: undefined
return { errorCode, errorMessage }
} catch {
return {}
}
}
/**
* 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)
if (!tokenResponse.refresh_token) {
// The access token is unusable without a refresh token for persistence.
throw new Error("Token exchange did not return a refresh_token")
}
// 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(credentials: ClaudeCodeCredentials): Promise<ClaudeCodeCredentials> {
const body = {
grant_type: "refresh_token",
client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId,
refresh_token: credentials.refresh_token,
}
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()
const { errorCode, errorMessage } = parseOAuthErrorDetails(errorText)
const details = errorMessage ? errorMessage : errorText
throw new ClaudeCodeOAuthTokenError(
`Token refresh failed: ${response.status} ${response.statusText}${details ? ` - ${details}` : ""}`,
{ status: response.status, errorCode },
)
}
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 ?? credentials.refresh_token,
expired: expiresAt.toISOString(),
email: tokenResponse.email ?? credentials.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 logFn: ((message: string) => void) | null = null
private refreshPromise: Promise<ClaudeCodeCredentials> | null = null
private pendingAuth: {
codeVerifier: string
state: string
server?: http.Server
} | null = null
private log(message: string): void {
if (this.logFn) {
this.logFn(message)
} else {
console.log(message)
}
}
private logError(message: string, error?: unknown): void {
const details = error instanceof Error ? error.message : error !== undefined ? String(error) : undefined
const full = details ? `${message} ${details}` : message
this.log(full)
console.error(full)
}
/**
* Initialize the OAuth manager with VS Code extension context
*/
initialize(context: ExtensionContext, logFn?: (message: string) => void): void {
this.context = context
this.logFn = logFn ?? null
}
/**
* Force a refresh using the stored refresh token even if the access token is not expired.
* Useful when the server invalidates an access token early.
*/
async forceRefreshAccessToken(): Promise<string | null> {
if (!this.credentials) {
await this.loadCredentials()
}
if (!this.credentials) {
return null
}
try {
// De-dupe concurrent refreshes
if (!this.refreshPromise) {
const prevRefreshToken = this.credentials.refresh_token
this.log(`[claude-code-oauth] Forcing token refresh (expired=${this.credentials.expired})...`)
this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => {
const rotated = newCreds.refresh_token !== prevRefreshToken
this.log(
`[claude-code-oauth] Forced refresh response received (expires_in≈${Math.round(
(new Date(newCreds.expired).getTime() - Date.now()) / 1000,
)}s, refresh_token_rotated=${rotated})`,
)
return newCreds
})
}
const newCredentials = await this.refreshPromise
this.refreshPromise = null
await this.saveCredentials(newCredentials)
this.log(`[claude-code-oauth] Forced token persisted (expired=${newCredentials.expired})`)
return newCredentials.access_token
} catch (error) {
this.refreshPromise = null
this.logError("[claude-code-oauth] Failed to force refresh token:", error)
if (error instanceof ClaudeCodeOAuthTokenError && error.isLikelyInvalidGrant()) {
this.log("[claude-code-oauth] Refresh token appears invalid; clearing stored credentials")
await this.clearCredentials()
}
return null
}
}
/**
* 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) {
this.logError("[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 {
// De-dupe concurrent refreshes
if (!this.refreshPromise) {
this.log(
`[claude-code-oauth] Access token expired (expired=${this.credentials.expired}). Refreshing...`,
)
const prevRefreshToken = this.credentials.refresh_token
this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => {
const rotated = newCreds.refresh_token !== prevRefreshToken
this.log(
`[claude-code-oauth] Refresh response received (expires_in≈${Math.round(
(new Date(newCreds.expired).getTime() - Date.now()) / 1000,
)}s, refresh_token_rotated=${rotated})`,
)
return newCreds
})
}
const newCredentials = await this.refreshPromise
this.refreshPromise = null
await this.saveCredentials(newCredentials)
this.log(`[claude-code-oauth] Token persisted (expired=${newCredentials.expired})`)
} catch (error) {
this.refreshPromise = null
this.logError("[claude-code-oauth] Failed to refresh token:", error)
// Only clear secrets when the refresh token is clearly invalid/revoked.
if (error instanceof ClaudeCodeOAuthTokenError && error.isLikelyInvalidGrant()) {
this.log("[claude-code-oauth] Refresh token appears invalid; clearing stored 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

@ -1,759 +0,0 @@
import type { Anthropic } from "@anthropic-ai/sdk"
import type { ClaudeCodeRateLimitInfo } from "@roo-code/types"
import { Package } from "../../shared/package"
/**
* 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",
])
type ContentBlockWithType = { type: string }
/**
* Filters out non-Anthropic content blocks from messages before sending to the API.
*
* NOTE: This function performs FILTERING ONLY - no type conversion is performed.
* Blocks are either kept as-is or removed entirely based on the allowlist.
*
* 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) - NOT converted to "thinking"
* - Gemini's "thoughtSignature" blocks
* - 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 (Anthropic's native extended thinking format) 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
}
// Filter out invalid block types (allowlist)
const filteredContent = content.filter((block) =>
VALID_ANTHROPIC_BLOCK_TYPES.has((block as ContentBlockWithType).type),
)
// If all content was filtered out, skip this message
if (filteredContent.length === 0) {
continue
}
// Return a clean message with only role and content (no extra fields)
result.push({
role,
content: filteredContent,
})
}
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: `Roo-Code/${Package.version}`,
} as const
/**
* 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 minimal headers
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",
"User-Agent": CLAUDE_CODE_API_CONFIG.userAgent,
}
// 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 minimal headers
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,
}
// 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

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

View file

@ -126,8 +126,6 @@ export interface WebviewMessage {
| "cloudLandingPageSignIn"
| "rooCloudSignOut"
| "rooCloudManualUrl"
| "claudeCodeSignIn"
| "claudeCodeSignOut"
| "switchOrganization"
| "condenseTaskContextRequest"
| "requestIndexingStatus"
@ -178,7 +176,6 @@ export interface WebviewMessage {
| "openDebugApiHistory"
| "openDebugUiHistory"
| "downloadErrorDiagnostics"
| "requestClaudeCodeRateLimits"
| "refreshCustomTools"
text?: string
editedMessageContent?: string

View file

@ -9,7 +9,7 @@ describe("getModelMaxOutputTokens", () => {
supportsPromptCache: true,
}
test("should return model maxTokens when not using claude-code provider and maxTokens is within 20% of context window", () => {
test("should return model maxTokens when maxTokens is within 20% of context window", () => {
const settings: ProviderSettings = {
apiProvider: "anthropic",
}

View file

@ -5,8 +5,8 @@ export function checkExistKey(config: ProviderSettings | undefined) {
return false
}
// Special case for fake-ai, claude-code, qwen-code, and roo providers which don't need any configuration.
if (config.apiProvider && ["fake-ai", "claude-code", "qwen-code", "roo"].includes(config.apiProvider)) {
// Special case for fake-ai, qwen-code, and roo providers which don't need any configuration.
if (config.apiProvider && ["fake-ai", "qwen-code", "roo"].includes(config.apiProvider)) {
return true
}

View file

@ -1104,36 +1104,30 @@ export const ChatRowContent = ({
let body = t(`chat:apiRequest.failed`)
let retryInfo, rawError, code, docsURL
if (message.text !== undefined) {
// Check for Claude Code authentication error first
if (message.text.includes("Not authenticated with Claude Code")) {
body = t("chat:apiRequest.errorMessage.claudeCodeNotAuthenticated")
docsURL = "roocode://settings?provider=claude-code"
} else {
// Try to show richer error message for that code, if available
const potentialCode = parseInt(message.text.substring(0, 3))
if (!isNaN(potentialCode) && potentialCode >= 400) {
code = potentialCode
const stringForError = `chat:apiRequest.errorMessage.${code}`
if (i18n.exists(stringForError)) {
body = t(stringForError)
// Fill this out in upcoming PRs
// Do not remove this
// switch(code) {
// case ERROR_CODE:
// docsURL = ???
// break;
// }
} else {
body = t("chat:apiRequest.errorMessage.unknown")
docsURL = "mailto:support@roocode.com?subject=Unknown API Error"
}
} else if (message.text.indexOf("Connection error") === 0) {
body = t("chat:apiRequest.errorMessage.connection")
// Try to show richer error message for that code, if available
const potentialCode = parseInt(message.text.substring(0, 3))
if (!isNaN(potentialCode) && potentialCode >= 400) {
code = potentialCode
const stringForError = `chat:apiRequest.errorMessage.${code}`
if (i18n.exists(stringForError)) {
body = t(stringForError)
// Fill this out in upcoming PRs
// Do not remove this
// switch(code) {
// case ERROR_CODE:
// docsURL = ???
// break;
// }
} else {
// Non-HTTP-status-code error message - store full text as errorDetails
body = t("chat:apiRequest.errorMessage.unknown")
docsURL = "mailto:support@roocode.com?subject=Unknown API Error"
}
} else if (message.text.indexOf("Connection error") === 0) {
body = t("chat:apiRequest.errorMessage.connection")
} else {
// Non-HTTP-status-code error message - store full text as errorDetails
body = t("chat:apiRequest.errorMessage.unknown")
docsURL = "mailto:support@roocode.com?subject=Unknown API Error"
}
// This isn't pretty, but since the retry logic happens at a lower level

View file

@ -15,7 +15,6 @@ import {
openAiNativeDefaultModelId,
anthropicDefaultModelId,
doubaoDefaultModelId,
claudeCodeDefaultModelId,
qwenCodeDefaultModelId,
geminiDefaultModelId,
deepSeekDefaultModelId,
@ -69,7 +68,6 @@ import {
Bedrock,
Cerebras,
Chutes,
ClaudeCode,
DeepSeek,
Doubao,
Gemini,
@ -138,7 +136,7 @@ const ApiOptions = ({
setErrorMessage,
}: ApiOptionsProps) => {
const { t } = useAppTranslation()
const { organizationAllowList, cloudIsAuthenticated, claudeCodeIsAuthenticated } = useExtensionState()
const { organizationAllowList, cloudIsAuthenticated } = useExtensionState()
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
const headers = apiConfiguration?.openAiHeaders || {}
@ -341,7 +339,6 @@ const ApiOptions = ({
litellm: { field: "litellmModelId", default: litellmDefaultModelId },
anthropic: { field: "apiModelId", default: anthropicDefaultModelId },
cerebras: { field: "apiModelId", default: cerebrasDefaultModelId },
"claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId },
"qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId },
"openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId },
gemini: { field: "apiModelId", default: geminiDefaultModelId },
@ -554,15 +551,6 @@ const ApiOptions = ({
/>
)}
{selectedProvider === "claude-code" && (
<ClaudeCode
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
simplifySettings={fromWelcomeView}
claudeCodeIsAuthenticated={claudeCodeIsAuthenticated}
/>
)}
{selectedProvider === "openai-native" && (
<OpenAI
apiConfiguration={apiConfiguration}
@ -761,8 +749,7 @@ const ApiOptions = ({
<Featherless apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
)}
{/* Skip generic model picker for claude-code since it has its own in ClaudeCode.tsx */}
{selectedProviderModels.length > 0 && selectedProvider !== "claude-code" && (
{selectedProviderModels.length > 0 && (
<>
<div>
<label className="block font-medium mb-1">{t("settings:providers.model")}</label>

View file

@ -4,7 +4,6 @@ import {
anthropicModels,
bedrockModels,
cerebrasModels,
claudeCodeModels,
deepSeekModels,
moonshotModels,
geminiModels,
@ -25,7 +24,6 @@ import {
export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, ModelInfo>>> = {
anthropic: anthropicModels,
"claude-code": claudeCodeModels,
bedrock: bedrockModels,
cerebras: cerebrasModels,
deepseek: deepSeekModels,
@ -50,7 +48,6 @@ export const PROVIDERS = [
{ value: "openrouter", label: "OpenRouter" },
{ value: "deepinfra", label: "DeepInfra" },
{ value: "anthropic", label: "Anthropic" },
{ value: "claude-code", label: "Claude Code" },
{ value: "cerebras", label: "Cerebras" },
{ value: "gemini", label: "Google Gemini" },
{ value: "doubao", label: "Doubao" },

View file

@ -1,68 +0,0 @@
import React from "react"
import { type ProviderSettings, claudeCodeDefaultModelId, claudeCodeModels } from "@roo-code/types"
import { useAppTranslation } from "@src/i18n/TranslationContext"
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,
simplifySettings,
claudeCodeIsAuthenticated = false,
}) => {
const { t } = useAppTranslation()
return (
<div className="flex flex-col gap-4">
{/* 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>
{/* 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

@ -1,181 +0,0 @@
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

@ -2,7 +2,6 @@ export { Anthropic } from "./Anthropic"
export { Bedrock } from "./Bedrock"
export { Cerebras } from "./Cerebras"
export { Chutes } from "./Chutes"
export { ClaudeCode } from "./ClaudeCode"
export { DeepSeek } from "./DeepSeek"
export { Doubao } from "./Doubao"
export { Gemini } from "./Gemini"

View file

@ -412,77 +412,6 @@ describe("useSelectedModel", () => {
})
})
describe("claude-code provider", () => {
it("should return claude-code model with correct model info", () => {
mockUseRouterModels.mockReturnValue({
data: {
openrouter: {},
requesty: {},
unbound: {},
litellm: {},
"io-intelligence": {},
},
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: {},
isLoading: false,
isError: false,
} as any)
const apiConfiguration: ProviderSettings = {
apiProvider: "claude-code",
apiModelId: "claude-sonnet-4-5", // Use valid claude-code model ID
}
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
expect(result.current.provider).toBe("claude-code")
expect(result.current.id).toBe("claude-sonnet-4-5")
expect(result.current.info).toBeDefined()
expect(result.current.info?.supportsImages).toBe(true) // Claude Code now supports images
expect(result.current.info?.supportsPromptCache).toBe(true) // Claude Code now supports prompt cache
// Verify it inherits other properties from claude-code models
expect(result.current.info?.maxTokens).toBe(32768)
expect(result.current.info?.contextWindow).toBe(200_000)
})
it("should use default claude-code model when no modelId is specified", () => {
mockUseRouterModels.mockReturnValue({
data: {
openrouter: {},
requesty: {},
unbound: {},
litellm: {},
"io-intelligence": {},
},
isLoading: false,
isError: false,
} as any)
mockUseOpenRouterModelProviders.mockReturnValue({
data: {},
isLoading: false,
isError: false,
} as any)
const apiConfiguration: ProviderSettings = {
apiProvider: "claude-code",
}
const wrapper = createWrapper()
const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper })
expect(result.current.provider).toBe("claude-code")
expect(result.current.id).toBe("claude-sonnet-4-5") // Default model
expect(result.current.info).toBeDefined()
expect(result.current.info?.supportsImages).toBe(true) // Claude Code now supports images
})
})
describe("bedrock provider with 1M context", () => {
beforeEach(() => {
mockUseRouterModels.mockReturnValue({

View file

@ -17,8 +17,6 @@ import {
groqModels,
vscodeLlmModels,
vscodeLlmDefaultModelId,
claudeCodeModels,
normalizeClaudeCodeModelId,
sambaNovaModels,
doubaoModels,
internationalZAiModels,
@ -333,14 +331,6 @@ function getSelectedModel({
const info = vscodeLlmModels[modelFamily as keyof typeof vscodeLlmModels]
return { id, info: { ...openAiModelInfoSaneDefaults, ...info, supportsImages: false } } // VSCode LM API currently doesn't support images.
}
case "claude-code": {
// Claude Code models extend anthropic models but with images and prompt caching disabled
// Normalize legacy model IDs to current canonical model IDs for backward compatibility
const rawId = apiConfiguration.apiModelId ?? defaultModelId
const normalizedId = normalizeClaudeCodeModelId(rawId)
const info = claudeCodeModels[normalizedId]
return { id: normalizedId, info: { ...openAiModelInfoSaneDefaults, ...info } }
}
case "cerebras": {
const id = apiConfiguration.apiModelId ?? defaultModelId
const info = cerebrasModels[id as keyof typeof cerebrasModels]