mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-05 08:10:14 +00:00
fix: Claude Code Opus support
This commit is contained in:
parent
a3b258ad62
commit
537ef5424a
23 changed files with 3286 additions and 945 deletions
|
|
@ -23,18 +23,20 @@ describe("convertModelNameForVertex", () => {
|
|||
|
||||
describe("getClaudeCodeModelId", () => {
|
||||
test("should return original model when useVertex is false", () => {
|
||||
expect(getClaudeCodeModelId("claude-sonnet-4-20250514", false)).toBe("claude-sonnet-4-20250514")
|
||||
expect(getClaudeCodeModelId("claude-opus-4-20250514", false)).toBe("claude-opus-4-20250514")
|
||||
expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", false)).toBe("claude-3-7-sonnet-20250219")
|
||||
// Use valid ClaudeCodeModelId values - they don't have date suffixes
|
||||
expect(getClaudeCodeModelId("claude-sonnet-4-5", false)).toBe("claude-sonnet-4-5")
|
||||
expect(getClaudeCodeModelId("claude-opus-4-5", false)).toBe("claude-opus-4-5")
|
||||
expect(getClaudeCodeModelId("claude-haiku-4-5", false)).toBe("claude-haiku-4-5")
|
||||
})
|
||||
|
||||
test("should return converted model when useVertex is true", () => {
|
||||
expect(getClaudeCodeModelId("claude-sonnet-4-20250514", true)).toBe("claude-sonnet-4@20250514")
|
||||
expect(getClaudeCodeModelId("claude-opus-4-20250514", true)).toBe("claude-opus-4@20250514")
|
||||
expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", true)).toBe("claude-3-7-sonnet@20250219")
|
||||
test("should return same model when useVertex is true (no date suffix to convert)", () => {
|
||||
// Valid ClaudeCodeModelIds don't have 8-digit date suffixes, so no conversion happens
|
||||
expect(getClaudeCodeModelId("claude-sonnet-4-5", true)).toBe("claude-sonnet-4-5")
|
||||
expect(getClaudeCodeModelId("claude-opus-4-5", true)).toBe("claude-opus-4-5")
|
||||
expect(getClaudeCodeModelId("claude-haiku-4-5", true)).toBe("claude-haiku-4-5")
|
||||
})
|
||||
|
||||
test("should default to useVertex false when parameter not provided", () => {
|
||||
expect(getClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-20250514")
|
||||
expect(getClaudeCodeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,5 +1,41 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
import { anthropicModels } from "./anthropic.js"
|
||||
|
||||
/**
|
||||
* Rate limit information from Claude Code API
|
||||
*/
|
||||
export interface ClaudeCodeRateLimitInfo {
|
||||
// 5-hour limit info
|
||||
fiveHour: {
|
||||
status: string
|
||||
utilization: number
|
||||
resetTime: number // Unix timestamp
|
||||
}
|
||||
// 7-day (weekly) limit info (Sonnet-specific)
|
||||
weekly?: {
|
||||
status: string
|
||||
utilization: number
|
||||
resetTime: number // Unix timestamp
|
||||
}
|
||||
// 7-day unified limit info
|
||||
weeklyUnified?: {
|
||||
status: string
|
||||
utilization: number
|
||||
resetTime: number // Unix timestamp
|
||||
}
|
||||
// Representative claim type
|
||||
representativeClaim?: string
|
||||
// Overage status
|
||||
overage?: {
|
||||
status: string
|
||||
disabledReason?: string
|
||||
}
|
||||
// Fallback percentage
|
||||
fallbackPercentage?: number
|
||||
// Organization ID
|
||||
organizationId?: string
|
||||
// Timestamp when this was fetched
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
// Regex pattern to match 8-digit date at the end of model names
|
||||
const VERTEX_DATE_PATTERN = /-(\d{8})$/
|
||||
|
|
@ -19,11 +55,29 @@ export function convertModelNameForVertex(modelName: string): string {
|
|||
return modelName.replace(VERTEX_DATE_PATTERN, "@$1")
|
||||
}
|
||||
|
||||
// Claude Code
|
||||
// Claude Code - Only models that work with Claude Code OAuth tokens
|
||||
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
|
||||
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5"
|
||||
export const CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS = 16000
|
||||
|
||||
/**
|
||||
* Reasoning effort configuration for Claude Code thinking mode.
|
||||
* Maps reasoning effort level to budget_tokens for the thinking process.
|
||||
*
|
||||
* Note: With interleaved thinking (enabled via beta header), budget_tokens
|
||||
* can exceed max_tokens as the token limit becomes the entire context window.
|
||||
* The max_tokens is drawn from the model's maxTokens definition.
|
||||
*
|
||||
* @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking
|
||||
*/
|
||||
export const claudeCodeReasoningConfig = {
|
||||
low: { budgetTokens: 16_000 },
|
||||
medium: { budgetTokens: 32_000 },
|
||||
high: { budgetTokens: 64_000 },
|
||||
} as const
|
||||
|
||||
export type ClaudeCodeReasoningLevel = keyof typeof claudeCodeReasoningConfig
|
||||
|
||||
/**
|
||||
* Gets the appropriate model ID based on whether Vertex AI is being used.
|
||||
*
|
||||
|
|
@ -39,116 +93,41 @@ export function getClaudeCodeModelId(baseModelId: ClaudeCodeModelId, useVertex =
|
|||
return useVertex ? convertModelNameForVertex(baseModelId) : baseModelId
|
||||
}
|
||||
|
||||
// Models that work with Claude Code OAuth tokens
|
||||
// See: https://docs.anthropic.com/en/docs/claude-code
|
||||
// NOTE: Claude Code is subscription-based with no per-token cost - pricing fields are 0
|
||||
export const claudeCodeModels = {
|
||||
"claude-haiku-4-5": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsNativeTools: true,
|
||||
defaultToolProtocol: "native",
|
||||
supportsReasoningEffort: ["disable", "low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
description: "Claude Haiku 4.5 - Fast and efficient with thinking",
|
||||
},
|
||||
"claude-sonnet-4-5": {
|
||||
...anthropicModels["claude-sonnet-4-5"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
maxTokens: 32768,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsNativeTools: true,
|
||||
defaultToolProtocol: "native",
|
||||
supportsReasoningEffort: ["disable", "low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
description: "Claude Sonnet 4.5 - Balanced performance with thinking",
|
||||
},
|
||||
"claude-sonnet-4-5-20250929[1m]": {
|
||||
...anthropicModels["claude-sonnet-4-5"],
|
||||
contextWindow: 1_000_000, // 1M token context window (requires [1m] suffix)
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"claude-sonnet-4-20250514": {
|
||||
...anthropicModels["claude-sonnet-4-20250514"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"claude-opus-4-5-20251101": {
|
||||
...anthropicModels["claude-opus-4-5-20251101"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"claude-opus-4-1-20250805": {
|
||||
...anthropicModels["claude-opus-4-1-20250805"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"claude-opus-4-20250514": {
|
||||
...anthropicModels["claude-opus-4-20250514"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"claude-3-7-sonnet-20250219": {
|
||||
...anthropicModels["claude-3-7-sonnet-20250219"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"claude-3-5-sonnet-20241022": {
|
||||
...anthropicModels["claude-3-5-sonnet-20241022"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"claude-3-5-haiku-20241022": {
|
||||
...anthropicModels["claude-3-5-haiku-20241022"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
},
|
||||
"claude-haiku-4-5-20251001": {
|
||||
...anthropicModels["claude-haiku-4-5-20251001"],
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true, // Claude Code does report cache tokens
|
||||
supportsReasoningEffort: false,
|
||||
supportsReasoningBudget: false,
|
||||
requiredReasoningBudget: false,
|
||||
// Claude Code manages its own tools and temperature via the CLI
|
||||
supportsNativeTools: false,
|
||||
supportsTemperature: false,
|
||||
"claude-opus-4-5": {
|
||||
maxTokens: 32768,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsNativeTools: true,
|
||||
defaultToolProtocol: "native",
|
||||
supportsReasoningEffort: ["disable", "low", "medium", "high"],
|
||||
reasoningEffort: "medium",
|
||||
description: "Claude Opus 4.5 - Most capable with thinking",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,30 @@
|
|||
import { ClaudeCodeHandler } from "../claude-code"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import { ClaudeCodeMessage } from "../../../integrations/claude-code/types"
|
||||
import type { StreamChunk } from "../../../integrations/claude-code/streaming-client"
|
||||
|
||||
// Mock the runClaudeCode function
|
||||
vi.mock("../../../integrations/claude-code/run", () => ({
|
||||
runClaudeCode: vi.fn(),
|
||||
// Mock the OAuth manager
|
||||
vi.mock("../../../integrations/claude-code/oauth", () => ({
|
||||
claudeCodeOAuthManager: {
|
||||
getAccessToken: vi.fn(),
|
||||
getEmail: vi.fn(),
|
||||
loadCredentials: vi.fn(),
|
||||
saveCredentials: vi.fn(),
|
||||
clearCredentials: vi.fn(),
|
||||
isAuthenticated: vi.fn(),
|
||||
},
|
||||
generateUserId: vi.fn(() => "user_abc123_account_def456_session_ghi789"),
|
||||
}))
|
||||
|
||||
// Mock the message filter
|
||||
vi.mock("../../../integrations/claude-code/message-filter", () => ({
|
||||
filterMessagesForClaudeCode: vi.fn((messages) => messages),
|
||||
// Mock the streaming client
|
||||
vi.mock("../../../integrations/claude-code/streaming-client", () => ({
|
||||
createStreamingMessage: vi.fn(),
|
||||
}))
|
||||
|
||||
const { runClaudeCode } = await import("../../../integrations/claude-code/run")
|
||||
const { filterMessagesForClaudeCode } = await import("../../../integrations/claude-code/message-filter")
|
||||
const mockRunClaudeCode = vi.mocked(runClaudeCode)
|
||||
const mockFilterMessages = vi.mocked(filterMessagesForClaudeCode)
|
||||
const { claudeCodeOAuthManager } = await import("../../../integrations/claude-code/oauth")
|
||||
const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client")
|
||||
|
||||
const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken)
|
||||
const mockCreateStreamingMessage = vi.mocked(createStreamingMessage)
|
||||
|
||||
describe("ClaudeCodeHandler", () => {
|
||||
let handler: ClaudeCodeHandler
|
||||
|
|
@ -23,22 +32,20 @@ describe("ClaudeCodeHandler", () => {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const options: ApiHandlerOptions = {
|
||||
claudeCodePath: "claude",
|
||||
apiModelId: "claude-3-5-sonnet-20241022",
|
||||
apiModelId: "claude-sonnet-4-5",
|
||||
}
|
||||
handler = new ClaudeCodeHandler(options)
|
||||
})
|
||||
|
||||
test("should create handler with correct model configuration", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe("claude-3-5-sonnet-20241022")
|
||||
expect(model.info.supportsImages).toBe(false)
|
||||
expect(model.info.supportsPromptCache).toBe(true) // Claude Code now supports prompt caching
|
||||
expect(model.id).toBe("claude-sonnet-4-5")
|
||||
expect(model.info.supportsImages).toBe(true)
|
||||
expect(model.info.supportsPromptCache).toBe(true)
|
||||
})
|
||||
|
||||
test("should use default model when invalid model provided", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
claudeCodePath: "claude",
|
||||
apiModelId: "invalid-model",
|
||||
}
|
||||
const handlerWithInvalidModel = new ClaudeCodeHandler(options)
|
||||
|
|
@ -47,44 +54,53 @@ describe("ClaudeCodeHandler", () => {
|
|||
expect(model.id).toBe("claude-sonnet-4-5") // default model
|
||||
})
|
||||
|
||||
test("should override maxTokens when claudeCodeMaxOutputTokens is provided", () => {
|
||||
test("should return model maxTokens from model definition", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
claudeCodePath: "claude",
|
||||
apiModelId: "claude-sonnet-4-20250514",
|
||||
claudeCodeMaxOutputTokens: 8000,
|
||||
apiModelId: "claude-opus-4-5",
|
||||
}
|
||||
const handlerWithMaxTokens = new ClaudeCodeHandler(options)
|
||||
const model = handlerWithMaxTokens.getModel()
|
||||
const handlerWithModel = new ClaudeCodeHandler(options)
|
||||
const model = handlerWithModel.getModel()
|
||||
|
||||
expect(model.id).toBe("claude-sonnet-4-20250514")
|
||||
expect(model.info.maxTokens).toBe(8000) // Should use the configured value, not the default 64000
|
||||
expect(model.id).toBe("claude-opus-4-5")
|
||||
// Model maxTokens is 32768 as defined in claudeCodeModels for opus
|
||||
expect(model.info.maxTokens).toBe(32768)
|
||||
})
|
||||
|
||||
test("should override maxTokens for default model when claudeCodeMaxOutputTokens is provided", () => {
|
||||
test("should support reasoning effort configuration", () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
claudeCodePath: "claude",
|
||||
apiModelId: "invalid-model", // Will fall back to default
|
||||
claudeCodeMaxOutputTokens: 16384,
|
||||
apiModelId: "claude-sonnet-4-5",
|
||||
}
|
||||
const handlerWithMaxTokens = new ClaudeCodeHandler(options)
|
||||
const model = handlerWithMaxTokens.getModel()
|
||||
const handler = new ClaudeCodeHandler(options)
|
||||
const model = handler.getModel()
|
||||
|
||||
expect(model.id).toBe("claude-sonnet-4-5") // default model
|
||||
expect(model.info.maxTokens).toBe(16384) // Should use the configured value
|
||||
// Default model has supportsReasoningEffort
|
||||
expect(model.info.supportsReasoningEffort).toEqual(["disable", "low", "medium", "high"])
|
||||
expect(model.info.reasoningEffort).toBe("medium")
|
||||
})
|
||||
|
||||
test("should filter messages and call runClaudeCode", async () => {
|
||||
test("should throw error when not authenticated", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
const filteredMessages = [{ role: "user" as const, content: "Hello (filtered)" }]
|
||||
|
||||
mockFilterMessages.mockReturnValue(filteredMessages)
|
||||
mockGetAccessToken.mockResolvedValue(null)
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
await expect(iterator.next()).rejects.toThrow(/not authenticated/i)
|
||||
})
|
||||
|
||||
test("should call createStreamingMessage with thinking enabled by default", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Mock empty async generator
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
// Empty generator for basic test
|
||||
}
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
|
||||
|
|
@ -92,86 +108,155 @@ describe("ClaudeCodeHandler", () => {
|
|||
const iterator = stream[Symbol.asyncIterator]()
|
||||
await iterator.next()
|
||||
|
||||
// Verify message filtering was called
|
||||
expect(mockFilterMessages).toHaveBeenCalledWith(messages)
|
||||
|
||||
// Verify runClaudeCode was called with filtered messages
|
||||
expect(mockRunClaudeCode).toHaveBeenCalledWith({
|
||||
// Verify createStreamingMessage was called with correct parameters
|
||||
// Default model has reasoning effort of "medium" so thinking should be enabled
|
||||
// With interleaved thinking, maxTokens comes from model definition, not reasoning config
|
||||
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
|
||||
accessToken: "test-access-token",
|
||||
model: "claude-sonnet-4-5",
|
||||
systemPrompt,
|
||||
messages: filteredMessages,
|
||||
path: "claude",
|
||||
modelId: "claude-3-5-sonnet-20241022",
|
||||
maxOutputTokens: undefined, // No maxOutputTokens configured in this test
|
||||
messages,
|
||||
maxTokens: 16384, // model's maxTokens (interleaved thinking uses context window)
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 32000, // medium reasoning budget_tokens
|
||||
},
|
||||
tools: undefined,
|
||||
toolChoice: undefined,
|
||||
metadata: {
|
||||
user_id: "user_abc123_account_def456_session_ghi789",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("should pass maxOutputTokens to runClaudeCode when configured", async () => {
|
||||
test("should disable thinking when reasoningEffort is set to disable", async () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
claudeCodePath: "claude",
|
||||
apiModelId: "claude-3-5-sonnet-20241022",
|
||||
claudeCodeMaxOutputTokens: 16384,
|
||||
apiModelId: "claude-sonnet-4-5",
|
||||
reasoningEffort: "disable",
|
||||
}
|
||||
const handlerWithMaxTokens = new ClaudeCodeHandler(options)
|
||||
const handlerNoThinking = new ClaudeCodeHandler(options)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
const filteredMessages = [{ role: "user" as const, content: "Hello (filtered)" }]
|
||||
|
||||
mockFilterMessages.mockReturnValue(filteredMessages)
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Mock empty async generator
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
// Empty generator for basic test
|
||||
}
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handlerWithMaxTokens.createMessage(systemPrompt, messages)
|
||||
const stream = handlerNoThinking.createMessage(systemPrompt, messages)
|
||||
|
||||
// Need to start iterating to trigger the call
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
await iterator.next()
|
||||
|
||||
// Verify runClaudeCode was called with maxOutputTokens
|
||||
expect(mockRunClaudeCode).toHaveBeenCalledWith({
|
||||
// Verify createStreamingMessage was called with thinking disabled
|
||||
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
|
||||
accessToken: "test-access-token",
|
||||
model: "claude-sonnet-4-5",
|
||||
systemPrompt,
|
||||
messages: filteredMessages,
|
||||
path: "claude",
|
||||
modelId: "claude-3-5-sonnet-20241022",
|
||||
maxOutputTokens: 16384,
|
||||
messages,
|
||||
maxTokens: 16384, // model default maxTokens
|
||||
thinking: { type: "disabled" },
|
||||
tools: undefined,
|
||||
toolChoice: undefined,
|
||||
metadata: {
|
||||
user_id: "user_abc123_account_def456_session_ghi789",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle thinking content properly", async () => {
|
||||
test("should use high reasoning config when reasoningEffort is high", async () => {
|
||||
const options: ApiHandlerOptions = {
|
||||
apiModelId: "claude-sonnet-4-5",
|
||||
reasoningEffort: "high",
|
||||
}
|
||||
const handlerHighThinking = new ClaudeCodeHandler(options)
|
||||
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
// Mock async generator that yields thinking content
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
yield {
|
||||
type: "assistant" as const,
|
||||
message: {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "I need to think about this carefully...",
|
||||
},
|
||||
],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
},
|
||||
} as any,
|
||||
session_id: "session_123",
|
||||
}
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Mock empty async generator
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
// Empty generator for basic test
|
||||
}
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handlerHighThinking.createMessage(systemPrompt, messages)
|
||||
|
||||
// Need to start iterating to trigger the call
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
await iterator.next()
|
||||
|
||||
// Verify createStreamingMessage was called with high thinking config
|
||||
// With interleaved thinking, maxTokens comes from model definition, not reasoning config
|
||||
expect(mockCreateStreamingMessage).toHaveBeenCalledWith({
|
||||
accessToken: "test-access-token",
|
||||
model: "claude-sonnet-4-5",
|
||||
systemPrompt,
|
||||
messages,
|
||||
maxTokens: 16384, // model's maxTokens (interleaved thinking uses context window)
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budget_tokens: 64000, // high reasoning budget_tokens
|
||||
},
|
||||
tools: undefined,
|
||||
toolChoice: undefined,
|
||||
metadata: {
|
||||
user_id: "user_abc123_account_def456_session_ghi789",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle text content from streaming", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Mock async generator that yields text chunks
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
yield { type: "text", text: "Hello " }
|
||||
yield { type: "text", text: "there!" }
|
||||
}
|
||||
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Hello ",
|
||||
})
|
||||
expect(results[1]).toEqual({
|
||||
type: "text",
|
||||
text: "there!",
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle reasoning content from streaming", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Mock async generator that yields reasoning chunks
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
yield { type: "reasoning", text: "I need to think about this carefully..." }
|
||||
}
|
||||
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
|
|
@ -187,86 +272,19 @@ describe("ClaudeCodeHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("should handle redacted thinking content", async () => {
|
||||
test("should handle mixed content types from streaming", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
// Mock async generator that yields redacted thinking content
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
yield {
|
||||
type: "assistant" as const,
|
||||
message: {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [
|
||||
{
|
||||
type: "redacted_thinking",
|
||||
},
|
||||
],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
},
|
||||
} as any,
|
||||
session_id: "session_123",
|
||||
}
|
||||
}
|
||||
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
|
||||
for await (const chunk of stream) {
|
||||
results.push(chunk)
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toEqual({
|
||||
type: "reasoning",
|
||||
text: "[Redacted thinking block]",
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle mixed content types", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Mock async generator that yields mixed content
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
yield {
|
||||
type: "assistant" as const,
|
||||
message: {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "Let me think about this...",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Here's my response!",
|
||||
},
|
||||
],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
},
|
||||
} as any,
|
||||
session_id: "session_123",
|
||||
}
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
yield { type: "reasoning", text: "Let me think about this..." }
|
||||
yield { type: "text", text: "Here's my response!" }
|
||||
}
|
||||
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
|
|
@ -286,17 +304,20 @@ describe("ClaudeCodeHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("should handle string chunks from generator", async () => {
|
||||
test("should handle tool call partial chunks from streaming", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
// Mock async generator that yields string chunks
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
yield "This is a string chunk"
|
||||
yield "Another string chunk"
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Mock async generator that yields tool call partial chunks
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
yield { type: "tool_call_partial", index: 0, id: "tool_123", name: "read_file", arguments: undefined }
|
||||
yield { type: "tool_call_partial", index: 0, id: undefined, name: undefined, arguments: '{"path":' }
|
||||
yield { type: "tool_call_partial", index: 0, id: undefined, name: undefined, arguments: '"test.txt"}' }
|
||||
}
|
||||
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
|
|
@ -305,74 +326,49 @@ describe("ClaudeCodeHandler", () => {
|
|||
results.push(chunk)
|
||||
}
|
||||
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results[0]).toEqual({
|
||||
type: "text",
|
||||
text: "This is a string chunk",
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: "tool_123",
|
||||
name: "read_file",
|
||||
arguments: undefined,
|
||||
})
|
||||
expect(results[1]).toEqual({
|
||||
type: "text",
|
||||
text: "Another string chunk",
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
arguments: '{"path":',
|
||||
})
|
||||
expect(results[2]).toEqual({
|
||||
type: "tool_call_partial",
|
||||
index: 0,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
arguments: '"test.txt"}',
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle usage and cost tracking with paid usage", async () => {
|
||||
test("should handle usage and cost tracking from streaming", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
// Mock async generator with init, assistant, and result messages
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
// Init message indicating paid usage
|
||||
yield {
|
||||
type: "system" as const,
|
||||
subtype: "init" as const,
|
||||
session_id: "session_123",
|
||||
tools: [],
|
||||
mcp_servers: [],
|
||||
apiKeySource: "/login managed key",
|
||||
}
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Assistant message
|
||||
// Mock async generator with text and usage
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
yield { type: "text", text: "Hello there!" }
|
||||
yield {
|
||||
type: "assistant" as const,
|
||||
message: {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello there!",
|
||||
},
|
||||
],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
cache_read_input_tokens: 5,
|
||||
cache_creation_input_tokens: 3,
|
||||
},
|
||||
} as any,
|
||||
session_id: "session_123",
|
||||
}
|
||||
|
||||
// Result message
|
||||
yield {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
total_cost_usd: 0.05,
|
||||
is_error: false,
|
||||
duration_ms: 1000,
|
||||
duration_api_ms: 800,
|
||||
num_turns: 1,
|
||||
result: "success",
|
||||
session_id: "session_123",
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 5,
|
||||
cacheWriteTokens: 3,
|
||||
}
|
||||
}
|
||||
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
|
|
@ -387,71 +383,34 @@ describe("ClaudeCodeHandler", () => {
|
|||
type: "text",
|
||||
text: "Hello there!",
|
||||
})
|
||||
// Claude Code is subscription-based, no per-token cost
|
||||
expect(results[1]).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 5,
|
||||
cacheWriteTokens: 3,
|
||||
totalCost: 0.05, // Paid usage, so cost is included
|
||||
totalCost: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle usage tracking with subscription (free) usage", async () => {
|
||||
test("should handle usage without cache tokens", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
// Mock async generator with subscription usage
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
// Init message indicating subscription usage
|
||||
yield {
|
||||
type: "system" as const,
|
||||
subtype: "init" as const,
|
||||
session_id: "session_123",
|
||||
tools: [],
|
||||
mcp_servers: [],
|
||||
apiKeySource: "none", // Subscription usage
|
||||
}
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Assistant message
|
||||
// Mock async generator with usage without cache tokens
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
yield { type: "text", text: "Hello there!" }
|
||||
yield {
|
||||
type: "assistant" as const,
|
||||
message: {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Hello there!",
|
||||
},
|
||||
],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
},
|
||||
} as any,
|
||||
session_id: "session_123",
|
||||
}
|
||||
|
||||
// Result message
|
||||
yield {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
total_cost_usd: 0.05,
|
||||
is_error: false,
|
||||
duration_ms: 1000,
|
||||
duration_api_ms: 800,
|
||||
num_turns: 1,
|
||||
result: "success",
|
||||
session_id: "session_123",
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
}
|
||||
}
|
||||
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
|
|
@ -460,95 +419,50 @@ describe("ClaudeCodeHandler", () => {
|
|||
results.push(chunk)
|
||||
}
|
||||
|
||||
// Should have text chunk and usage chunk
|
||||
// Claude Code is subscription-based, no per-token cost
|
||||
expect(results).toHaveLength(2)
|
||||
expect(results[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Hello there!",
|
||||
})
|
||||
expect(results[1]).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0, // Subscription usage, so cost is 0
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
totalCost: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test("should handle API errors properly", async () => {
|
||||
test("should handle API errors from streaming", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
|
||||
// Mock async generator that yields an API error
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
yield {
|
||||
type: "assistant" as const,
|
||||
message: {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: 'API Error: 400 {"error":{"message":"Invalid model name"}}',
|
||||
},
|
||||
],
|
||||
stop_reason: "stop_sequence",
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
},
|
||||
} as any,
|
||||
session_id: "session_123",
|
||||
}
|
||||
mockGetAccessToken.mockResolvedValue("test-access-token")
|
||||
|
||||
// Mock async generator that yields an error
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
yield { type: "error", error: "Invalid model name" }
|
||||
}
|
||||
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const iterator = stream[Symbol.asyncIterator]()
|
||||
|
||||
// Should throw an error
|
||||
await expect(iterator.next()).rejects.toThrow()
|
||||
await expect(iterator.next()).rejects.toThrow("Invalid model name")
|
||||
})
|
||||
|
||||
test("should log warning for unsupported tool_use content", async () => {
|
||||
test("should handle authentication refresh and continue streaming", async () => {
|
||||
const systemPrompt = "You are a helpful assistant"
|
||||
const messages = [{ role: "user" as const, content: "Hello" }]
|
||||
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {})
|
||||
|
||||
// Mock async generator that yields tool_use content
|
||||
const mockGenerator = async function* (): AsyncGenerator<ClaudeCodeMessage | string> {
|
||||
yield {
|
||||
type: "assistant" as const,
|
||||
message: {
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_123",
|
||||
name: "test_tool",
|
||||
input: { test: "data" },
|
||||
},
|
||||
],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 20,
|
||||
},
|
||||
} as any,
|
||||
session_id: "session_123",
|
||||
}
|
||||
// First call returns a valid token
|
||||
mockGetAccessToken.mockResolvedValue("refreshed-token")
|
||||
|
||||
const mockGenerator = async function* (): AsyncGenerator<StreamChunk> {
|
||||
yield { type: "text", text: "Response after refresh" }
|
||||
}
|
||||
|
||||
mockRunClaudeCode.mockReturnValue(mockGenerator())
|
||||
mockCreateStreamingMessage.mockReturnValue(mockGenerator())
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const results = []
|
||||
|
|
@ -557,9 +471,16 @@ describe("ClaudeCodeHandler", () => {
|
|||
results.push(chunk)
|
||||
}
|
||||
|
||||
// Should log error for unsupported tool_use
|
||||
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("tool_use is not supported yet"))
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toEqual({
|
||||
type: "text",
|
||||
text: "Response after refresh",
|
||||
})
|
||||
|
||||
consoleSpy.mockRestore()
|
||||
expect(mockCreateStreamingMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: "refreshed-token",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,143 +1,270 @@
|
|||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import {
|
||||
claudeCodeDefaultModelId,
|
||||
type ClaudeCodeModelId,
|
||||
claudeCodeModels,
|
||||
claudeCodeReasoningConfig,
|
||||
type ClaudeCodeReasoningLevel,
|
||||
type ModelInfo,
|
||||
getClaudeCodeModelId,
|
||||
} from "@roo-code/types"
|
||||
import { type ApiHandler, ApiHandlerCreateMessageMetadata } from ".."
|
||||
import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream"
|
||||
import { runClaudeCode } from "../../integrations/claude-code/run"
|
||||
import { filterMessagesForClaudeCode } from "../../integrations/claude-code/message-filter"
|
||||
import { claudeCodeOAuthManager, generateUserId } from "../../integrations/claude-code/oauth"
|
||||
import {
|
||||
createStreamingMessage,
|
||||
type StreamChunk,
|
||||
type ThinkingConfig,
|
||||
} from "../../integrations/claude-code/streaming-client"
|
||||
import { t } from "../../i18n"
|
||||
import { ApiHandlerOptions } from "../../shared/api"
|
||||
import { countTokens } from "../../utils/countTokens"
|
||||
import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters"
|
||||
|
||||
/**
|
||||
* Converts OpenAI tool_choice to Anthropic ToolChoice format
|
||||
* @param toolChoice - OpenAI tool_choice parameter
|
||||
* @param parallelToolCalls - When true, allows parallel tool calls. When false (default), disables parallel tool calls.
|
||||
*/
|
||||
function convertOpenAIToolChoice(
|
||||
toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"],
|
||||
parallelToolCalls?: boolean,
|
||||
): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined {
|
||||
// Anthropic allows parallel tool calls by default. When parallelToolCalls is false or undefined,
|
||||
// we disable parallel tool use to ensure one tool call at a time.
|
||||
const disableParallelToolUse = !parallelToolCalls
|
||||
|
||||
if (!toolChoice) {
|
||||
// Default to auto with parallel tool use control
|
||||
return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
|
||||
}
|
||||
|
||||
if (typeof toolChoice === "string") {
|
||||
switch (toolChoice) {
|
||||
case "none":
|
||||
return undefined // Anthropic doesn't have "none", just omit tools
|
||||
case "auto":
|
||||
return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
|
||||
case "required":
|
||||
return { type: "any", disable_parallel_tool_use: disableParallelToolUse }
|
||||
default:
|
||||
return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
|
||||
}
|
||||
}
|
||||
|
||||
// Handle object form { type: "function", function: { name: string } }
|
||||
if (typeof toolChoice === "object" && "function" in toolChoice) {
|
||||
return {
|
||||
type: "tool",
|
||||
name: toolChoice.function.name,
|
||||
disable_parallel_tool_use: disableParallelToolUse,
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "auto", disable_parallel_tool_use: disableParallelToolUse }
|
||||
}
|
||||
|
||||
export class ClaudeCodeHandler implements ApiHandler {
|
||||
private options: ApiHandlerOptions
|
||||
/**
|
||||
* Store the last thinking block signature for interleaved thinking with tool use.
|
||||
* This is captured from thinking_complete events during streaming and
|
||||
* must be passed back to the API when providing tool results.
|
||||
* Similar to Gemini's thoughtSignature pattern.
|
||||
*/
|
||||
private lastThinkingSignature?: string
|
||||
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the thinking signature from the last response.
|
||||
* Used by Task.addToApiConversationHistory to persist the signature
|
||||
* so it can be passed back to the API for tool use continuations.
|
||||
* This follows the same pattern as Gemini's getThoughtSignature().
|
||||
*/
|
||||
public getThoughtSignature(): string | undefined {
|
||||
return this.lastThinkingSignature
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the reasoning effort level for the current request.
|
||||
* Returns the effective reasoning level (low/medium/high) or null if disabled.
|
||||
*/
|
||||
private getReasoningEffort(modelInfo: ModelInfo): ClaudeCodeReasoningLevel | null {
|
||||
// Check if reasoning is explicitly disabled
|
||||
if (this.options.enableReasoningEffort === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Get the selected effort from settings or model default
|
||||
const selectedEffort = this.options.reasoningEffort ?? modelInfo.reasoningEffort
|
||||
|
||||
// "disable" or no selection means no reasoning
|
||||
if (!selectedEffort || selectedEffort === "disable") {
|
||||
return null
|
||||
}
|
||||
|
||||
// Only allow valid levels for Claude Code
|
||||
if (selectedEffort === "low" || selectedEffort === "medium" || selectedEffort === "high") {
|
||||
return selectedEffort
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
_metadata?: ApiHandlerCreateMessageMetadata,
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
// Filter out image blocks since Claude Code doesn't support them
|
||||
const filteredMessages = filterMessagesForClaudeCode(messages)
|
||||
// Reset per-request state that we persist into apiConversationHistory
|
||||
this.lastThinkingSignature = undefined
|
||||
|
||||
// Get access token from OAuth manager
|
||||
const accessToken = await claudeCodeOAuthManager.getAccessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error(
|
||||
t("common:errors.claudeCode.notAuthenticated", {
|
||||
defaultValue:
|
||||
"Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Get user email for generating user_id metadata
|
||||
const email = await claudeCodeOAuthManager.getEmail()
|
||||
|
||||
const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1"
|
||||
const model = this.getModel()
|
||||
|
||||
// Validate that the model ID is a valid ClaudeCodeModelId
|
||||
const modelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId
|
||||
|
||||
const claudeProcess = runClaudeCode({
|
||||
systemPrompt,
|
||||
messages: filteredMessages,
|
||||
path: this.options.claudeCodePath,
|
||||
modelId: getClaudeCodeModelId(modelId, useVertex),
|
||||
maxOutputTokens: this.options.claudeCodeMaxOutputTokens,
|
||||
})
|
||||
// Generate user_id metadata in the format required by Claude Code API
|
||||
const userId = generateUserId(email || undefined)
|
||||
|
||||
// Usage is included with assistant messages,
|
||||
// but cost is included in the result chunk
|
||||
const usage: ApiStreamUsageChunk = {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
// Convert OpenAI tools to Anthropic format if provided and protocol is native
|
||||
// Exclude tools when tool_choice is "none" since that means "don't use tools"
|
||||
const shouldIncludeNativeTools =
|
||||
metadata?.tools &&
|
||||
metadata.tools.length > 0 &&
|
||||
metadata?.toolProtocol !== "xml" &&
|
||||
metadata?.tool_choice !== "none"
|
||||
|
||||
const anthropicTools = shouldIncludeNativeTools ? convertOpenAIToolsToAnthropic(metadata.tools!) : undefined
|
||||
|
||||
const anthropicToolChoice = shouldIncludeNativeTools
|
||||
? convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls)
|
||||
: undefined
|
||||
|
||||
// Determine reasoning effort and thinking configuration
|
||||
const reasoningLevel = this.getReasoningEffort(model.info)
|
||||
|
||||
let thinking: ThinkingConfig
|
||||
// With interleaved thinking (enabled via beta header), budget_tokens can exceed max_tokens
|
||||
// as the token limit becomes the entire context window. We use the model's maxTokens.
|
||||
// See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking
|
||||
const maxTokens = model.info.maxTokens ?? 16384
|
||||
|
||||
if (reasoningLevel) {
|
||||
// Use thinking mode with budget_tokens from config
|
||||
const config = claudeCodeReasoningConfig[reasoningLevel]
|
||||
thinking = {
|
||||
type: "enabled",
|
||||
budget_tokens: config.budgetTokens,
|
||||
}
|
||||
} else {
|
||||
// Explicitly disable thinking
|
||||
thinking = { type: "disabled" }
|
||||
}
|
||||
|
||||
let isPaidUsage = true
|
||||
// Create streaming request using OAuth
|
||||
const stream = createStreamingMessage({
|
||||
accessToken,
|
||||
model: modelId,
|
||||
systemPrompt,
|
||||
messages,
|
||||
maxTokens,
|
||||
thinking,
|
||||
tools: anthropicTools,
|
||||
toolChoice: anthropicToolChoice,
|
||||
metadata: {
|
||||
user_id: userId,
|
||||
},
|
||||
})
|
||||
|
||||
for await (const chunk of claudeProcess) {
|
||||
if (typeof chunk === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk,
|
||||
}
|
||||
// Track usage for cost calculation
|
||||
let inputTokens = 0
|
||||
let outputTokens = 0
|
||||
let cacheReadTokens = 0
|
||||
let cacheWriteTokens = 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "system" && chunk.subtype === "init") {
|
||||
// Based on my tests, subscription usage sets the `apiKeySource` to "none"
|
||||
isPaidUsage = chunk.apiKeySource !== "none"
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "assistant" && "message" in chunk) {
|
||||
const message = chunk.message
|
||||
|
||||
if (message.stop_reason !== null) {
|
||||
const content = "text" in message.content[0] ? message.content[0] : undefined
|
||||
|
||||
const isError = content && content.text.startsWith(`API Error`)
|
||||
if (isError) {
|
||||
// Error messages are formatted as: `API Error: <<status code>> <<json>>`
|
||||
const errorMessageStart = content.text.indexOf("{")
|
||||
const errorMessage = content.text.slice(errorMessageStart)
|
||||
|
||||
const error = this.attemptParse(errorMessage)
|
||||
if (!error) {
|
||||
throw new Error(content.text)
|
||||
}
|
||||
|
||||
if (error.error.message.includes("Invalid model name")) {
|
||||
throw new Error(
|
||||
content.text + `\n\n${t("common:errors.claudeCode.apiKeyModelPlanMismatch")}`,
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
for await (const chunk of stream) {
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.text,
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
for (const content of message.content) {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
yield {
|
||||
type: "text",
|
||||
text: content.text,
|
||||
}
|
||||
break
|
||||
case "thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: content.thinking || "",
|
||||
}
|
||||
break
|
||||
case "redacted_thinking":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: "[Redacted thinking block]",
|
||||
}
|
||||
break
|
||||
case "tool_use":
|
||||
console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`)
|
||||
break
|
||||
case "reasoning":
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: chunk.text,
|
||||
}
|
||||
break
|
||||
|
||||
case "thinking_complete":
|
||||
// Capture the signature for persistence in api_conversation_history
|
||||
// This enables tool use continuations where thinking blocks must be passed back
|
||||
if (chunk.signature) {
|
||||
this.lastThinkingSignature = chunk.signature
|
||||
}
|
||||
// Emit a complete thinking block with signature
|
||||
// This is critical for interleaved thinking with tool use
|
||||
// The signature must be included when passing thinking blocks back to the API
|
||||
yield {
|
||||
type: "reasoning",
|
||||
text: chunk.thinking,
|
||||
signature: chunk.signature,
|
||||
}
|
||||
break
|
||||
|
||||
case "tool_call_partial":
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index: chunk.index,
|
||||
id: chunk.id,
|
||||
name: chunk.name,
|
||||
arguments: chunk.arguments,
|
||||
}
|
||||
break
|
||||
|
||||
case "usage": {
|
||||
inputTokens = chunk.inputTokens
|
||||
outputTokens = chunk.outputTokens
|
||||
cacheReadTokens = chunk.cacheReadTokens || 0
|
||||
cacheWriteTokens = chunk.cacheWriteTokens || 0
|
||||
|
||||
// Claude Code is subscription-based, no per-token cost
|
||||
const usageChunk: ApiStreamUsageChunk = {
|
||||
type: "usage",
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
|
||||
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
|
||||
totalCost: 0,
|
||||
}
|
||||
|
||||
yield usageChunk
|
||||
break
|
||||
}
|
||||
|
||||
// Accumulate usage across streaming chunks
|
||||
usage.inputTokens += message.usage.input_tokens
|
||||
usage.outputTokens += message.usage.output_tokens
|
||||
usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (message.usage.cache_read_input_tokens || 0)
|
||||
usage.cacheWriteTokens =
|
||||
(usage.cacheWriteTokens || 0) + (message.usage.cache_creation_input_tokens || 0)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (chunk.type === "result" && "result" in chunk) {
|
||||
usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0
|
||||
|
||||
yield usage
|
||||
case "error":
|
||||
throw new Error(chunk.error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -146,26 +273,12 @@ export class ClaudeCodeHandler implements ApiHandler {
|
|||
const modelId = this.options.apiModelId
|
||||
if (modelId && modelId in claudeCodeModels) {
|
||||
const id = modelId as ClaudeCodeModelId
|
||||
const modelInfo: ModelInfo = { ...claudeCodeModels[id] }
|
||||
|
||||
// Override maxTokens with the configured value if provided
|
||||
if (this.options.claudeCodeMaxOutputTokens !== undefined) {
|
||||
modelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens
|
||||
}
|
||||
|
||||
return { id, info: modelInfo }
|
||||
}
|
||||
|
||||
const defaultModelInfo: ModelInfo = { ...claudeCodeModels[claudeCodeDefaultModelId] }
|
||||
|
||||
// Override maxTokens with the configured value if provided
|
||||
if (this.options.claudeCodeMaxOutputTokens !== undefined) {
|
||||
defaultModelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens
|
||||
return { id, info: { ...claudeCodeModels[id] } }
|
||||
}
|
||||
|
||||
return {
|
||||
id: claudeCodeDefaultModelId,
|
||||
info: defaultModelInfo,
|
||||
info: { ...claudeCodeModels[claudeCodeDefaultModelId] },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -175,12 +288,4 @@ export class ClaudeCodeHandler implements ApiHandler {
|
|||
}
|
||||
return countTokens(content, { useWorker: true })
|
||||
}
|
||||
|
||||
private attemptParse(str: string) {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch (err) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export type ApiStreamChunk =
|
|||
| ApiStreamTextChunk
|
||||
| ApiStreamUsageChunk
|
||||
| ApiStreamReasoningChunk
|
||||
| ApiStreamThinkingCompleteChunk
|
||||
| ApiStreamGroundingChunk
|
||||
| ApiStreamToolCallChunk
|
||||
| ApiStreamToolCallStartChunk
|
||||
|
|
@ -23,9 +24,35 @@ export interface ApiStreamTextChunk {
|
|||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasoning/thinking chunk from the API stream.
|
||||
* For Anthropic extended thinking, this may include a signature field
|
||||
* which is required for passing thinking blocks back to the API during tool use.
|
||||
*/
|
||||
export interface ApiStreamReasoningChunk {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
/**
|
||||
* Signature for the thinking block (Anthropic extended thinking).
|
||||
* When present, this indicates a complete thinking block that should be
|
||||
* preserved for tool use continuations. The signature is used to verify
|
||||
* that thinking blocks were generated by Claude.
|
||||
*/
|
||||
signature?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Signals completion of a thinking block with its verification signature.
|
||||
* Used by Anthropic extended thinking to pass the signature needed for
|
||||
* tool use continuations and caching.
|
||||
*/
|
||||
export interface ApiStreamThinkingCompleteChunk {
|
||||
type: "thinking_complete"
|
||||
/**
|
||||
* Cryptographic signature that verifies this thinking block was generated by Claude.
|
||||
* Must be preserved and passed back to the API when continuing conversations with tool use.
|
||||
*/
|
||||
signature: string
|
||||
}
|
||||
|
||||
export interface ApiStreamUsageChunk {
|
||||
|
|
|
|||
|
|
@ -751,9 +751,30 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
messageWithTs.reasoning_details = reasoningDetails
|
||||
}
|
||||
|
||||
// Store reasoning: plain text (most providers) or encrypted (OpenAI Native)
|
||||
// Store reasoning: Anthropic thinking (with signature), plain text (most providers), or encrypted (OpenAI Native)
|
||||
// Skip if reasoning_details already contains the reasoning (to avoid duplication)
|
||||
if (reasoning && !reasoningDetails) {
|
||||
if (reasoning && thoughtSignature && !reasoningDetails) {
|
||||
// Anthropic provider with extended thinking: Store as proper `thinking` block
|
||||
// This format passes through anthropic-filter.ts and is properly round-tripped
|
||||
// for interleaved thinking with tool use (required by Anthropic API)
|
||||
const thinkingBlock = {
|
||||
type: "thinking",
|
||||
thinking: reasoning,
|
||||
signature: thoughtSignature,
|
||||
}
|
||||
|
||||
if (typeof messageWithTs.content === "string") {
|
||||
messageWithTs.content = [
|
||||
thinkingBlock,
|
||||
{ type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam,
|
||||
]
|
||||
} else if (Array.isArray(messageWithTs.content)) {
|
||||
messageWithTs.content = [thinkingBlock, ...messageWithTs.content]
|
||||
} else if (!messageWithTs.content) {
|
||||
messageWithTs.content = [thinkingBlock]
|
||||
}
|
||||
} else if (reasoning && !reasoningDetails) {
|
||||
// Other providers (non-Anthropic): Store as generic reasoning block
|
||||
const reasoningBlock = {
|
||||
type: "reasoning",
|
||||
text: reasoning,
|
||||
|
|
@ -791,9 +812,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
}
|
||||
|
||||
// If we have a thought signature, append it as a dedicated content block
|
||||
// so it can be round-tripped in api_history.json and re-sent on subsequent calls.
|
||||
if (thoughtSignature) {
|
||||
// If we have a thought signature WITHOUT reasoning text (edge case),
|
||||
// append it as a dedicated content block for non-Anthropic providers (e.g., Gemini).
|
||||
// Note: For Anthropic, the signature is already included in the thinking block above.
|
||||
if (thoughtSignature && !reasoning) {
|
||||
const thoughtSignatureBlock = {
|
||||
type: "thoughtSignature",
|
||||
thoughtSignature,
|
||||
|
|
|
|||
|
|
@ -2071,6 +2071,14 @@ export class ClineProvider
|
|||
openRouterImageGenerationSelectedModel,
|
||||
openRouterUseMiddleOutTransform,
|
||||
featureRoomoteControlEnabled,
|
||||
claudeCodeIsAuthenticated: await (async () => {
|
||||
try {
|
||||
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
|
||||
return await claudeCodeOAuthManager.isAuthenticated()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})(),
|
||||
debug: vscode.workspace.getConfiguration(Package.name).get<boolean>("debug", false),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2268,6 +2268,45 @@ export const webviewMessageHandler = async (
|
|||
|
||||
break
|
||||
}
|
||||
case "claudeCodeSignIn": {
|
||||
try {
|
||||
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
|
||||
const authUrl = claudeCodeOAuthManager.startAuthorizationFlow()
|
||||
|
||||
// Open the authorization URL in the browser
|
||||
await vscode.env.openExternal(vscode.Uri.parse(authUrl))
|
||||
|
||||
// Wait for the callback in a separate promise (non-blocking)
|
||||
claudeCodeOAuthManager
|
||||
.waitForCallback()
|
||||
.then(async () => {
|
||||
vscode.window.showInformationMessage("Successfully signed in to Claude Code")
|
||||
await provider.postStateToWebview()
|
||||
})
|
||||
.catch((error) => {
|
||||
provider.log(`Claude Code OAuth callback failed: ${error}`)
|
||||
if (!String(error).includes("timed out")) {
|
||||
vscode.window.showErrorMessage(`Claude Code sign in failed: ${error.message || error}`)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
provider.log(`Claude Code OAuth failed: ${error}`)
|
||||
vscode.window.showErrorMessage("Claude Code sign in failed.")
|
||||
}
|
||||
break
|
||||
}
|
||||
case "claudeCodeSignOut": {
|
||||
try {
|
||||
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
|
||||
await claudeCodeOAuthManager.clearCredentials()
|
||||
vscode.window.showInformationMessage("Signed out from Claude Code")
|
||||
await provider.postStateToWebview()
|
||||
} catch (error) {
|
||||
provider.log(`Claude Code sign out failed: ${error}`)
|
||||
vscode.window.showErrorMessage("Claude Code sign out failed.")
|
||||
}
|
||||
break
|
||||
}
|
||||
case "rooCloudManualUrl": {
|
||||
try {
|
||||
if (!message.text) {
|
||||
|
|
@ -3042,6 +3081,37 @@ export const webviewMessageHandler = async (
|
|||
break
|
||||
}
|
||||
|
||||
case "requestClaudeCodeRateLimits": {
|
||||
try {
|
||||
const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth")
|
||||
const accessToken = await claudeCodeOAuthManager.getAccessToken()
|
||||
|
||||
if (!accessToken) {
|
||||
provider.postMessageToWebview({
|
||||
type: "claudeCodeRateLimits",
|
||||
error: "Not authenticated with Claude Code",
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const { fetchRateLimitInfo } = await import("../../integrations/claude-code/streaming-client")
|
||||
const rateLimits = await fetchRateLimitInfo(accessToken)
|
||||
|
||||
provider.postMessageToWebview({
|
||||
type: "claudeCodeRateLimits",
|
||||
values: rateLimits,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
provider.log(`Error fetching Claude Code rate limits: ${errorMessage}`)
|
||||
provider.postMessageToWebview({
|
||||
type: "claudeCodeRateLimits",
|
||||
error: errorMessage,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "openDebugApiHistory":
|
||||
case "openDebugUiHistory": {
|
||||
const currentTask = provider.getCurrentTask()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { ContextProxy } from "./core/config/ContextProxy"
|
|||
import { ClineProvider } from "./core/webview/ClineProvider"
|
||||
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
|
||||
import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry"
|
||||
import { claudeCodeOAuthManager } from "./integrations/claude-code/oauth"
|
||||
import { McpServerManager } from "./services/mcp/McpServerManager"
|
||||
import { CodeIndexManager } from "./services/code-index/manager"
|
||||
import { MdmService } from "./services/mdm/MdmService"
|
||||
|
|
@ -90,6 +91,9 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
// Initialize terminal shell execution handlers.
|
||||
TerminalRegistry.initialize()
|
||||
|
||||
// Initialize Claude Code OAuth manager for direct API access.
|
||||
claudeCodeOAuthManager.initialize(context)
|
||||
|
||||
// Get default commands from configuration.
|
||||
const defaultCommands = vscode.workspace.getConfiguration(Package.name).get<string[]>("allowedCommands") || []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,263 +0,0 @@
|
|||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { filterMessagesForClaudeCode } from "../message-filter"
|
||||
|
||||
describe("filterMessagesForClaudeCode", () => {
|
||||
test("should pass through string messages unchanged", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello, this is a simple text message",
|
||||
},
|
||||
]
|
||||
|
||||
const result = filterMessagesForClaudeCode(messages)
|
||||
|
||||
expect(result).toEqual(messages)
|
||||
})
|
||||
|
||||
test("should pass through text-only content blocks unchanged", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "This is a text block",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = filterMessagesForClaudeCode(messages)
|
||||
|
||||
expect(result).toEqual(messages)
|
||||
})
|
||||
|
||||
test("should replace image blocks with text placeholders", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Here's an image:",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = filterMessagesForClaudeCode(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Here's an image:",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "[Image (base64): image/png not supported by Claude Code]",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should handle image blocks with unknown source types", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "image",
|
||||
source: undefined as any,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = filterMessagesForClaudeCode(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "[Image (unknown): unknown not supported by Claude Code]",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should handle mixed content with multiple images", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Compare these images:",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/jpeg",
|
||||
data: "base64data1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "and",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/gif",
|
||||
data: "base64data2",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "What do you think?",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = filterMessagesForClaudeCode(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Compare these images:",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "[Image (base64): image/jpeg not supported by Claude Code]",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "and",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "[Image (base64): image/gif not supported by Claude Code]",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "What do you think?",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should handle multiple messages with images", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: "First message with text only",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "I can help with that.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Here's an image:",
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: "image/png",
|
||||
data: "imagedata",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = filterMessagesForClaudeCode(messages)
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: "First message with text only",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "I can help with that.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Here's an image:",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "[Image (base64): image/png not supported by Claude Code]",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should preserve other content block types unchanged", () => {
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Regular text",
|
||||
},
|
||||
// This would be some other content type that's not an image
|
||||
{
|
||||
type: "tool_use" as any,
|
||||
id: "tool_123",
|
||||
name: "test_tool",
|
||||
input: { test: "data" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const result = filterMessagesForClaudeCode(messages)
|
||||
|
||||
expect(result).toEqual(messages)
|
||||
})
|
||||
})
|
||||
198
src/integrations/claude-code/__tests__/oauth.spec.ts
Normal file
198
src/integrations/claude-code/__tests__/oauth.spec.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import {
|
||||
generateCodeVerifier,
|
||||
generateCodeChallenge,
|
||||
generateState,
|
||||
generateUserId,
|
||||
buildAuthorizationUrl,
|
||||
isTokenExpired,
|
||||
CLAUDE_CODE_OAUTH_CONFIG,
|
||||
type ClaudeCodeCredentials,
|
||||
} from "../oauth"
|
||||
|
||||
describe("Claude Code OAuth", () => {
|
||||
describe("generateCodeVerifier", () => {
|
||||
test("should generate a base64url encoded verifier", () => {
|
||||
const verifier = generateCodeVerifier()
|
||||
// Base64url encoded 32 bytes = 43 characters
|
||||
expect(verifier).toHaveLength(43)
|
||||
// Should only contain base64url safe characters
|
||||
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
|
||||
test("should generate unique verifiers on each call", () => {
|
||||
const verifier1 = generateCodeVerifier()
|
||||
const verifier2 = generateCodeVerifier()
|
||||
expect(verifier1).not.toBe(verifier2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateCodeChallenge", () => {
|
||||
test("should generate a base64url encoded SHA256 hash", () => {
|
||||
const verifier = "test-verifier-string"
|
||||
const challenge = generateCodeChallenge(verifier)
|
||||
// Base64url encoded SHA256 hash = 43 characters
|
||||
expect(challenge).toHaveLength(43)
|
||||
// Should only contain base64url safe characters
|
||||
expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
|
||||
test("should generate consistent challenge for same verifier", () => {
|
||||
const verifier = "test-verifier-string"
|
||||
const challenge1 = generateCodeChallenge(verifier)
|
||||
const challenge2 = generateCodeChallenge(verifier)
|
||||
expect(challenge1).toBe(challenge2)
|
||||
})
|
||||
|
||||
test("should generate different challenges for different verifiers", () => {
|
||||
const challenge1 = generateCodeChallenge("verifier1")
|
||||
const challenge2 = generateCodeChallenge("verifier2")
|
||||
expect(challenge1).not.toBe(challenge2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateState", () => {
|
||||
test("should generate a 32-character hex string", () => {
|
||||
const state = generateState()
|
||||
expect(state).toHaveLength(32) // 16 bytes = 32 hex chars
|
||||
expect(state).toMatch(/^[0-9a-f]+$/)
|
||||
})
|
||||
|
||||
test("should generate unique states on each call", () => {
|
||||
const state1 = generateState()
|
||||
const state2 = generateState()
|
||||
expect(state1).not.toBe(state2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateUserId", () => {
|
||||
test("should generate user ID with correct format", () => {
|
||||
const userId = generateUserId()
|
||||
// Format: user_<16 hex>_account_<32 hex>_session_<32 hex>
|
||||
expect(userId).toMatch(/^user_[0-9a-f]{16}_account_[0-9a-f]{32}_session_[0-9a-f]{32}$/)
|
||||
})
|
||||
|
||||
test("should generate unique session IDs on each call", () => {
|
||||
const userId1 = generateUserId()
|
||||
const userId2 = generateUserId()
|
||||
// Full IDs should be different due to random session UUID
|
||||
expect(userId1).not.toBe(userId2)
|
||||
})
|
||||
|
||||
test("should generate deterministic user hash and account UUID from email", () => {
|
||||
const email = "test@example.com"
|
||||
const userId1 = generateUserId(email)
|
||||
const userId2 = generateUserId(email)
|
||||
|
||||
// Extract user and account parts (everything except session)
|
||||
const userAccount1 = userId1.replace(/_session_[0-9a-f]{32}$/, "")
|
||||
const userAccount2 = userId2.replace(/_session_[0-9a-f]{32}$/, "")
|
||||
|
||||
// User hash and account UUID should be deterministic for same email
|
||||
expect(userAccount1).toBe(userAccount2)
|
||||
|
||||
// But session UUID should be different
|
||||
const session1 = userId1.match(/_session_([0-9a-f]{32})$/)?.[1]
|
||||
const session2 = userId2.match(/_session_([0-9a-f]{32})$/)?.[1]
|
||||
expect(session1).not.toBe(session2)
|
||||
})
|
||||
|
||||
test("should generate different user hash for different emails", () => {
|
||||
const userId1 = generateUserId("user1@example.com")
|
||||
const userId2 = generateUserId("user2@example.com")
|
||||
|
||||
const userHash1 = userId1.match(/^user_([0-9a-f]{16})_/)?.[1]
|
||||
const userHash2 = userId2.match(/^user_([0-9a-f]{16})_/)?.[1]
|
||||
|
||||
expect(userHash1).not.toBe(userHash2)
|
||||
})
|
||||
|
||||
test("should generate random user hash and account UUID without email", () => {
|
||||
const userId1 = generateUserId()
|
||||
const userId2 = generateUserId()
|
||||
|
||||
// Without email, even user hash should be different each call
|
||||
const userHash1 = userId1.match(/^user_([0-9a-f]{16})_/)?.[1]
|
||||
const userHash2 = userId2.match(/^user_([0-9a-f]{16})_/)?.[1]
|
||||
|
||||
// Extremely unlikely to be the same (random 8 bytes)
|
||||
expect(userHash1).not.toBe(userHash2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildAuthorizationUrl", () => {
|
||||
test("should build correct authorization URL with all parameters", () => {
|
||||
const codeChallenge = "test-code-challenge"
|
||||
const state = "test-state"
|
||||
const url = buildAuthorizationUrl(codeChallenge, state)
|
||||
|
||||
const parsedUrl = new URL(url)
|
||||
expect(parsedUrl.origin + parsedUrl.pathname).toBe(CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint)
|
||||
|
||||
const params = parsedUrl.searchParams
|
||||
expect(params.get("client_id")).toBe(CLAUDE_CODE_OAUTH_CONFIG.clientId)
|
||||
expect(params.get("redirect_uri")).toBe(CLAUDE_CODE_OAUTH_CONFIG.redirectUri)
|
||||
expect(params.get("scope")).toBe(CLAUDE_CODE_OAUTH_CONFIG.scopes)
|
||||
expect(params.get("code_challenge")).toBe(codeChallenge)
|
||||
expect(params.get("code_challenge_method")).toBe("S256")
|
||||
expect(params.get("response_type")).toBe("code")
|
||||
expect(params.get("state")).toBe(state)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isTokenExpired", () => {
|
||||
test("should return false for non-expired token", () => {
|
||||
const futureDate = new Date(Date.now() + 60 * 60 * 1000) // 1 hour in future
|
||||
const credentials: ClaudeCodeCredentials = {
|
||||
type: "claude",
|
||||
access_token: "test-token",
|
||||
refresh_token: "test-refresh",
|
||||
expired: futureDate.toISOString(),
|
||||
}
|
||||
expect(isTokenExpired(credentials)).toBe(false)
|
||||
})
|
||||
|
||||
test("should return true for expired token", () => {
|
||||
const pastDate = new Date(Date.now() - 60 * 60 * 1000) // 1 hour in past
|
||||
const credentials: ClaudeCodeCredentials = {
|
||||
type: "claude",
|
||||
access_token: "test-token",
|
||||
refresh_token: "test-refresh",
|
||||
expired: pastDate.toISOString(),
|
||||
}
|
||||
expect(isTokenExpired(credentials)).toBe(true)
|
||||
})
|
||||
|
||||
test("should return true for token expiring within 5 minute buffer", () => {
|
||||
const almostExpired = new Date(Date.now() + 3 * 60 * 1000) // 3 minutes in future (within 5 min buffer)
|
||||
const credentials: ClaudeCodeCredentials = {
|
||||
type: "claude",
|
||||
access_token: "test-token",
|
||||
refresh_token: "test-refresh",
|
||||
expired: almostExpired.toISOString(),
|
||||
}
|
||||
expect(isTokenExpired(credentials)).toBe(true)
|
||||
})
|
||||
|
||||
test("should return false for token expiring after 5 minute buffer", () => {
|
||||
const notYetExpiring = new Date(Date.now() + 10 * 60 * 1000) // 10 minutes in future
|
||||
const credentials: ClaudeCodeCredentials = {
|
||||
type: "claude",
|
||||
access_token: "test-token",
|
||||
refresh_token: "test-refresh",
|
||||
expired: notYetExpiring.toISOString(),
|
||||
}
|
||||
expect(isTokenExpired(credentials)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("CLAUDE_CODE_OAUTH_CONFIG", () => {
|
||||
test("should have correct configuration values", () => {
|
||||
expect(CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint).toBe("https://claude.ai/oauth/authorize")
|
||||
expect(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint).toBe("https://console.anthropic.com/v1/oauth/token")
|
||||
expect(CLAUDE_CODE_OAUTH_CONFIG.clientId).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e")
|
||||
expect(CLAUDE_CODE_OAUTH_CONFIG.redirectUri).toBe("http://localhost:54545/callback")
|
||||
expect(CLAUDE_CODE_OAUTH_CONFIG.scopes).toBe("org:create_api_key user:profile user:inference")
|
||||
expect(CLAUDE_CODE_OAUTH_CONFIG.callbackPort).toBe(54545)
|
||||
})
|
||||
})
|
||||
})
|
||||
740
src/integrations/claude-code/__tests__/streaming-client.spec.ts
Normal file
740
src/integrations/claude-code/__tests__/streaming-client.spec.ts
Normal file
|
|
@ -0,0 +1,740 @@
|
|||
import { CLAUDE_CODE_API_CONFIG } from "../streaming-client"
|
||||
|
||||
describe("Claude Code Streaming Client", () => {
|
||||
describe("CLAUDE_CODE_API_CONFIG", () => {
|
||||
test("should have correct API endpoint", () => {
|
||||
expect(CLAUDE_CODE_API_CONFIG.endpoint).toBe("https://api.anthropic.com/v1/messages")
|
||||
})
|
||||
|
||||
test("should have correct API version", () => {
|
||||
expect(CLAUDE_CODE_API_CONFIG.version).toBe("2023-06-01")
|
||||
})
|
||||
|
||||
test("should have correct default betas", () => {
|
||||
expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("claude-code-20250219")
|
||||
expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("oauth-2025-04-20")
|
||||
expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("interleaved-thinking-2025-05-14")
|
||||
expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("fine-grained-tool-streaming-2025-05-14")
|
||||
})
|
||||
|
||||
test("should have correct user agent", () => {
|
||||
expect(CLAUDE_CODE_API_CONFIG.userAgent).toBe("claude-cli/1.0.83 (external, cli)")
|
||||
})
|
||||
})
|
||||
|
||||
describe("createStreamingMessage", () => {
|
||||
let originalFetch: typeof global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
test("should make request with correct headers", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(CLAUDE_CODE_API_CONFIG.endpoint),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer test-token",
|
||||
"Content-Type": "application/json",
|
||||
"Anthropic-Version": CLAUDE_CODE_API_CONFIG.version,
|
||||
Accept: "text/event-stream",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("should include correct body parameters", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
maxTokens: 4096,
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
expect(body.model).toBe("claude-3-5-sonnet-20241022")
|
||||
expect(body.stream).toBe(true)
|
||||
expect(body.max_tokens).toBe(4096)
|
||||
// System prompt should have cache_control on the user-provided text
|
||||
expect(body.system).toEqual([
|
||||
{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
|
||||
{ type: "text", text: "You are helpful", cache_control: { type: "ephemeral" } },
|
||||
])
|
||||
// Messages should have cache_control on the last user message
|
||||
expect(body.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("should add cache breakpoints to last two user messages", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [
|
||||
{ role: "user", content: "First message" },
|
||||
{ role: "assistant", content: "Response" },
|
||||
{ role: "user", content: "Second message" },
|
||||
{ role: "assistant", content: "Another response" },
|
||||
{ role: "user", content: "Third message" },
|
||||
],
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
// Only the last two user messages should have cache_control
|
||||
expect(body.messages[0].content).toBe("First message") // No cache_control
|
||||
expect(body.messages[2].content).toEqual([
|
||||
{ type: "text", text: "Second message", cache_control: { type: "ephemeral" } },
|
||||
])
|
||||
expect(body.messages[4].content).toEqual([
|
||||
{ type: "text", text: "Third message", cache_control: { type: "ephemeral" } },
|
||||
])
|
||||
})
|
||||
|
||||
test("should filter out non-Anthropic block types", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "reasoning", text: "Internal reasoning" }, // Should be filtered
|
||||
{ type: "thoughtSignature", data: "encrypted" }, // Should be filtered
|
||||
{ type: "text", text: "Response" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Follow up" }],
|
||||
},
|
||||
] as any,
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
// The assistant message should only have the text block
|
||||
expect(body.messages[1].content).toEqual([{ type: "text", text: "Response" }])
|
||||
})
|
||||
|
||||
test("should preserve thinking and redacted_thinking blocks", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "Let me think...", signature: "abc123" },
|
||||
{ type: "text", text: "Response" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "123", content: "result" }],
|
||||
},
|
||||
] as any,
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
// Thinking blocks should be preserved
|
||||
expect(body.messages[1].content).toContainEqual({
|
||||
type: "thinking",
|
||||
thinking: "Let me think...",
|
||||
signature: "abc123",
|
||||
})
|
||||
// Tool result blocks should be preserved
|
||||
expect(body.messages[2].content).toContainEqual({
|
||||
type: "tool_result",
|
||||
tool_use_id: "123",
|
||||
content: "result",
|
||||
})
|
||||
})
|
||||
|
||||
test("should convert reasoning + thoughtSignature to thinking blocks for interleaved thinking (adjacent)", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
// Adjacent format (simple case)
|
||||
{ type: "reasoning", text: "Let me analyze this problem step by step..." },
|
||||
{
|
||||
type: "thoughtSignature",
|
||||
thoughtSignature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve",
|
||||
},
|
||||
{ type: "tool_use", id: "tool_123", name: "read_file", input: { path: "/test.txt" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }],
|
||||
},
|
||||
] as any,
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
// The reasoning + thoughtSignature should be converted to a proper thinking block
|
||||
expect(body.messages[1].content).toContainEqual({
|
||||
type: "thinking",
|
||||
thinking: "Let me analyze this problem step by step...",
|
||||
signature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve",
|
||||
})
|
||||
// tool_use should be preserved
|
||||
expect(body.messages[1].content).toContainEqual({
|
||||
type: "tool_use",
|
||||
id: "tool_123",
|
||||
name: "read_file",
|
||||
input: { path: "/test.txt" },
|
||||
})
|
||||
// tool_result should be preserved in user message
|
||||
expect(body.messages[2].content).toContainEqual({
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_123",
|
||||
content: "file contents",
|
||||
})
|
||||
})
|
||||
|
||||
test("should convert reasoning + thoughtSignature to thinking blocks when not adjacent (Task.ts format)", async () => {
|
||||
// This matches the actual format from Task.ts where:
|
||||
// - reasoning is PREPENDED (line 769)
|
||||
// - text/tool_use blocks are in the middle
|
||||
// - thoughtSignature is APPENDED (line 808)
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
// Task.ts format: reasoning at START, content in MIDDLE, thoughtSignature at END
|
||||
{ type: "reasoning", text: "Let me analyze this problem step by step..." },
|
||||
{ type: "text", text: "I'll help you with that." },
|
||||
{ type: "tool_use", id: "tool_123", name: "read_file", input: { path: "/test.txt" } },
|
||||
{
|
||||
type: "thoughtSignature",
|
||||
thoughtSignature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }],
|
||||
},
|
||||
] as any,
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
// Check the ORDER of blocks - thinking should be FIRST (at reasoning position)
|
||||
const assistantContent = body.messages[1].content
|
||||
expect(assistantContent[0]).toEqual({
|
||||
type: "thinking",
|
||||
thinking: "Let me analyze this problem step by step...",
|
||||
signature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve",
|
||||
})
|
||||
|
||||
// text block should be second
|
||||
expect(assistantContent[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: "I'll help you with that.",
|
||||
})
|
||||
|
||||
// tool_use should be third
|
||||
expect(assistantContent[2]).toEqual({
|
||||
type: "tool_use",
|
||||
id: "tool_123",
|
||||
name: "read_file",
|
||||
input: { path: "/test.txt" },
|
||||
})
|
||||
|
||||
// thoughtSignature should be filtered out (combined with reasoning)
|
||||
expect(assistantContent.length).toBe(3)
|
||||
expect(assistantContent.some((b: { type: string }) => b.type === "thoughtSignature")).toBe(false)
|
||||
|
||||
// tool_result should be preserved in user message
|
||||
expect(body.messages[2].content).toContainEqual({
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_123",
|
||||
content: "file contents",
|
||||
})
|
||||
})
|
||||
|
||||
test("should strip reasoning_details from messages (provider switching)", async () => {
|
||||
// When switching from OpenRouter/Roo to Claude Code, messages may have
|
||||
// reasoning_details fields that the Anthropic API doesn't accept
|
||||
// This causes errors like: "messages.3.reasoning_details: Extra inputs are not permitted"
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
// Simulate messages with reasoning_details (added by OpenRouter for Gemini/o-series)
|
||||
const messagesWithReasoningDetails = [
|
||||
{ role: "user", content: "Hello" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "I'll help with that." }],
|
||||
// This field is added by OpenRouter/Roo providers for Gemini/OpenAI reasoning
|
||||
reasoning_details: [{ type: "summary_text", summary: "Thinking about the request" }],
|
||||
},
|
||||
{ role: "user", content: "Follow up question" },
|
||||
]
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: messagesWithReasoningDetails as any,
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
// The assistant message should NOT have reasoning_details
|
||||
expect(body.messages[1]).not.toHaveProperty("reasoning_details")
|
||||
// But should still have the content
|
||||
expect(body.messages[1].content).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "text",
|
||||
text: "I'll help with that.",
|
||||
}),
|
||||
)
|
||||
// Only role and content should be present
|
||||
expect(Object.keys(body.messages[1])).toEqual(["role", "content"])
|
||||
})
|
||||
|
||||
test("should strip other non-standard message fields", async () => {
|
||||
// Ensure any non-standard fields are stripped from messages
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockResolvedValue({ done: true, value: undefined }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const messagesWithExtraFields = [
|
||||
{
|
||||
role: "user",
|
||||
content: "Hello",
|
||||
customField: "should be stripped",
|
||||
metadata: { foo: "bar" },
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Response" }],
|
||||
internalId: "123",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: messagesWithExtraFields as any,
|
||||
})
|
||||
|
||||
// Consume the stream
|
||||
for await (const _ of stream) {
|
||||
// Just consume
|
||||
}
|
||||
|
||||
const call = mockFetch.mock.calls[0]
|
||||
const body = JSON.parse(call[1].body)
|
||||
|
||||
// All messages should only have role and content
|
||||
body.messages.forEach((msg: Record<string, unknown>) => {
|
||||
expect(Object.keys(msg).filter((k) => k !== "role" && k !== "content")).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
test("should yield error chunk on non-ok response", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
text: vi.fn().mockResolvedValue('{"error":{"message":"Invalid API key"}}'),
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "invalid-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(chunks[0].type).toBe("error")
|
||||
expect((chunks[0] as { type: "error"; error: string }).error).toBe("Invalid API key")
|
||||
})
|
||||
|
||||
test("should yield error chunk when no response body", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: null,
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks).toHaveLength(1)
|
||||
expect(chunks[0].type).toBe("error")
|
||||
expect((chunks[0] as { type: "error"; error: string }).error).toBe("No response body")
|
||||
})
|
||||
|
||||
test("should parse text SSE events correctly", async () => {
|
||||
const sseData = [
|
||||
'event: content_block_start\ndata: {"index":0,"content_block":{"type":"text","text":"Hello"}}\n\n',
|
||||
'event: content_block_delta\ndata: {"index":0,"delta":{"type":"text_delta","text":" world"}}\n\n',
|
||||
"event: message_stop\ndata: {}\n\n",
|
||||
]
|
||||
|
||||
let readIndex = 0
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockImplementation(() => {
|
||||
if (readIndex < sseData.length) {
|
||||
const value = new TextEncoder().encode(sseData[readIndex++])
|
||||
return Promise.resolve({ done: false, value })
|
||||
}
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Should have text chunks and usage
|
||||
expect(chunks.some((c) => c.type === "text")).toBe(true)
|
||||
expect(chunks.filter((c) => c.type === "text")).toEqual([
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "text", text: " world" },
|
||||
])
|
||||
})
|
||||
|
||||
test("should parse thinking/reasoning SSE events correctly", async () => {
|
||||
const sseData = [
|
||||
'event: content_block_start\ndata: {"index":0,"content_block":{"type":"thinking","thinking":"Let me think..."}}\n\n',
|
||||
'event: content_block_delta\ndata: {"index":0,"delta":{"type":"thinking_delta","thinking":" more thoughts"}}\n\n',
|
||||
"event: message_stop\ndata: {}\n\n",
|
||||
]
|
||||
|
||||
let readIndex = 0
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockImplementation(() => {
|
||||
if (readIndex < sseData.length) {
|
||||
const value = new TextEncoder().encode(sseData[readIndex++])
|
||||
return Promise.resolve({ done: false, value })
|
||||
}
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
expect(chunks.filter((c) => c.type === "reasoning")).toEqual([
|
||||
{ type: "reasoning", text: "Let me think..." },
|
||||
{ type: "reasoning", text: " more thoughts" },
|
||||
])
|
||||
})
|
||||
|
||||
test("should track and yield usage from message events", async () => {
|
||||
const sseData = [
|
||||
'event: message_start\ndata: {"message":{"usage":{"input_tokens":10,"output_tokens":0,"cache_read_input_tokens":5}}}\n\n',
|
||||
'event: message_delta\ndata: {"usage":{"output_tokens":20}}\n\n',
|
||||
"event: message_stop\ndata: {}\n\n",
|
||||
]
|
||||
|
||||
let readIndex = 0
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi.fn().mockImplementation(() => {
|
||||
if (readIndex < sseData.length) {
|
||||
const value = new TextEncoder().encode(sseData[readIndex++])
|
||||
return Promise.resolve({ done: false, value })
|
||||
}
|
||||
return Promise.resolve({ done: true, value: undefined })
|
||||
}),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const { createStreamingMessage } = await import("../streaming-client")
|
||||
|
||||
const stream = createStreamingMessage({
|
||||
accessToken: "test-token",
|
||||
model: "claude-3-5-sonnet-20241022",
|
||||
systemPrompt: "You are helpful",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
})
|
||||
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
const usageChunk = chunks.find((c) => c.type === "usage")
|
||||
expect(usageChunk).toBeDefined()
|
||||
expect(usageChunk).toMatchObject({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 5,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
/**
|
||||
* Filters out image blocks from messages since Claude Code doesn't support images.
|
||||
* Replaces image blocks with text placeholders similar to how VSCode LM provider handles it.
|
||||
*/
|
||||
export function filterMessagesForClaudeCode(
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
): Anthropic.Messages.MessageParam[] {
|
||||
return messages.map((message) => {
|
||||
// Handle simple string messages
|
||||
if (typeof message.content === "string") {
|
||||
return message
|
||||
}
|
||||
|
||||
// Handle complex message structures
|
||||
const filteredContent = message.content.map((block) => {
|
||||
if (block.type === "image") {
|
||||
// Replace image blocks with text placeholders
|
||||
const sourceType = block.source?.type || "unknown"
|
||||
const mediaType = block.source?.media_type || "unknown"
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
|
||||
}
|
||||
}
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: filteredContent,
|
||||
}
|
||||
})
|
||||
}
|
||||
479
src/integrations/claude-code/oauth.ts
Normal file
479
src/integrations/claude-code/oauth.ts
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
import * as crypto from "crypto"
|
||||
import * as http from "http"
|
||||
import { URL } from "url"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
import { z } from "zod"
|
||||
|
||||
// OAuth Configuration
|
||||
export const CLAUDE_CODE_OAUTH_CONFIG = {
|
||||
authorizationEndpoint: "https://claude.ai/oauth/authorize",
|
||||
tokenEndpoint: "https://console.anthropic.com/v1/oauth/token",
|
||||
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
|
||||
redirectUri: "http://localhost:54545/callback",
|
||||
scopes: "org:create_api_key user:profile user:inference",
|
||||
callbackPort: 54545,
|
||||
} as const
|
||||
|
||||
// Token storage key
|
||||
const CLAUDE_CODE_CREDENTIALS_KEY = "claude-code-oauth-credentials"
|
||||
|
||||
// Credentials schema
|
||||
const claudeCodeCredentialsSchema = z.object({
|
||||
type: z.literal("claude"),
|
||||
access_token: z.string().min(1),
|
||||
refresh_token: z.string().min(1),
|
||||
expired: z.string(), // RFC3339 datetime
|
||||
email: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ClaudeCodeCredentials = z.infer<typeof claudeCodeCredentialsSchema>
|
||||
|
||||
// Token response schema from Anthropic
|
||||
const tokenResponseSchema = z.object({
|
||||
access_token: z.string(),
|
||||
refresh_token: z.string(),
|
||||
expires_in: z.number(),
|
||||
email: z.string().optional(),
|
||||
token_type: z.string().optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Generates a cryptographically random PKCE code verifier
|
||||
* Must be 43-128 characters long using unreserved characters
|
||||
*/
|
||||
export function generateCodeVerifier(): string {
|
||||
// Generate 32 random bytes and encode as base64url (will be 43 characters)
|
||||
const buffer = crypto.randomBytes(32)
|
||||
return buffer.toString("base64url")
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the PKCE code challenge from the verifier using S256 method
|
||||
*/
|
||||
export function generateCodeChallenge(verifier: string): string {
|
||||
const hash = crypto.createHash("sha256").update(verifier).digest()
|
||||
return hash.toString("base64url")
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random state parameter for CSRF protection
|
||||
*/
|
||||
export function generateState(): string {
|
||||
return crypto.randomBytes(16).toString("hex")
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a user_id in the format required by Claude Code API
|
||||
* Format: user_<hash>_account_<uuid>_session_<uuid>
|
||||
*/
|
||||
export function generateUserId(email?: string): string {
|
||||
// Generate user hash from email or random bytes
|
||||
const userHash = email
|
||||
? crypto.createHash("sha256").update(email).digest("hex").slice(0, 16)
|
||||
: crypto.randomBytes(8).toString("hex")
|
||||
|
||||
// Generate account UUID (persistent per email or random)
|
||||
const accountUuid = email
|
||||
? crypto.createHash("sha256").update(`account:${email}`).digest("hex").slice(0, 32)
|
||||
: crypto.randomUUID().replace(/-/g, "")
|
||||
|
||||
// Generate session UUID (always random for each request)
|
||||
const sessionUuid = crypto.randomUUID().replace(/-/g, "")
|
||||
|
||||
return `user_${userHash}_account_${accountUuid}_session_${sessionUuid}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the authorization URL for OAuth flow
|
||||
*/
|
||||
export function buildAuthorizationUrl(codeChallenge: string, state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId,
|
||||
redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.redirectUri,
|
||||
scope: CLAUDE_CODE_OAUTH_CONFIG.scopes,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
response_type: "code",
|
||||
state,
|
||||
})
|
||||
|
||||
return `${CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchanges the authorization code for tokens
|
||||
*/
|
||||
export async function exchangeCodeForTokens(
|
||||
code: string,
|
||||
codeVerifier: string,
|
||||
state: string,
|
||||
): Promise<ClaudeCodeCredentials> {
|
||||
const body = {
|
||||
code,
|
||||
state,
|
||||
grant_type: "authorization_code",
|
||||
client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId,
|
||||
redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.redirectUri,
|
||||
code_verifier: codeVerifier,
|
||||
}
|
||||
|
||||
const response = await fetch(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const tokenResponse = tokenResponseSchema.parse(data)
|
||||
|
||||
// Calculate expiry time
|
||||
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000)
|
||||
|
||||
return {
|
||||
type: "claude",
|
||||
access_token: tokenResponse.access_token,
|
||||
refresh_token: tokenResponse.refresh_token,
|
||||
expired: expiresAt.toISOString(),
|
||||
email: tokenResponse.email,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the access token using the refresh token
|
||||
*/
|
||||
export async function refreshAccessToken(refreshToken: string): Promise<ClaudeCodeCredentials> {
|
||||
const body = {
|
||||
grant_type: "refresh_token",
|
||||
client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId,
|
||||
refresh_token: refreshToken,
|
||||
}
|
||||
|
||||
const response = await fetch(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const tokenResponse = tokenResponseSchema.parse(data)
|
||||
|
||||
// Calculate expiry time
|
||||
const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000)
|
||||
|
||||
return {
|
||||
type: "claude",
|
||||
access_token: tokenResponse.access_token,
|
||||
refresh_token: tokenResponse.refresh_token,
|
||||
expired: expiresAt.toISOString(),
|
||||
email: tokenResponse.email,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the credentials are expired (with 5 minute buffer)
|
||||
*/
|
||||
export function isTokenExpired(credentials: ClaudeCodeCredentials): boolean {
|
||||
const expiryTime = new Date(credentials.expired).getTime()
|
||||
const bufferMs = 5 * 60 * 1000 // 5 minutes buffer
|
||||
return Date.now() >= expiryTime - bufferMs
|
||||
}
|
||||
|
||||
/**
|
||||
* ClaudeCodeOAuthManager - Handles OAuth flow and token management
|
||||
*/
|
||||
export class ClaudeCodeOAuthManager {
|
||||
private context: ExtensionContext | null = null
|
||||
private credentials: ClaudeCodeCredentials | null = null
|
||||
private pendingAuth: {
|
||||
codeVerifier: string
|
||||
state: string
|
||||
server?: http.Server
|
||||
} | null = null
|
||||
|
||||
/**
|
||||
* Initialize the OAuth manager with VS Code extension context
|
||||
*/
|
||||
initialize(context: ExtensionContext): void {
|
||||
this.context = context
|
||||
}
|
||||
|
||||
/**
|
||||
* Load credentials from storage
|
||||
*/
|
||||
async loadCredentials(): Promise<ClaudeCodeCredentials | null> {
|
||||
if (!this.context) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const credentialsJson = await this.context.secrets.get(CLAUDE_CODE_CREDENTIALS_KEY)
|
||||
if (!credentialsJson) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(credentialsJson)
|
||||
this.credentials = claudeCodeCredentialsSchema.parse(parsed)
|
||||
return this.credentials
|
||||
} catch (error) {
|
||||
console.error("[claude-code-oauth] Failed to load credentials:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save credentials to storage
|
||||
*/
|
||||
async saveCredentials(credentials: ClaudeCodeCredentials): Promise<void> {
|
||||
if (!this.context) {
|
||||
throw new Error("OAuth manager not initialized")
|
||||
}
|
||||
|
||||
await this.context.secrets.store(CLAUDE_CODE_CREDENTIALS_KEY, JSON.stringify(credentials))
|
||||
this.credentials = credentials
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear credentials from storage
|
||||
*/
|
||||
async clearCredentials(): Promise<void> {
|
||||
if (!this.context) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.context.secrets.delete(CLAUDE_CODE_CREDENTIALS_KEY)
|
||||
this.credentials = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a valid access token, refreshing if necessary
|
||||
*/
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
// Try to load credentials if not already loaded
|
||||
if (!this.credentials) {
|
||||
await this.loadCredentials()
|
||||
}
|
||||
|
||||
if (!this.credentials) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if token is expired and refresh if needed
|
||||
if (isTokenExpired(this.credentials)) {
|
||||
try {
|
||||
const newCredentials = await refreshAccessToken(this.credentials.refresh_token)
|
||||
await this.saveCredentials(newCredentials)
|
||||
} catch (error) {
|
||||
console.error("[claude-code-oauth] Failed to refresh token:", error)
|
||||
// Clear invalid credentials
|
||||
await this.clearCredentials()
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return this.credentials.access_token
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's email from credentials
|
||||
*/
|
||||
async getEmail(): Promise<string | null> {
|
||||
if (!this.credentials) {
|
||||
await this.loadCredentials()
|
||||
}
|
||||
return this.credentials?.email || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is authenticated
|
||||
*/
|
||||
async isAuthenticated(): Promise<boolean> {
|
||||
const token = await this.getAccessToken()
|
||||
return token !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the OAuth authorization flow
|
||||
* Returns the authorization URL to open in browser
|
||||
*/
|
||||
startAuthorizationFlow(): string {
|
||||
// Cancel any existing authorization flow before starting a new one
|
||||
this.cancelAuthorizationFlow()
|
||||
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier)
|
||||
const state = generateState()
|
||||
|
||||
this.pendingAuth = {
|
||||
codeVerifier,
|
||||
state,
|
||||
}
|
||||
|
||||
return buildAuthorizationUrl(codeChallenge, state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local server to receive the OAuth callback
|
||||
* Returns a promise that resolves when authentication is complete
|
||||
*/
|
||||
async waitForCallback(): Promise<ClaudeCodeCredentials> {
|
||||
if (!this.pendingAuth) {
|
||||
throw new Error("No pending authorization flow")
|
||||
}
|
||||
|
||||
// Close any existing server before starting a new one
|
||||
if (this.pendingAuth.server) {
|
||||
try {
|
||||
this.pendingAuth.server.close()
|
||||
} catch {
|
||||
// Ignore errors when closing
|
||||
}
|
||||
this.pendingAuth.server = undefined
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url || "", `http://localhost:${CLAUDE_CODE_OAUTH_CONFIG.callbackPort}`)
|
||||
|
||||
if (url.pathname !== "/callback") {
|
||||
res.writeHead(404)
|
||||
res.end("Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
const code = url.searchParams.get("code")
|
||||
const state = url.searchParams.get("state")
|
||||
const error = url.searchParams.get("error")
|
||||
|
||||
if (error) {
|
||||
res.writeHead(400)
|
||||
res.end(`Authentication failed: ${error}`)
|
||||
reject(new Error(`OAuth error: ${error}`))
|
||||
server.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
res.writeHead(400)
|
||||
res.end("Missing code or state parameter")
|
||||
reject(new Error("Missing code or state parameter"))
|
||||
server.close()
|
||||
return
|
||||
}
|
||||
|
||||
if (state !== this.pendingAuth?.state) {
|
||||
res.writeHead(400)
|
||||
res.end("State mismatch - possible CSRF attack")
|
||||
reject(new Error("State mismatch"))
|
||||
server.close()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = await exchangeCodeForTokens(code, this.pendingAuth.codeVerifier, state)
|
||||
|
||||
await this.saveCredentials(credentials)
|
||||
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" })
|
||||
res.end(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Authentication Successful</title>
|
||||
</head>
|
||||
<body style="font-family: system-ui; text-align: center; padding: 50px;">
|
||||
<h1>✓ 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()
|
||||
883
src/integrations/claude-code/streaming-client.ts
Normal file
883
src/integrations/claude-code/streaming-client.ts
Normal file
|
|
@ -0,0 +1,883 @@
|
|||
import type { Anthropic } from "@anthropic-ai/sdk"
|
||||
import type { ClaudeCodeRateLimitInfo } from "@roo-code/types"
|
||||
import * as os from "os"
|
||||
|
||||
/**
|
||||
* Set of content block types that are valid for Anthropic API.
|
||||
* Only these types will be passed through to the API.
|
||||
* See: https://docs.anthropic.com/en/api/messages
|
||||
*/
|
||||
const VALID_ANTHROPIC_BLOCK_TYPES = new Set([
|
||||
"text",
|
||||
"image",
|
||||
"tool_use",
|
||||
"tool_result",
|
||||
"thinking",
|
||||
"redacted_thinking",
|
||||
"document",
|
||||
])
|
||||
|
||||
/**
|
||||
* Converts internal content blocks to proper Anthropic format for interleaved thinking.
|
||||
*
|
||||
* This handles the conversion of Roo Code's internal block types to Anthropic's API format:
|
||||
* - `reasoning` blocks (with text) + `thoughtSignature` blocks -> `thinking` blocks with `signature`
|
||||
*
|
||||
* According to Anthropic docs:
|
||||
* - During tool use, you must pass `thinking` blocks back to the API for the last assistant message
|
||||
* - The `signature` field is used to verify that thinking blocks were generated by Claude
|
||||
* - Include the complete unmodified block back to the API to maintain reasoning continuity
|
||||
*
|
||||
* IMPORTANT: In Task.ts, the message structure is:
|
||||
* - reasoning block is PREPENDED (at the start)
|
||||
* - text/tool_use blocks are in the middle
|
||||
* - thoughtSignature block is APPENDED (at the end)
|
||||
*
|
||||
* So we need to:
|
||||
* 1. Find the reasoning block and thoughtSignature block anywhere in the content
|
||||
* 2. If both exist, combine them into a thinking block at the REASONING position
|
||||
* 3. Remove the thoughtSignature block from its position
|
||||
* 4. Pass through other valid blocks in their original positions
|
||||
*/
|
||||
/**
|
||||
* Internal type for content blocks that may include non-Anthropic types like
|
||||
* reasoning and thoughtSignature that are used internally by Roo Code.
|
||||
*/
|
||||
interface InternalContentBlock {
|
||||
type: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
signature?: string
|
||||
thoughtSignature?: string
|
||||
summary?: unknown[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
function convertToAnthropicThinkingBlocks(
|
||||
content: Anthropic.Messages.ContentBlockParam[],
|
||||
): Anthropic.Messages.ContentBlockParam[] {
|
||||
// First pass: Find reasoning and thoughtSignature blocks (legacy format from older Task.ts)
|
||||
// Note: New Task.ts stores thinking blocks directly with { type: "thinking", thinking, signature }
|
||||
// which will pass through unchanged since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES
|
||||
let reasoningIndex = -1
|
||||
let reasoningText: string | undefined
|
||||
let thoughtSignatureIndex = -1
|
||||
let signature: string | undefined
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const block = content[i] as unknown as InternalContentBlock
|
||||
|
||||
// Handle legacy reasoning + thoughtSignature format
|
||||
if (block.type === "reasoning" && typeof block.text === "string") {
|
||||
reasoningIndex = i
|
||||
reasoningText = block.text
|
||||
} else if (block.type === "thoughtSignature" && typeof block.thoughtSignature === "string") {
|
||||
thoughtSignatureIndex = i
|
||||
signature = block.thoughtSignature
|
||||
}
|
||||
// Note: thinking blocks with { type: "thinking", thinking: "...", signature: "..." }
|
||||
// are handled naturally since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES
|
||||
}
|
||||
|
||||
// Second pass: Build result with proper thinking block placement
|
||||
const result: Anthropic.Messages.ContentBlockParam[] = []
|
||||
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const block = content[i] as unknown as InternalContentBlock
|
||||
|
||||
if (i === reasoningIndex) {
|
||||
// At the reasoning position, insert a thinking block if we have both reasoning and signature (legacy format)
|
||||
if (reasoningText && signature) {
|
||||
result.push({
|
||||
type: "thinking",
|
||||
thinking: reasoningText,
|
||||
signature: signature,
|
||||
} as unknown as Anthropic.Messages.ContentBlockParam)
|
||||
}
|
||||
// If we only have reasoning without signature, skip it (not valid for API)
|
||||
continue
|
||||
}
|
||||
|
||||
if (i === thoughtSignatureIndex) {
|
||||
// Skip the thoughtSignature block - it was combined with reasoning above
|
||||
continue
|
||||
}
|
||||
|
||||
// Pass through valid Anthropic blocks (includes thinking blocks with proper format)
|
||||
if (VALID_ANTHROPIC_BLOCK_TYPES.has(block.type)) {
|
||||
result.push(content[i])
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out non-Anthropic content blocks from messages before sending to the API.
|
||||
* Also converts internal reasoning + thoughtSignature blocks to proper Anthropic thinking blocks.
|
||||
*
|
||||
* Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept.
|
||||
* This automatically filters out:
|
||||
* - Internal "reasoning" blocks (Roo Code's internal representation) - unless combined with thoughtSignature
|
||||
* - Gemini's "thoughtSignature" blocks (converted to thinking blocks when paired with reasoning)
|
||||
* - Any other unknown block types
|
||||
*
|
||||
* IMPORTANT: This function also strips message-level fields that are not part of the Anthropic API:
|
||||
* - `reasoning_details` (added by OpenRouter/Roo providers for Gemini/OpenAI reasoning)
|
||||
* - Any other non-standard fields added by other providers
|
||||
*
|
||||
* We preserve ALL thinking blocks for these reasons:
|
||||
* 1. Rewind functionality - users need to be able to go back in conversation history
|
||||
* 2. Claude Opus 4.5+ preserves thinking blocks by default (per Anthropic docs)
|
||||
* 3. Interleaved thinking requires thinking blocks to be passed back for tool use continuations
|
||||
*
|
||||
* The API will handle thinking blocks appropriately based on the model:
|
||||
* - Claude Opus 4.5+: thinking blocks preserved (enables cache optimization)
|
||||
* - Older models: thinking blocks stripped from prior turns automatically
|
||||
*/
|
||||
function filterNonAnthropicBlocks(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
|
||||
const result: Anthropic.Messages.MessageParam[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
// Extract ONLY the standard Anthropic message fields (role, content)
|
||||
// This strips out any extra fields like `reasoning_details` that other providers
|
||||
// may have added to the messages (e.g., OpenRouter adds reasoning_details for Gemini/o-series)
|
||||
const { role, content } = message
|
||||
|
||||
if (typeof content === "string") {
|
||||
// Return a clean message with only role and content
|
||||
result.push({ role, content })
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert reasoning + thoughtSignature to proper thinking blocks
|
||||
// and filter out any invalid block types
|
||||
const convertedContent = convertToAnthropicThinkingBlocks(content)
|
||||
|
||||
// If all content was filtered out, skip this message
|
||||
if (convertedContent.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Return a clean message with only role and content (no extra fields)
|
||||
result.push({
|
||||
role,
|
||||
content: convertedContent,
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds cache_control breakpoints to the last two user messages for prompt caching.
|
||||
* This follows Anthropic's recommended pattern:
|
||||
* - Cache the system prompt (handled separately)
|
||||
* - Cache the last text block of the second-to-last user message
|
||||
* - Cache the last text block of the last user message
|
||||
*
|
||||
* According to Anthropic docs:
|
||||
* - System prompts and tools remain cached despite thinking parameter changes
|
||||
* - Message cache breakpoints are invalidated when thinking parameters change
|
||||
* - When using extended thinking, thinking blocks from previous turns are stripped from context
|
||||
*/
|
||||
function addMessageCacheBreakpoints(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
|
||||
// Find indices of user messages
|
||||
const userMsgIndices = messages.reduce(
|
||||
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
|
||||
[] as number[],
|
||||
)
|
||||
|
||||
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
|
||||
const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
|
||||
|
||||
return messages.map((message, index) => {
|
||||
// Only add cache control to the last two user messages
|
||||
if (index !== lastUserMsgIndex && index !== secondLastUserMsgIndex) {
|
||||
return message
|
||||
}
|
||||
|
||||
// Handle string content
|
||||
if (typeof message.content === "string") {
|
||||
return {
|
||||
...message,
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: message.content,
|
||||
cache_control: { type: "ephemeral" as const },
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// Handle array content - add cache_control to the last text block
|
||||
const contentWithCache = message.content.map((block, blockIndex) => {
|
||||
// Find the last text block index
|
||||
let lastTextIndex = -1
|
||||
for (let i = message.content.length - 1; i >= 0; i--) {
|
||||
if ((message.content[i] as { type: string }).type === "text") {
|
||||
lastTextIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Only add cache_control to text blocks (the last one specifically)
|
||||
if (blockIndex === lastTextIndex && (block as { type: string }).type === "text") {
|
||||
const textBlock = block as { type: "text"; text: string }
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: textBlock.text,
|
||||
cache_control: { type: "ephemeral" as const },
|
||||
}
|
||||
}
|
||||
|
||||
return block
|
||||
})
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: contentWithCache,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// API Configuration
|
||||
export const CLAUDE_CODE_API_CONFIG = {
|
||||
endpoint: "https://api.anthropic.com/v1/messages",
|
||||
version: "2023-06-01",
|
||||
defaultBetas: [
|
||||
"prompt-caching-2024-07-31",
|
||||
"claude-code-20250219",
|
||||
"oauth-2025-04-20",
|
||||
"interleaved-thinking-2025-05-14",
|
||||
"fine-grained-tool-streaming-2025-05-14",
|
||||
],
|
||||
userAgent: "claude-cli/1.0.83 (external, cli)",
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Get Claude Code CLI headers - includes Stainless SDK headers and special CLI headers
|
||||
*/
|
||||
function getClaudeCodeCliHeaders(): Record<string, string> {
|
||||
const arch = os.arch()
|
||||
const platform = os.platform()
|
||||
|
||||
// Map platform to OS name - must match Claude CLI format exactly
|
||||
const osMap: Record<string, string> = {
|
||||
darwin: "MacOS", // Note: Claude CLI uses "MacOS" not "macOS"
|
||||
linux: "Linux",
|
||||
win32: "Windows",
|
||||
}
|
||||
|
||||
return {
|
||||
// Claude Code specific headers
|
||||
"Anthropic-Dangerous-Direct-Browser-Access": "true",
|
||||
"X-App": "cli",
|
||||
// Stainless SDK headers as used by Claude CLI
|
||||
"X-Stainless-Lang": "js",
|
||||
"X-Stainless-Package-Version": "0.55.1",
|
||||
"X-Stainless-OS": osMap[platform] || platform,
|
||||
"X-Stainless-Arch": arch,
|
||||
"X-Stainless-Runtime": "node",
|
||||
"X-Stainless-Runtime-Version": "v24.3.0",
|
||||
"X-Stainless-Helper-Method": "stream",
|
||||
"X-Stainless-Timeout": "60",
|
||||
Connection: "keep-alive",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE Event types from Anthropic streaming API
|
||||
*/
|
||||
export type SSEEventType =
|
||||
| "message_start"
|
||||
| "content_block_start"
|
||||
| "content_block_delta"
|
||||
| "content_block_stop"
|
||||
| "message_delta"
|
||||
| "message_stop"
|
||||
| "ping"
|
||||
| "error"
|
||||
|
||||
export interface SSEEvent {
|
||||
event: SSEEventType
|
||||
data: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Thinking configuration for extended thinking mode
|
||||
*/
|
||||
export type ThinkingConfig =
|
||||
| {
|
||||
type: "enabled"
|
||||
budget_tokens: number
|
||||
}
|
||||
| {
|
||||
type: "disabled"
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream message request options
|
||||
*/
|
||||
export interface StreamMessageOptions {
|
||||
accessToken: string
|
||||
model: string
|
||||
systemPrompt: string
|
||||
messages: Anthropic.Messages.MessageParam[]
|
||||
maxTokens?: number
|
||||
thinking?: ThinkingConfig
|
||||
tools?: Anthropic.Messages.Tool[]
|
||||
toolChoice?: Anthropic.Messages.ToolChoice
|
||||
metadata?: {
|
||||
user_id?: string
|
||||
}
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE Parser state that persists across chunks
|
||||
* This is necessary because SSE events can be split across multiple chunks
|
||||
*/
|
||||
interface SSEParserState {
|
||||
buffer: string
|
||||
currentEvent: string | null
|
||||
currentData: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates initial SSE parser state
|
||||
*/
|
||||
function createSSEParserState(): SSEParserState {
|
||||
return {
|
||||
buffer: "",
|
||||
currentEvent: null,
|
||||
currentData: [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses SSE lines from a text chunk
|
||||
* Returns parsed events and updates the state for the next chunk
|
||||
*
|
||||
* The state persists across chunks to handle events that span multiple chunks:
|
||||
* - buffer: incomplete line from previous chunk
|
||||
* - currentEvent: event type if we've seen "event:" but not the complete event
|
||||
* - currentData: accumulated data lines for the current event
|
||||
*/
|
||||
function parseSSEChunk(chunk: string, state: SSEParserState): { events: SSEEvent[]; state: SSEParserState } {
|
||||
const events: SSEEvent[] = []
|
||||
const lines = (state.buffer + chunk).split("\n")
|
||||
|
||||
// Start with the accumulated state
|
||||
let currentEvent = state.currentEvent
|
||||
let currentData = [...state.currentData]
|
||||
let remaining = ""
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
|
||||
// If this is the last line and doesn't end with newline, it might be incomplete
|
||||
if (i === lines.length - 1 && !chunk.endsWith("\n") && line !== "") {
|
||||
remaining = line
|
||||
continue
|
||||
}
|
||||
|
||||
// Empty line signals end of event
|
||||
if (line === "") {
|
||||
if (currentEvent && currentData.length > 0) {
|
||||
try {
|
||||
const dataStr = currentData.join("\n")
|
||||
const data = dataStr === "[DONE]" ? null : JSON.parse(dataStr)
|
||||
events.push({
|
||||
event: currentEvent as SSEEventType,
|
||||
data,
|
||||
})
|
||||
} catch {
|
||||
// Skip malformed events
|
||||
console.error("[claude-code-streaming] Failed to parse SSE data:", currentData.join("\n"))
|
||||
}
|
||||
}
|
||||
currentEvent = null
|
||||
currentData = []
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse event type
|
||||
if (line.startsWith("event: ")) {
|
||||
currentEvent = line.slice(7)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse data
|
||||
if (line.startsWith("data: ")) {
|
||||
currentData.push(line.slice(6))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated state for next chunk
|
||||
return {
|
||||
events,
|
||||
state: {
|
||||
buffer: remaining,
|
||||
currentEvent,
|
||||
currentData,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream chunk types that the handler can yield
|
||||
*/
|
||||
export interface StreamTextChunk {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface StreamReasoningChunk {
|
||||
type: "reasoning"
|
||||
text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A complete thinking block with signature, used for tool use continuations.
|
||||
* According to Anthropic docs:
|
||||
* - During tool use, you must pass thinking blocks back to the API for the last assistant message
|
||||
* - Include the complete unmodified block back to the API to maintain reasoning continuity
|
||||
* - The signature field is used to verify that thinking blocks were generated by Claude
|
||||
*/
|
||||
export interface StreamThinkingCompleteChunk {
|
||||
type: "thinking_complete"
|
||||
index: number
|
||||
thinking: string
|
||||
signature: string
|
||||
}
|
||||
|
||||
export interface StreamToolCallPartialChunk {
|
||||
type: "tool_call_partial"
|
||||
index: number
|
||||
id?: string
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
|
||||
export interface StreamUsageChunk {
|
||||
type: "usage"
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
totalCost?: number
|
||||
}
|
||||
|
||||
export interface StreamErrorChunk {
|
||||
type: "error"
|
||||
error: string
|
||||
}
|
||||
|
||||
export type StreamChunk =
|
||||
| StreamTextChunk
|
||||
| StreamReasoningChunk
|
||||
| StreamThinkingCompleteChunk
|
||||
| StreamToolCallPartialChunk
|
||||
| StreamUsageChunk
|
||||
| StreamErrorChunk
|
||||
|
||||
/**
|
||||
* Creates a streaming message request to the Anthropic API using OAuth
|
||||
*/
|
||||
export async function* createStreamingMessage(options: StreamMessageOptions): AsyncGenerator<StreamChunk> {
|
||||
const { accessToken, model, systemPrompt, messages, maxTokens, thinking, tools, toolChoice, metadata, signal } =
|
||||
options
|
||||
|
||||
// Filter out non-Anthropic blocks before processing
|
||||
const sanitizedMessages = filterNonAnthropicBlocks(messages)
|
||||
|
||||
// Add cache breakpoints to the last two user messages
|
||||
// According to Anthropic docs:
|
||||
// - System prompts and tools remain cached despite thinking parameter changes
|
||||
// - Message cache breakpoints are invalidated when thinking parameters change
|
||||
// - We cache the last two user messages for optimal cache hit rates
|
||||
const messagesWithCache = addMessageCacheBreakpoints(sanitizedMessages)
|
||||
|
||||
// Build request body - match Claude Code format exactly
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
stream: true,
|
||||
messages: messagesWithCache,
|
||||
}
|
||||
|
||||
// Only include max_tokens if explicitly provided
|
||||
if (maxTokens !== undefined) {
|
||||
body.max_tokens = maxTokens
|
||||
}
|
||||
|
||||
// Add thinking configuration for extended thinking mode
|
||||
if (thinking) {
|
||||
body.thinking = thinking
|
||||
}
|
||||
|
||||
// System prompt as array of content blocks (Claude Code format)
|
||||
// Prepend Claude Code branding as required by the API
|
||||
// Add cache_control to the last text block for prompt caching
|
||||
// System prompt caching is preserved even when thinking parameters change
|
||||
body.system = [
|
||||
{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." },
|
||||
...(systemPrompt ? [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }] : []),
|
||||
]
|
||||
|
||||
// Metadata with user_id is required for Claude Code
|
||||
if (metadata) {
|
||||
body.metadata = metadata
|
||||
}
|
||||
|
||||
if (tools && tools.length > 0) {
|
||||
body.tools = tools
|
||||
// Default tool_choice to "auto" when tools are provided (as per spec example)
|
||||
body.tool_choice = toolChoice || { type: "auto" }
|
||||
} else if (toolChoice) {
|
||||
body.tool_choice = toolChoice
|
||||
}
|
||||
|
||||
// Build headers - match Claude Code CLI exactly
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Anthropic-Version": CLAUDE_CODE_API_CONFIG.version,
|
||||
"Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","),
|
||||
Accept: "text/event-stream",
|
||||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||||
"User-Agent": CLAUDE_CODE_API_CONFIG.userAgent,
|
||||
...getClaudeCodeCliHeaders(),
|
||||
}
|
||||
|
||||
// Make the request
|
||||
const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `API request failed: ${response.status} ${response.statusText}`
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText)
|
||||
if (errorJson.error?.message) {
|
||||
errorMessage = errorJson.error.message
|
||||
}
|
||||
} catch {
|
||||
if (errorText) {
|
||||
errorMessage += ` - ${errorText}`
|
||||
}
|
||||
}
|
||||
yield { type: "error", error: errorMessage }
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
yield { type: "error", error: "No response body" }
|
||||
return
|
||||
}
|
||||
|
||||
// Track usage across events
|
||||
let totalInputTokens = 0
|
||||
let totalOutputTokens = 0
|
||||
let cacheReadTokens = 0
|
||||
let cacheWriteTokens = 0
|
||||
|
||||
// Track content blocks by index for proper assembly
|
||||
// This is critical for interleaved thinking - we need to capture complete thinking blocks
|
||||
// with their signatures so they can be passed back to the API for tool use continuations
|
||||
const contentBlocks: Map<
|
||||
number,
|
||||
{
|
||||
type: string
|
||||
text: string
|
||||
signature?: string
|
||||
id?: string
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
> = new Map()
|
||||
|
||||
// Read the stream
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let sseState = createSSEParserState()
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
const result = parseSSEChunk(chunk, sseState)
|
||||
sseState = result.state
|
||||
const events = result.events
|
||||
|
||||
for (const event of events) {
|
||||
const eventData = event.data as Record<string, unknown> | null
|
||||
|
||||
if (!eventData) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch (event.event) {
|
||||
case "message_start": {
|
||||
const message = eventData.message as Record<string, unknown>
|
||||
if (!message) {
|
||||
break
|
||||
}
|
||||
const usage = message.usage as Record<string, number> | undefined
|
||||
if (usage) {
|
||||
totalInputTokens += usage.input_tokens || 0
|
||||
totalOutputTokens += usage.output_tokens || 0
|
||||
cacheReadTokens += usage.cache_read_input_tokens || 0
|
||||
cacheWriteTokens += usage.cache_creation_input_tokens || 0
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "content_block_start": {
|
||||
const contentBlock = eventData.content_block as Record<string, unknown>
|
||||
const index = eventData.index as number
|
||||
|
||||
if (contentBlock) {
|
||||
switch (contentBlock.type) {
|
||||
case "text":
|
||||
// Initialize text block tracking
|
||||
contentBlocks.set(index, {
|
||||
type: "text",
|
||||
text: (contentBlock.text as string) || "",
|
||||
})
|
||||
if (contentBlock.text) {
|
||||
yield { type: "text", text: contentBlock.text as string }
|
||||
}
|
||||
break
|
||||
case "thinking":
|
||||
// Initialize thinking block tracking - critical for interleaved thinking
|
||||
// We need to accumulate the text and capture the signature
|
||||
contentBlocks.set(index, {
|
||||
type: "thinking",
|
||||
text: (contentBlock.thinking as string) || "",
|
||||
})
|
||||
if (contentBlock.thinking) {
|
||||
yield { type: "reasoning", text: contentBlock.thinking as string }
|
||||
}
|
||||
break
|
||||
case "tool_use":
|
||||
contentBlocks.set(index, {
|
||||
type: "tool_use",
|
||||
text: "",
|
||||
id: contentBlock.id as string,
|
||||
name: contentBlock.name as string,
|
||||
arguments: "",
|
||||
})
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index,
|
||||
id: contentBlock.id as string,
|
||||
name: contentBlock.name as string,
|
||||
arguments: undefined,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "content_block_delta": {
|
||||
const delta = eventData.delta as Record<string, unknown>
|
||||
const index = eventData.index as number
|
||||
const block = contentBlocks.get(index)
|
||||
|
||||
if (delta) {
|
||||
switch (delta.type) {
|
||||
case "text_delta":
|
||||
if (delta.text) {
|
||||
// Accumulate text
|
||||
if (block && block.type === "text") {
|
||||
block.text += delta.text as string
|
||||
}
|
||||
yield { type: "text", text: delta.text as string }
|
||||
}
|
||||
break
|
||||
case "thinking_delta":
|
||||
if (delta.thinking) {
|
||||
// Accumulate thinking text
|
||||
if (block && block.type === "thinking") {
|
||||
block.text += delta.thinking as string
|
||||
}
|
||||
yield { type: "reasoning", text: delta.thinking as string }
|
||||
}
|
||||
break
|
||||
case "signature_delta":
|
||||
// Capture the signature for the thinking block
|
||||
// This is critical for interleaved thinking - the signature
|
||||
// must be included when passing thinking blocks back to the API
|
||||
if (delta.signature && block && block.type === "thinking") {
|
||||
block.signature = delta.signature as string
|
||||
}
|
||||
break
|
||||
case "input_json_delta":
|
||||
if (block && block.type === "tool_use") {
|
||||
block.arguments = (block.arguments || "") + (delta.partial_json as string)
|
||||
}
|
||||
yield {
|
||||
type: "tool_call_partial",
|
||||
index,
|
||||
id: undefined,
|
||||
name: undefined,
|
||||
arguments: delta.partial_json as string,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "content_block_stop": {
|
||||
// When a content block completes, emit complete thinking blocks
|
||||
// This enables the caller to preserve them for tool use continuations
|
||||
const index = eventData.index as number
|
||||
const block = contentBlocks.get(index)
|
||||
|
||||
if (block && block.type === "thinking" && block.signature) {
|
||||
// Emit the complete thinking block with signature
|
||||
// This is required for interleaved thinking with tool use
|
||||
yield {
|
||||
type: "thinking_complete",
|
||||
index,
|
||||
thinking: block.text,
|
||||
signature: block.signature,
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "message_delta": {
|
||||
const usage = eventData.usage as Record<string, number> | undefined
|
||||
if (usage && usage.output_tokens !== undefined) {
|
||||
// output_tokens in message_delta is the running total, not a delta
|
||||
// So we replace rather than add
|
||||
totalOutputTokens = usage.output_tokens
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "message_stop": {
|
||||
// Yield final usage chunk
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined,
|
||||
cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined,
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "error": {
|
||||
const errorData = eventData.error as Record<string, unknown>
|
||||
yield {
|
||||
type: "error",
|
||||
error: (errorData?.message as string) || "Unknown streaming error",
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse rate limit headers from a response into a structured format
|
||||
*/
|
||||
function parseRateLimitHeaders(headers: Headers): ClaudeCodeRateLimitInfo {
|
||||
const getHeader = (name: string): string | null => headers.get(name)
|
||||
const parseFloat = (val: string | null): number => (val ? Number.parseFloat(val) : 0)
|
||||
const parseInt = (val: string | null): number => (val ? Number.parseInt(val, 10) : 0)
|
||||
|
||||
return {
|
||||
fiveHour: {
|
||||
status: getHeader("anthropic-ratelimit-unified-5h-status") || "unknown",
|
||||
utilization: parseFloat(getHeader("anthropic-ratelimit-unified-5h-utilization")),
|
||||
resetTime: parseInt(getHeader("anthropic-ratelimit-unified-5h-reset")),
|
||||
},
|
||||
weekly: {
|
||||
status: getHeader("anthropic-ratelimit-unified-7d_sonnet-status") || "unknown",
|
||||
utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d_sonnet-utilization")),
|
||||
resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d_sonnet-reset")),
|
||||
},
|
||||
weeklyUnified: {
|
||||
status: getHeader("anthropic-ratelimit-unified-7d-status") || "unknown",
|
||||
utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d-utilization")),
|
||||
resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d-reset")),
|
||||
},
|
||||
representativeClaim: getHeader("anthropic-ratelimit-unified-representative-claim") || undefined,
|
||||
overage: {
|
||||
status: getHeader("anthropic-ratelimit-unified-overage-status") || "unknown",
|
||||
disabledReason: getHeader("anthropic-ratelimit-unified-overage-disabled-reason") || undefined,
|
||||
},
|
||||
fallbackPercentage: parseFloat(getHeader("anthropic-ratelimit-unified-fallback-percentage")) || undefined,
|
||||
organizationId: getHeader("anthropic-organization-id") || undefined,
|
||||
fetchedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch rate limit information by making a minimal API call
|
||||
* Uses a small request to get the response headers containing rate limit data
|
||||
*/
|
||||
export async function fetchRateLimitInfo(accessToken: string): Promise<ClaudeCodeRateLimitInfo> {
|
||||
// Build minimal request body - use haiku for speed and lowest cost
|
||||
const body = {
|
||||
model: "claude-haiku-4-5",
|
||||
max_tokens: 1,
|
||||
system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }],
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
}
|
||||
|
||||
// Build headers - match Claude Code CLI exactly
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"Anthropic-Version": CLAUDE_CODE_API_CONFIG.version,
|
||||
"Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","),
|
||||
"User-Agent": CLAUDE_CODE_API_CONFIG.userAgent,
|
||||
...getClaudeCodeCliHeaders(),
|
||||
}
|
||||
|
||||
// Make the request
|
||||
const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(30000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `API request failed: ${response.status} ${response.statusText}`
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText)
|
||||
if (errorJson.error?.message) {
|
||||
errorMessage = errorJson.error.message
|
||||
}
|
||||
} catch {
|
||||
if (errorText) {
|
||||
errorMessage += ` - ${errorText}`
|
||||
}
|
||||
}
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
// Parse rate limit headers from the response
|
||||
return parseRateLimitHeaders(response.headers)
|
||||
}
|
||||
|
|
@ -132,6 +132,7 @@ export interface ExtensionMessage {
|
|||
| "interactionRequired"
|
||||
| "browserSessionUpdate"
|
||||
| "browserSessionNavigate"
|
||||
| "claudeCodeRateLimits"
|
||||
text?: string
|
||||
payload?: any // Add a generic payload for now, can refine later
|
||||
// Checkpoint warning message
|
||||
|
|
@ -358,6 +359,7 @@ export type ExtensionState = Pick<
|
|||
remoteControlEnabled: boolean
|
||||
taskSyncEnabled: boolean
|
||||
featureRoomoteControlEnabled: boolean
|
||||
claudeCodeIsAuthenticated?: boolean
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ export interface WebviewMessage {
|
|||
| "cloudLandingPageSignIn"
|
||||
| "rooCloudSignOut"
|
||||
| "rooCloudManualUrl"
|
||||
| "claudeCodeSignIn"
|
||||
| "claudeCodeSignOut"
|
||||
| "switchOrganization"
|
||||
| "condenseTaskContextRequest"
|
||||
| "requestIndexingStatus"
|
||||
|
|
@ -176,6 +178,7 @@ export interface WebviewMessage {
|
|||
| "browserPanelDidLaunch"
|
||||
| "openDebugApiHistory"
|
||||
| "openDebugUiHistory"
|
||||
| "requestClaudeCodeRateLimits"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ const ApiOptions = ({
|
|||
setErrorMessage,
|
||||
}: ApiOptionsProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
const { organizationAllowList, cloudIsAuthenticated } = useExtensionState()
|
||||
const { organizationAllowList, cloudIsAuthenticated, claudeCodeIsAuthenticated } = useExtensionState()
|
||||
|
||||
const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => {
|
||||
const headers = apiConfiguration?.openAiHeaders || {}
|
||||
|
|
@ -567,6 +567,7 @@ const ApiOptions = ({
|
|||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
simplifySettings={fromWelcomeView}
|
||||
claudeCodeIsAuthenticated={claudeCodeIsAuthenticated}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
@ -779,7 +780,8 @@ const ApiOptions = ({
|
|||
<Featherless apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
|
||||
)}
|
||||
|
||||
{selectedProviderModels.length > 0 && (
|
||||
{/* Skip generic model picker for claude-code since it has its own in ClaudeCode.tsx */}
|
||||
{selectedProviderModels.length > 0 && selectedProvider !== "claude-code" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-medium mb-1">{t("settings:providers.model")}</label>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ type ModelInfoViewProps = {
|
|||
modelInfo?: ModelInfo
|
||||
isDescriptionExpanded: boolean
|
||||
setIsDescriptionExpanded: (isExpanded: boolean) => void
|
||||
hidePricing?: boolean
|
||||
}
|
||||
|
||||
export const ModelInfoView = ({
|
||||
|
|
@ -22,6 +23,7 @@ export const ModelInfoView = ({
|
|||
modelInfo,
|
||||
isDescriptionExpanded,
|
||||
setIsDescriptionExpanded,
|
||||
hidePricing,
|
||||
}: ModelInfoViewProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
|
|
@ -95,7 +97,8 @@ export const ModelInfoView = ({
|
|||
),
|
||||
].filter(Boolean)
|
||||
|
||||
const infoItems = shouldShowTierPricingTable ? baseInfoItems : [...baseInfoItems, ...priceInfoItems]
|
||||
// Show pricing info unless hidePricing is set or tier pricing table is shown
|
||||
const infoItems = shouldShowTierPricingTable || hidePricing ? baseInfoItems : [...baseInfoItems, ...priceInfoItems]
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -113,7 +116,7 @@ export const ModelInfoView = ({
|
|||
))}
|
||||
</div>
|
||||
|
||||
{shouldShowTierPricingTable && (
|
||||
{shouldShowTierPricingTable && !hidePricing && (
|
||||
<div className="mt-2">
|
||||
<div className="text-xs text-vscode-descriptionForeground mb-1">
|
||||
{t("settings:serviceTier.pricingTableTitle")}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,10 @@ interface ModelPickerProps {
|
|||
value: ProviderSettings[K],
|
||||
isUserAction?: boolean,
|
||||
) => void
|
||||
organizationAllowList: OrganizationAllowList
|
||||
organizationAllowList?: OrganizationAllowList
|
||||
errorMessage?: string
|
||||
simplifySettings?: boolean
|
||||
hidePricing?: boolean
|
||||
}
|
||||
|
||||
export const ModelPicker = ({
|
||||
|
|
@ -67,6 +68,7 @@ export const ModelPicker = ({
|
|||
organizationAllowList,
|
||||
errorMessage,
|
||||
simplifySettings,
|
||||
hidePricing,
|
||||
}: ModelPickerProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
|
|
@ -262,20 +264,23 @@ export const ModelPicker = ({
|
|||
modelInfo={selectedModelInfo}
|
||||
isDescriptionExpanded={isDescriptionExpanded}
|
||||
setIsDescriptionExpanded={setIsDescriptionExpanded}
|
||||
hidePricing={hidePricing}
|
||||
/>
|
||||
)}
|
||||
<div className="text-sm text-vscode-descriptionForeground">
|
||||
<Trans
|
||||
i18nKey="settings:modelPicker.automaticFetch"
|
||||
components={{
|
||||
serviceLink: <VSCodeLink href={serviceUrl} className="text-sm" />,
|
||||
defaultModelLink: (
|
||||
<VSCodeLink onClick={() => onSelect(defaultModelId)} className="text-sm" />
|
||||
),
|
||||
}}
|
||||
values={{ serviceName, defaultModelId }}
|
||||
/>
|
||||
</div>
|
||||
{!hidePricing && (
|
||||
<div className="text-sm text-vscode-descriptionForeground">
|
||||
<Trans
|
||||
i18nKey="settings:modelPicker.automaticFetch"
|
||||
components={{
|
||||
serviceLink: <VSCodeLink href={serviceUrl} className="text-sm" />,
|
||||
defaultModelLink: (
|
||||
<VSCodeLink onClick={() => onSelect(defaultModelId)} className="text-sm" />
|
||||
),
|
||||
}}
|
||||
values={{ serviceName, defaultModelId }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -1,63 +1,68 @@
|
|||
import React from "react"
|
||||
import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { type ProviderSettings } from "@roo-code/types"
|
||||
import { type ProviderSettings, claudeCodeDefaultModelId, claudeCodeModels } from "@roo-code/types"
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import { Slider } from "@src/components/ui"
|
||||
import { Button } from "@src/components/ui"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
import { ModelPicker } from "../ModelPicker"
|
||||
import { ClaudeCodeRateLimitDashboard } from "./ClaudeCodeRateLimitDashboard"
|
||||
|
||||
interface ClaudeCodeProps {
|
||||
apiConfiguration: ProviderSettings
|
||||
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
|
||||
simplifySettings?: boolean
|
||||
claudeCodeIsAuthenticated?: boolean
|
||||
}
|
||||
|
||||
export const ClaudeCode: React.FC<ClaudeCodeProps> = ({ apiConfiguration, setApiConfigurationField }) => {
|
||||
export const ClaudeCode: React.FC<ClaudeCodeProps> = ({
|
||||
apiConfiguration,
|
||||
setApiConfigurationField,
|
||||
simplifySettings,
|
||||
claudeCodeIsAuthenticated = false,
|
||||
}) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const handleInputChange = (e: Event | React.FormEvent<HTMLElement>) => {
|
||||
const element = e.target as HTMLInputElement
|
||||
setApiConfigurationField("claudeCodePath", element.value)
|
||||
}
|
||||
|
||||
const maxOutputTokens = apiConfiguration?.claudeCodeMaxOutputTokens || 8000
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.claudeCodePath || ""}
|
||||
style={{ width: "100%", marginTop: 3 }}
|
||||
type="text"
|
||||
onInput={handleInputChange}
|
||||
placeholder={t("settings:providers.claudeCode.placeholder")}>
|
||||
{t("settings:providers.claudeCode.pathLabel")}
|
||||
</VSCodeTextField>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: "12px",
|
||||
marginTop: 3,
|
||||
color: "var(--vscode-descriptionForeground)",
|
||||
}}>
|
||||
{t("settings:providers.claudeCode.description")}
|
||||
</p>
|
||||
{/* Authentication Section */}
|
||||
<div className="flex flex-col gap-2">
|
||||
{claudeCodeIsAuthenticated ? (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => vscode.postMessage({ type: "claudeCodeSignOut" })}>
|
||||
{t("settings:providers.claudeCode.signOutButton", {
|
||||
defaultValue: "Sign Out",
|
||||
})}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => vscode.postMessage({ type: "claudeCodeSignIn" })}
|
||||
className="w-fit">
|
||||
{t("settings:providers.claudeCode.signInButton", {
|
||||
defaultValue: "Sign in to Claude Code",
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-medium">{t("settings:providers.claudeCode.maxTokensLabel")}</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Slider
|
||||
min={8000}
|
||||
max={64000}
|
||||
step={1024}
|
||||
value={[maxOutputTokens]}
|
||||
onValueChange={([value]) => setApiConfigurationField("claudeCodeMaxOutputTokens", value)}
|
||||
/>
|
||||
<div className="w-16 text-sm text-center">{maxOutputTokens}</div>
|
||||
</div>
|
||||
<p className="text-sm text-vscode-descriptionForeground mt-1">
|
||||
{t("settings:providers.claudeCode.maxTokensDescription")}
|
||||
</p>
|
||||
</div>
|
||||
{/* Rate Limit Dashboard - only shown when authenticated */}
|
||||
<ClaudeCodeRateLimitDashboard isAuthenticated={claudeCodeIsAuthenticated} />
|
||||
|
||||
{/* Model Picker */}
|
||||
<ModelPicker
|
||||
apiConfiguration={apiConfiguration}
|
||||
setApiConfigurationField={setApiConfigurationField}
|
||||
defaultModelId={claudeCodeDefaultModelId}
|
||||
models={claudeCodeModels}
|
||||
modelIdKey="apiModelId"
|
||||
serviceName="Claude Code"
|
||||
serviceUrl="https://claude.ai"
|
||||
simplifySettings={simplifySettings}
|
||||
hidePricing
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,181 @@
|
|||
import React, { useEffect, useState, useCallback } from "react"
|
||||
import type { ClaudeCodeRateLimitInfo } from "@roo-code/types"
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
||||
interface ClaudeCodeRateLimitDashboardProps {
|
||||
isAuthenticated: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a Unix timestamp reset time into a human-readable duration
|
||||
*/
|
||||
function formatResetTime(resetTimestamp: number): string {
|
||||
if (!resetTimestamp) return "N/A"
|
||||
|
||||
const now = Date.now() / 1000 // Current time in seconds
|
||||
const diff = resetTimestamp - now
|
||||
|
||||
if (diff <= 0) return "Now"
|
||||
|
||||
const hours = Math.floor(diff / 3600)
|
||||
const minutes = Math.floor((diff % 3600) / 60)
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
const remainingHours = hours % 24
|
||||
return `${days}d ${remainingHours}h`
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes}m`
|
||||
}
|
||||
|
||||
return `${minutes}m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats utilization as a percentage
|
||||
*/
|
||||
function formatUtilization(utilization: number): string {
|
||||
return `${(utilization * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress bar component for displaying usage
|
||||
*/
|
||||
const UsageProgressBar: React.FC<{ utilization: number; label: string }> = ({ utilization, label }) => {
|
||||
const percentage = Math.min(utilization * 100, 100)
|
||||
const isWarning = percentage >= 70
|
||||
const isCritical = percentage >= 90
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="text-xs text-vscode-descriptionForeground mb-1">{label}</div>
|
||||
<div className="w-full bg-vscode-input-background rounded-sm h-2 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
isCritical
|
||||
? "bg-vscode-errorForeground"
|
||||
: isWarning
|
||||
? "bg-vscode-editorWarning-foreground"
|
||||
: "bg-vscode-button-background"
|
||||
}`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ClaudeCodeRateLimitDashboard: React.FC<ClaudeCodeRateLimitDashboardProps> = ({ isAuthenticated }) => {
|
||||
const [rateLimits, setRateLimits] = useState<ClaudeCodeRateLimitInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchRateLimits = useCallback(() => {
|
||||
if (!isAuthenticated) {
|
||||
setRateLimits(null)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
vscode.postMessage({ type: "requestClaudeCodeRateLimits" })
|
||||
}, [isAuthenticated])
|
||||
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
if (message.type === "claudeCodeRateLimits") {
|
||||
setIsLoading(false)
|
||||
if (message.error) {
|
||||
setError(message.error)
|
||||
setRateLimits(null)
|
||||
} else if (message.values) {
|
||||
setRateLimits(message.values)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
return () => window.removeEventListener("message", handleMessage)
|
||||
}, [])
|
||||
|
||||
// Fetch rate limits when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchRateLimits()
|
||||
}
|
||||
}, [isAuthenticated, fetchRateLimits])
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isLoading && !rateLimits) {
|
||||
return (
|
||||
<div className="bg-vscode-editor-background border border-vscode-panel-border rounded-md p-3">
|
||||
<div className="text-sm text-vscode-descriptionForeground">Loading rate limits...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-vscode-editor-background border border-vscode-panel-border rounded-md p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-vscode-errorForeground">Failed to load rate limits</div>
|
||||
<button
|
||||
onClick={fetchRateLimits}
|
||||
className="text-xs text-vscode-textLink-foreground hover:text-vscode-textLink-activeForeground cursor-pointer bg-transparent border-none">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!rateLimits) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-vscode-editor-background border border-vscode-panel-border rounded-md p-3">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-medium text-vscode-foreground">Usage Limits</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 5-hour limit */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-vscode-foreground">
|
||||
Limit: {rateLimits.representativeClaim || "5-hour"}
|
||||
</span>
|
||||
<span className="text-vscode-descriptionForeground">
|
||||
{formatUtilization(rateLimits.fiveHour.utilization)} used • resets in{" "}
|
||||
{formatResetTime(rateLimits.fiveHour.resetTime)}
|
||||
</span>
|
||||
</div>
|
||||
<UsageProgressBar utilization={rateLimits.fiveHour.utilization} label="" />
|
||||
</div>
|
||||
|
||||
{/* Weekly limit (if available) */}
|
||||
{rateLimits.weeklyUnified && rateLimits.weeklyUnified.utilization > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-vscode-foreground">Weekly</span>
|
||||
<span className="text-vscode-descriptionForeground">
|
||||
{formatUtilization(rateLimits.weeklyUnified.utilization)} used • resets in{" "}
|
||||
{formatResetTime(rateLimits.weeklyUnified.resetTime)}
|
||||
</span>
|
||||
</div>
|
||||
<UsageProgressBar utilization={rateLimits.weeklyUnified.utilization} label="" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ function SelectTrigger({ className, children, ...props }: React.ComponentProps<t
|
|||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:border-destructive flex h-7 w-fit items-center justify-between gap-2 rounded-xs px-3 py-2 whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
|
||||
"data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground aria-invalid:border-destructive flex h-7 w-fit items-center justify-between gap-2 rounded-full px-3 py-2 whitespace-nowrap transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 cursor-pointer",
|
||||
"border border-vscode-dropdown-border aria-expanded:border-vscode-focusBorder focus-visible:border-vscode-focusBorder",
|
||||
"bg-vscode-dropdown-background hover:bg-transparent",
|
||||
"text-vscode-dropdown-foreground",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue