mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
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:
parent
907b94bc40
commit
67b5d3448b
32 changed files with 55 additions and 4130 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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") || []
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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>✓ 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()
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue