Add support for OpenRouter and AWS Bedrock

This commit is contained in:
Saoud Rizwan 2024-08-03 14:24:56 -04:00
parent d441950b7f
commit c09a8462d7
19 changed files with 4458 additions and 194 deletions

3794
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -122,12 +122,14 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/bedrock-sdk": "^0.10.2",
"@anthropic-ai/sdk": "^0.24.3",
"@vscode/codicons": "^0.0.36",
"default-shell": "^2.2.0",
"diff": "^5.2.0",
"execa": "^9.3.0",
"globby": "^14.0.2",
"openai": "^4.54.0",
"os-name": "^6.0.0",
"p-wait-for": "^5.0.2",
"serialize-error": "^11.0.3",

View File

@ -8,15 +8,17 @@ import osName from "os-name"
import pWaitFor from "p-wait-for"
import * as path from "path"
import { serializeError } from "serialize-error"
import treeKill from "tree-kill"
import * as vscode from "vscode"
import { ApiHandler, buildApiHandler } from "./api"
import { listFiles, parseSourceCodeForDefinitionsTopLevel } from "./parse-source-code"
import { ClaudeDevProvider } from "./providers/ClaudeDevProvider"
import { ApiConfiguration } from "./shared/api"
import { ClaudeRequestResult } from "./shared/ClaudeRequestResult"
import { DEFAULT_MAX_REQUESTS_PER_TASK } from "./shared/Constants"
import { ClaudeAsk, ClaudeMessage, ClaudeSay, ClaudeSayTool } from "./shared/ExtensionMessage"
import { Tool, ToolName } from "./shared/Tool"
import { ClaudeAskResponse } from "./shared/WebviewMessage"
import treeKill from "tree-kill"
const SYSTEM_PROMPT =
() => `You are Claude Dev, a highly skilled software developer with extensive knowledge in many programming languages, frameworks, design patterns, and best practices.
@ -225,7 +227,7 @@ const tools: Tool[] = [
]
export class ClaudeDev {
private client: Anthropic
private api: ApiHandler
private maxRequestsPerTask: number
private requestCount = 0
apiConversationHistory: Anthropic.MessageParam[] = []
@ -236,16 +238,21 @@ export class ClaudeDev {
private providerRef: WeakRef<ClaudeDevProvider>
abort: boolean = false
constructor(provider: ClaudeDevProvider, task: string, apiKey: string, maxRequestsPerTask?: number) {
constructor(
provider: ClaudeDevProvider,
task: string,
apiConfiguration: ApiConfiguration,
maxRequestsPerTask?: number
) {
this.providerRef = new WeakRef(provider)
this.client = new Anthropic({ apiKey })
this.api = buildApiHandler(apiConfiguration)
this.maxRequestsPerTask = maxRequestsPerTask ?? DEFAULT_MAX_REQUESTS_PER_TASK
this.startTask(task)
}
updateApiKey(apiKey: string) {
this.client = new Anthropic({ apiKey })
updateApi(apiConfiguration: ApiConfiguration) {
this.api = buildApiHandler(apiConfiguration)
}
updateMaxRequestsPerTask(maxRequestsPerTask: number | undefined) {
@ -699,22 +706,7 @@ export class ClaudeDev {
async attemptApiRequest(): Promise<Anthropic.Messages.Message> {
try {
const response = await this.client.messages.create(
{
model: "claude-3-5-sonnet-20240620", // https://docs.anthropic.com/en/docs/about-claude/models
// beta max tokens
max_tokens: 8192,
system: SYSTEM_PROMPT(),
messages: this.apiConversationHistory,
tools: tools,
tool_choice: { type: "auto" },
},
{
// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers
headers: { "anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15" },
}
)
return response
return await this.api.createMessage(SYSTEM_PROMPT(), this.apiConversationHistory, tools)
} catch (error) {
const { response } = await this.ask(
"api_req_failed",

34
src/api/anthropic.ts Normal file
View File

@ -0,0 +1,34 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler } from "."
import { ApiHandlerOptions } from "../shared/api"
export class AnthropicHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: Anthropic
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new Anthropic({ apiKey: this.options.apiKey })
}
async createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools: Anthropic.Messages.Tool[]
): Promise<Anthropic.Messages.Message> {
return await this.client.messages.create(
{
model: "claude-3-5-sonnet-20240620", // https://docs.anthropic.com/en/docs/about-claude/models
max_tokens: 8192, // beta max tokens
system: systemPrompt,
messages,
tools,
tool_choice: { type: "auto" },
},
{
// https://github.com/anthropics/anthropic-sdk-typescript?tab=readme-ov-file#default-headers
headers: { "anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15" },
}
)
}
}

39
src/api/bedrock.ts Normal file
View File

@ -0,0 +1,39 @@
import AnthropicBedrock from "@anthropic-ai/bedrock-sdk"
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandlerOptions } from "../shared/api"
import { ApiHandler } from "."
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
export class AwsBedrockHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: AnthropicBedrock
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new AnthropicBedrock({
// Authenticate by either providing the keys below or use the default AWS credential providers, such as
// using ~/.aws/credentials or the "AWS_SECRET_ACCESS_KEY" and "AWS_ACCESS_KEY_ID" environment variables.
awsAccessKey: this.options.awsAccessKey,
awsSecretKey: this.options.awsSecretKey,
// awsRegion changes the aws region to which the request is made. By default, we read AWS_REGION,
// and if that's not present, we default to us-east-1. Note that we do not read ~/.aws/config for the region.
awsRegion: this.options.awsRegion,
})
}
async createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools: Anthropic.Messages.Tool[]
): Promise<Anthropic.Messages.Message> {
return await this.client.messages.create({
model: "anthropic.claude-3-5-sonnet-20240620-v1:0",
max_tokens: 4096,
system: systemPrompt,
messages,
tools,
tool_choice: { type: "auto" },
})
}
}

27
src/api/index.ts Normal file
View File

@ -0,0 +1,27 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiConfiguration } from "../shared/api"
import { AnthropicHandler } from "./anthropic"
import { AwsBedrockHandler } from "./bedrock"
import { OpenRouterHandler } from "./openrouter"
export interface ApiHandler {
createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools: Anthropic.Messages.Tool[]
): Promise<Anthropic.Messages.Message>
}
export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
const { apiProvider, ...options } = configuration
switch (apiProvider) {
case "anthropic":
return new AnthropicHandler(options)
case "openrouter":
return new OpenRouterHandler(options)
case "bedrock":
return new AwsBedrockHandler(options)
default:
throw new Error(`Unknown API provider: ${apiProvider}`)
}
}

140
src/api/openrouter.ts Normal file
View File

@ -0,0 +1,140 @@
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiHandlerOptions } from "../shared/api"
import { ApiHandler } from "."
export class OpenRouterHandler implements ApiHandler {
private options: ApiHandlerOptions
private client: OpenAI
constructor(options: ApiHandlerOptions) {
this.options = options
this.client = new OpenAI({
baseURL: "https://openrouter.ai/api/v1",
apiKey: this.options.openRouterApiKey,
defaultHeaders: {
"HTTP-Referer": "https://github.com/saoudrizwan/claude-dev", // Optional, for including your app on openrouter.ai rankings.
"X-Title": "claude-dev", // Optional. Shows in rankings on openrouter.ai.
},
})
}
async createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools: Anthropic.Messages.Tool[]
): Promise<Anthropic.Messages.Message> {
// Convert Anthropic messages to OpenAI format
const openAIMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...messages.map((msg) => {
const baseMessage = {
content:
typeof msg.content === "string"
? msg.content
: msg.content
.map((part) => {
if ("text" in part) {
return part.text
} else if ("source" in part) {
return { type: "image_url" as const, image_url: { url: part.source.data } }
}
return ""
})
.filter(Boolean)
.join("\n"),
}
if (msg.role === "user") {
return { ...baseMessage, role: "user" as const }
} else if (msg.role === "assistant") {
const assistantMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
...baseMessage,
role: "assistant" as const,
}
if ("tool_calls" in msg && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
assistantMessage.tool_calls = msg.tool_calls.map((toolCall) => ({
id: toolCall.id,
type: "function",
function: {
name: toolCall.function.name,
arguments: JSON.stringify(toolCall.function.arguments),
},
}))
}
return assistantMessage
}
throw new Error(`Unsupported message role: ${msg.role}`)
}),
]
// Convert Anthropic tools to OpenAI tools
const openAITools: OpenAI.Chat.ChatCompletionTool[] = tools.map((tool) => ({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.input_schema,
},
}))
const completion = await this.client.chat.completions.create({
model: "anthropic/claude-3.5-sonnet:beta",
max_tokens: 4096,
messages: openAIMessages,
tools: openAITools,
tool_choice: "auto",
})
// Convert OpenAI response to Anthropic format
const openAIMessage = completion.choices[0].message
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
type: "message",
role: "assistant",
content: [
{
type: "text",
text: openAIMessage.content || "",
},
],
model: completion.model,
stop_reason: this.mapFinishReason(completion.choices[0].finish_reason),
stop_sequence: null,
usage: {
input_tokens: completion.usage?.prompt_tokens || 0,
output_tokens: completion.usage?.completion_tokens || 0,
},
}
if (openAIMessage.tool_calls && openAIMessage.tool_calls.length > 0) {
anthropicMessage.content.push(
...openAIMessage.tool_calls.map((toolCall) => ({
type: "tool_use" as const,
id: toolCall.id,
name: toolCall.function.name,
input: JSON.parse(toolCall.function.arguments || "{}"),
}))
)
}
return anthropicMessage
}
private mapFinishReason(
finishReason: OpenAI.Chat.ChatCompletion.Choice["finish_reason"]
): Anthropic.Messages.Message["stop_reason"] {
switch (finishReason) {
case "stop":
return "end_turn"
case "length":
return "max_tokens"
case "tool_calls":
return "tool_use"
case "content_filter":
return null // Anthropic doesn't have an exact equivalent
default:
return null
}
}
}

View File

@ -1,12 +1,12 @@
import { Uri, Webview } from "vscode"
//import * as weather from "weather-js"
import { Anthropic } from "@anthropic-ai/sdk"
import os from "os"
import * as path from "path"
import * as vscode from "vscode"
import { ClaudeDev } from "../ClaudeDev"
import { ClaudeMessage, ExtensionMessage } from "../shared/ExtensionMessage"
import { ApiProvider } from "../shared/api"
import { ExtensionMessage } from "../shared/ExtensionMessage"
import { WebviewMessage } from "../shared/WebviewMessage"
import { Anthropic } from "@anthropic-ai/sdk"
import * as path from "path"
import os from "os"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@ -14,6 +14,9 @@ https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default
https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
*/
type SecretKey = "apiKey" | "openRouterApiKey" | "awsAccessKey" | "awsSecretKey"
type GlobalStateKey = "apiProvider" | "awsRegion" | "maxRequestsPerTask" | "lastShownAnnouncementId"
export class ClaudeDevProvider implements vscode.WebviewViewProvider {
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
@ -131,15 +134,16 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
this.outputChannel.appendLine("Webview view resolved")
}
async tryToInitClaudeDevWithTask(task: string) {
async initClaudeDevWithTask(task: string) {
await this.clearTask() // ensures that an exising task doesn't exist before starting a new one, although this shouldn't be possible since user must clear task before starting a new one
const [apiKey, maxRequestsPerTask] = await Promise.all([
this.getSecret("apiKey") as Promise<string | undefined>,
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
])
if (this.view && apiKey) {
this.claudeDev = new ClaudeDev(this, task, apiKey, maxRequestsPerTask)
}
const { apiProvider, apiKey, openRouterApiKey, awsAccessKey, awsSecretKey, awsRegion, maxRequestsPerTask } =
await this.getState()
this.claudeDev = new ClaudeDev(
this,
task,
{ apiProvider, apiKey, openRouterApiKey, awsAccessKey, awsSecretKey, awsRegion },
maxRequestsPerTask
)
}
// Send any JSON serializable data to the react app
@ -249,11 +253,20 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
// Could also do this in extension .ts
//this.postMessageToWebview({ type: "text", text: `Extension: ${Date.now()}` })
// initializing new instance of ClaudeDev will make sure that any agentically running promises in old instance don't affect our new task. this essentially creates a fresh slate for the new task
await this.tryToInitClaudeDevWithTask(message.text!)
await this.initClaudeDevWithTask(message.text!)
break
case "apiKey":
await this.storeSecret("apiKey", message.text!)
this.claudeDev?.updateApiKey(message.text!)
case "apiConfiguration":
if (message.apiConfiguration) {
const { apiProvider, apiKey, openRouterApiKey, awsAccessKey, awsSecretKey, awsRegion } =
message.apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider)
await this.storeSecret("apiKey", apiKey)
await this.storeSecret("openRouterApiKey", openRouterApiKey)
await this.storeSecret("awsAccessKey", awsAccessKey)
await this.storeSecret("awsSecretKey", awsSecretKey)
await this.updateGlobalState("awsRegion", awsRegion)
this.claudeDev?.updateApi(message.apiConfiguration)
}
await this.postStateToWebview()
break
case "maxRequestsPerTask":
@ -369,15 +382,20 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
}
async postStateToWebview() {
const [apiKey, maxRequestsPerTask, lastShownAnnouncementId] = await Promise.all([
this.getSecret("apiKey") as Promise<string | undefined>,
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
])
const {
apiProvider,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsRegion,
maxRequestsPerTask,
lastShownAnnouncementId,
} = await this.getState()
this.postMessageToWebview({
type: "state",
state: {
apiKey,
apiConfiguration: { apiProvider, apiKey, openRouterApiKey, awsAccessKey, awsSecretKey, awsRegion },
maxRequestsPerTask,
themeName: vscode.workspace.getConfiguration("workbench").get<string>("colorTheme"),
claudeMessages: this.claudeDev?.claudeMessages || [],
@ -476,13 +494,45 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
https://www.eliostruyf.com/devhack-code-extension-storage-options/
*/
async getState() {
const [
apiProvider,
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsRegion,
maxRequestsPerTask,
lastShownAnnouncementId,
] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getSecret("apiKey") as Promise<string | undefined>,
this.getSecret("openRouterApiKey") as Promise<string | undefined>,
this.getSecret("awsAccessKey") as Promise<string | undefined>,
this.getSecret("awsSecretKey") as Promise<string | undefined>,
this.getGlobalState("awsRegion") as Promise<string | undefined>,
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
])
return {
apiProvider: apiProvider || "anthropic", // for legacy users that were using Anthropic by default
apiKey,
openRouterApiKey,
awsAccessKey,
awsSecretKey,
awsRegion,
maxRequestsPerTask,
lastShownAnnouncementId,
}
}
// global
private async updateGlobalState(key: string, value: any) {
private async updateGlobalState(key: GlobalStateKey, value: any) {
await this.context.globalState.update(key, value)
}
private async getGlobalState(key: string) {
private async getGlobalState(key: GlobalStateKey) {
return await this.context.globalState.get(key)
}
@ -508,11 +558,11 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
// secrets
private async storeSecret(key: string, value: any) {
private async storeSecret(key: SecretKey, value: any) {
await this.context.secrets.store(key, value)
}
private async getSecret(key: string) {
private async getSecret(key: SecretKey) {
return await this.context.secrets.get(key)
}
}

View File

@ -1,5 +1,7 @@
// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonTapped' or 'settingsButtonTapped' or 'hello'
import { ApiConfiguration } from "./api"
// webview will hold state
export interface ExtensionMessage {
type: "action" | "state"
@ -9,7 +11,7 @@ export interface ExtensionMessage {
}
export interface ExtensionState {
apiKey?: string
apiConfiguration?: ApiConfiguration
maxRequestsPerTask?: number
themeName?: string
claudeMessages: ClaudeMessage[]

View File

@ -1,6 +1,8 @@
import { ApiConfiguration, ApiProvider } from "./api"
export interface WebviewMessage {
type:
| "apiKey"
| "apiConfiguration"
| "maxRequestsPerTask"
| "webviewDidLaunch"
| "newTask"
@ -10,6 +12,7 @@ export interface WebviewMessage {
| "downloadTask"
text?: string
askResponse?: ClaudeAskResponse
apiConfiguration?: ApiConfiguration
}
export type ClaudeAskResponse = "yesButtonTapped" | "noButtonTapped" | "textResponse"

13
src/shared/api.ts Normal file
View File

@ -0,0 +1,13 @@
export type ApiProvider = "anthropic" | "openrouter" | "bedrock"
export interface ApiHandlerOptions {
apiKey?: string // anthropic
openRouterApiKey?: string
awsAccessKey?: string
awsSecretKey?: string
awsRegion?: string
}
export type ApiConfiguration = ApiHandlerOptions & {
apiProvider?: ApiProvider
}

16
src/utils/getNonce.ts Normal file
View File

@ -0,0 +1,16 @@
/**
* A helper function that returns a unique alphanumeric identifier called a nonce.
*
* @remarks This function is primarily used to help enforce content security
* policies for resources/scripts being executed in a webview context.
*
* @returns A nonce
*/
export function getNonce() {
let text = ""
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length))
}
return text
}

15
src/utils/getUri.ts Normal file
View File

@ -0,0 +1,15 @@
import { Uri, Webview } from "vscode"
/**
* A helper function which will get the webview URI of a given file or resource.
*
* @remarks This URI can be used within a webview's HTML as a link to the
* given file/resource.
*
* @param webview A reference to the extension webview
* @param extensionUri The URI of the directory containing the extension
* @param pathList An array of strings representing the path to a file/resource
* @returns A URI pointing to the file/resource
*/
export function getUri(webview: Webview, extensionUri: Uri, pathList: string[]) {
return webview.asWebviewUri(Uri.joinPath(extensionUri, ...pathList))
}

View File

@ -6,6 +6,7 @@ import { ClaudeMessage, ExtensionMessage } from "@shared/ExtensionMessage"
import WelcomeView from "./components/WelcomeView"
import { vscode } from "./utilities/vscode"
import { useEvent } from "react-use"
import { ApiConfiguration } from "@shared/api"
/*
The contents of webviews however are created when the webview becomes visible and destroyed when the webview is moved into the background. Any state inside the webview will be lost when the webview is moved to a background tab.
@ -19,7 +20,7 @@ const App: React.FC = () => {
const [didHydrateState, setDidHydrateState] = useState(false)
const [showSettings, setShowSettings] = useState(false)
const [showWelcome, setShowWelcome] = useState<boolean>(false)
const [apiKey, setApiKey] = useState<string>("")
const [apiConfiguration, setApiConfiguration] = useState<ApiConfiguration | undefined>(undefined)
const [maxRequestsPerTask, setMaxRequestsPerTask] = useState<string>("")
const [vscodeThemeName, setVscodeThemeName] = useState<string | undefined>(undefined)
const [claudeMessages, setClaudeMessages] = useState<ClaudeMessage[]>([])
@ -33,8 +34,12 @@ const App: React.FC = () => {
const message: ExtensionMessage = e.data
switch (message.type) {
case "state":
setShowWelcome(!message.state!.apiKey)
setApiKey(message.state!.apiKey || "")
const hasKey =
message.state!.apiConfiguration?.apiKey !== undefined ||
message.state!.apiConfiguration?.openRouterApiKey !== undefined ||
message.state!.apiConfiguration?.awsAccessKey !== undefined
setShowWelcome(!hasKey)
setApiConfiguration(message.state!.apiConfiguration)
setMaxRequestsPerTask(
message.state!.maxRequestsPerTask !== undefined ? message.state!.maxRequestsPerTask.toString() : ""
)
@ -70,13 +75,13 @@ const App: React.FC = () => {
return (
<>
{showWelcome ? (
<WelcomeView apiKey={apiKey} setApiKey={setApiKey} />
<WelcomeView apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
) : (
<>
{showSettings && (
<SettingsView
apiKey={apiKey}
setApiKey={setApiKey}
apiConfiguration={apiConfiguration}
setApiConfiguration={setApiConfiguration}
maxRequestsPerTask={maxRequestsPerTask}
setMaxRequestsPerTask={setMaxRequestsPerTask}
onDone={() => setShowSettings(false)}

View File

@ -0,0 +1,144 @@
import { ApiConfiguration } from "@shared/api"
import { VSCodeDropdown, VSCodeLink, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import React from "react"
interface ApiOptionsProps {
apiConfiguration?: ApiConfiguration
setApiConfiguration: React.Dispatch<React.SetStateAction<ApiConfiguration | undefined>>
}
const ApiOptions: React.FC<ApiOptionsProps> = ({ apiConfiguration, setApiConfiguration }) => {
const handleInputChange = (field: keyof ApiConfiguration) => (event: any) => {
setApiConfiguration((prev) => ({ ...prev, [field]: event.target.value }))
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<div className="dropdown-container">
<label htmlFor="api-provider">
<span style={{ fontWeight: 500 }}>API Provider</span>
</label>
<VSCodeDropdown
id="api-provider"
value={apiConfiguration?.apiProvider || "anthropic"}
onChange={handleInputChange("apiProvider")}>
<VSCodeOption value="anthropic">Anthropic</VSCodeOption>
<VSCodeOption value="openrouter">OpenRouter</VSCodeOption>
<VSCodeOption value="bedrock">AWS Bedrock</VSCodeOption>
</VSCodeDropdown>
</div>
{apiConfiguration?.apiProvider === "anthropic" && (
<div>
<VSCodeTextField
value={apiConfiguration?.apiKey || ""}
style={{ width: "100%" }}
onInput={handleInputChange("apiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Anthropic API Key</span>
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
This key is stored locally and only used to make API requests from the extension.
<VSCodeLink href="https://console.anthropic.com/" style={{ display: "inline" }}>
You can get an Anthropic API key by signing up here.
</VSCodeLink>
</p>
</div>
)}
{apiConfiguration?.apiProvider === "openrouter" && (
<div>
<VSCodeTextField
value={apiConfiguration?.openRouterApiKey || ""}
style={{ width: "100%" }}
onInput={handleInputChange("openRouterApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>OpenRouter API Key</span>
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
This key is stored locally and only used to make API requests from the extension.
<VSCodeLink href="https://openrouter.ai/" style={{ display: "inline" }}>
You can get an OpenRouter API key by signing up here.
</VSCodeLink>
</p>
</div>
)}
{apiConfiguration?.apiProvider === "bedrock" && (
<div style={{ display: "flex", flexDirection: "column", gap: 5 }}>
<VSCodeTextField
value={apiConfiguration?.awsAccessKey || ""}
style={{ width: "100%" }}
onInput={handleInputChange("awsAccessKey")}
placeholder="Enter Access Key...">
<span style={{ fontWeight: 500 }}>AWS Access Key</span>
</VSCodeTextField>
<VSCodeTextField
value={apiConfiguration?.awsSecretKey || ""}
style={{ width: "100%" }}
onInput={handleInputChange("awsSecretKey")}
placeholder="Enter Secret Key...">
<span style={{ fontWeight: 500 }}>AWS Secret Key</span>
</VSCodeTextField>
<div className="dropdown-container">
<label htmlFor="aws-region-dropdown">
<span style={{ fontWeight: 500 }}>AWS Region</span>
</label>
<VSCodeDropdown
id="aws-region-dropdown"
value={apiConfiguration?.awsRegion || ""}
style={{ width: "100%" }}
onChange={handleInputChange("awsRegion")}>
<VSCodeOption value="">Select a region...</VSCodeOption>
<VSCodeOption value="us-east-1">US East (N. Virginia)</VSCodeOption>
<VSCodeOption value="us-east-2">US East (Ohio)</VSCodeOption>
<VSCodeOption value="us-west-1">US West (N. California)</VSCodeOption>
<VSCodeOption value="us-west-2">US West (Oregon)</VSCodeOption>
<VSCodeOption value="af-south-1">Africa (Cape Town)</VSCodeOption>
<VSCodeOption value="ap-east-1">Asia Pacific (Hong Kong)</VSCodeOption>
<VSCodeOption value="ap-south-1">Asia Pacific (Mumbai)</VSCodeOption>
<VSCodeOption value="ap-northeast-1">Asia Pacific (Tokyo)</VSCodeOption>
<VSCodeOption value="ap-northeast-2">Asia Pacific (Seoul)</VSCodeOption>
<VSCodeOption value="ap-northeast-3">Asia Pacific (Osaka)</VSCodeOption>
<VSCodeOption value="ap-southeast-1">Asia Pacific (Singapore)</VSCodeOption>
<VSCodeOption value="ap-southeast-2">Asia Pacific (Sydney)</VSCodeOption>
<VSCodeOption value="ca-central-1">Canada (Central)</VSCodeOption>
<VSCodeOption value="eu-central-1">Europe (Frankfurt)</VSCodeOption>
<VSCodeOption value="eu-west-1">Europe (Ireland)</VSCodeOption>
<VSCodeOption value="eu-west-2">Europe (London)</VSCodeOption>
<VSCodeOption value="eu-west-3">Europe (Paris)</VSCodeOption>
<VSCodeOption value="eu-north-1">Europe (Stockholm)</VSCodeOption>
<VSCodeOption value="me-south-1">Middle East (Bahrain)</VSCodeOption>
<VSCodeOption value="sa-east-1">South America (São Paulo)</VSCodeOption>
</VSCodeDropdown>
</div>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
These credentials are stored locally and only used to make API requests from the extension.
<VSCodeLink
href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_access-keys.html"
style={{ display: "inline" }}>
You can find your AWS access key and secret key here.
</VSCodeLink>
</p>
</div>
)}
</div>
)
}
export default ApiOptions

View File

@ -1,64 +1,50 @@
import { ApiConfiguration } from "@shared/api"
import { VSCodeButton, VSCodeDivider, VSCodeLink, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import React, { useState } from "react"
import { useEffectOnce } from "react-use"
import React, { useEffect, useState } from "react"
import { validateApiConfiguration, validateMaxRequestsPerTask } from "../utilities/validate"
import { vscode } from "../utilities/vscode"
import ApiOptions from "./ApiOptions"
type SettingsViewProps = {
apiKey: string
setApiKey: React.Dispatch<React.SetStateAction<string>>
apiConfiguration?: ApiConfiguration
setApiConfiguration: React.Dispatch<React.SetStateAction<ApiConfiguration | undefined>>
maxRequestsPerTask: string
setMaxRequestsPerTask: React.Dispatch<React.SetStateAction<string>>
onDone: () => void // Define the type of the onDone prop
onDone: () => void
}
const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPerTask, onDone }: SettingsViewProps) => {
const [apiKeyErrorMessage, setApiKeyErrorMessage] = useState<string | undefined>(undefined)
const SettingsView = ({
apiConfiguration,
setApiConfiguration,
maxRequestsPerTask,
setMaxRequestsPerTask,
onDone,
}: SettingsViewProps) => {
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const [maxRequestsErrorMessage, setMaxRequestsErrorMessage] = useState<string | undefined>(undefined)
const disableDoneButton = apiKeyErrorMessage != null || maxRequestsErrorMessage != null
const handleApiKeyChange = (event: any) => {
const input = event.target.value
setApiKey(input)
validateApiKey(input)
}
const validateApiKey = (value: string) => {
if (value.trim() === "") {
setApiKeyErrorMessage("API Key cannot be empty")
} else {
setApiKeyErrorMessage(undefined)
}
}
const handleMaxRequestsChange = (event: any) => {
const input = event.target.value
setMaxRequestsPerTask(input)
validateMaxRequests(input)
}
const validateMaxRequests = (value: string | undefined) => {
if (value?.trim()) {
const num = Number(value)
if (isNaN(num)) {
setMaxRequestsErrorMessage("Maximum requests must be a number")
} else if (num < 3 || num > 100) {
setMaxRequestsErrorMessage("Maximum requests must be between 3 and 100")
} else {
setMaxRequestsErrorMessage(undefined)
}
} else {
setMaxRequestsErrorMessage(undefined)
}
}
const handleSubmit = () => {
vscode.postMessage({ type: "apiKey", text: apiKey })
vscode.postMessage({ type: "maxRequestsPerTask", text: maxRequestsPerTask })
const apiValidationResult = validateApiConfiguration(apiConfiguration)
const maxRequestsValidationResult = validateMaxRequestsPerTask(maxRequestsPerTask)
onDone()
setApiErrorMessage(apiValidationResult)
setMaxRequestsErrorMessage(maxRequestsValidationResult)
if (!apiValidationResult && !maxRequestsValidationResult) {
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
vscode.postMessage({ type: "maxRequestsPerTask", text: maxRequestsPerTask })
onDone()
}
}
useEffect(() => {
setApiErrorMessage(undefined)
}, [apiConfiguration])
useEffect(() => {
setMaxRequestsErrorMessage(undefined)
}, [maxRequestsPerTask])
// validate as soon as the component is mounted
/*
useEffect will use stale values of variables if they are not included in the dependency array. so trying to use useEffect with a dependency array of only one value for example will use any other variables' old values. In most cases you don't want this, and should opt to use react-use hooks.
@ -70,14 +56,6 @@ const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPer
If we only want to run code once on mount we can use react-use's useEffectOnce or useMount
*/
useEffectOnce(() => {
const timeoutId = setTimeout(() => {
validateApiKey(apiKey)
validateMaxRequests(maxRequestsPerTask)
}, 1000)
return () => clearTimeout(timeoutId)
})
return (
<div style={{ margin: "0 auto", paddingTop: "10px" }}>
@ -89,40 +67,21 @@ const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPer
marginBottom: "17px",
}}>
<h3 style={{ color: "var(--vscode-foreground)", margin: 0 }}>Settings</h3>
<VSCodeButton onClick={handleSubmit} disabled={disableDoneButton}>
Done
</VSCodeButton>
<VSCodeButton onClick={handleSubmit}>Done</VSCodeButton>
</div>
<div style={{ marginBottom: "20px" }}>
<VSCodeTextField
value={apiKey}
style={{ width: "100%" }}
placeholder="Enter your Anthropic API Key"
onInput={handleApiKeyChange}>
<span style={{ fontWeight: "500" }}>Anthropic API Key</span>
</VSCodeTextField>
{apiKeyErrorMessage && (
<div style={{ marginBottom: 5 }}>
<ApiOptions apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
{apiErrorMessage && (
<p
style={{
margin: "-5px 0 12px 0",
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-errorForeground)",
}}>
{apiKeyErrorMessage}
{apiErrorMessage}
</p>
)}
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
This key is not shared with anyone and only used to make API requests from the extension.
<VSCodeLink href="https://console.anthropic.com/" style={{ display: "inline" }}>
You can get an API key by signing up here.
</VSCodeLink>
</p>
</div>
<div style={{ marginBottom: "20px" }}>
@ -130,9 +89,18 @@ const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPer
value={maxRequestsPerTask}
style={{ width: "100%" }}
placeholder="20"
onInput={handleMaxRequestsChange}>
onInput={(e: any) => setMaxRequestsPerTask(e.target?.value)}>
<span style={{ fontWeight: "500" }}>Maximum # Requests Per Task</span>
</VSCodeTextField>
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
If Claude Dev reaches this limit, it will pause and ask for your permission before making additional
requests.
</p>
{maxRequestsErrorMessage && (
<p
style={{
@ -143,15 +111,6 @@ const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPer
{maxRequestsErrorMessage}
</p>
)}
<p
style={{
fontSize: "12px",
marginTop: "5px",
color: "var(--vscode-descriptionForeground)",
}}>
If Claude Dev reaches this limit, it will pause and ask for your permission before making additional
requests.
</p>
</div>
<VSCodeDivider />
@ -163,13 +122,14 @@ const SettingsView = ({ apiKey, setApiKey, maxRequestsPerTask, setMaxRequestsPer
color: "var(--vscode-descriptionForeground)",
fontSize: "12px",
lineHeight: "1.2",
fontStyle: "italic",
}}>
<p>
<VSCodeLink href="https://github.com/saoudrizwan/claude-dev">
<p style={{ wordWrap: "break-word" }}>
If you have any questions or feedback, feel free to open an issue at{" "}
<VSCodeLink href="https://github.com/saoudrizwan/claude-dev" style={{ display: "inline" }}>
https://github.com/saoudrizwan/claude-dev
</VSCodeLink>
</p>
<p style={{ fontStyle: "italic" }}>v1.0.86</p>
</div>
</div>
)

View File

@ -1,32 +1,27 @@
import React, { useState, useEffect } from "react"
import { VSCodeButton, VSCodeTextField, VSCodeLink, VSCodeDivider } from "@vscode/webview-ui-toolkit/react"
import { ApiConfiguration } from "@shared/api"
import { VSCodeButton, VSCodeLink } from "@vscode/webview-ui-toolkit/react"
import React, { useEffect, useState } from "react"
import { validateApiConfiguration } from "../utilities/validate"
import { vscode } from "../utilities/vscode"
import ApiOptions from "./ApiOptions"
interface WelcomeViewProps {
apiKey: string
setApiKey: React.Dispatch<React.SetStateAction<string>>
apiConfiguration?: ApiConfiguration
setApiConfiguration: React.Dispatch<React.SetStateAction<ApiConfiguration | undefined>>
}
const WelcomeView: React.FC<WelcomeViewProps> = ({ apiKey, setApiKey }) => {
const [apiKeyErrorMessage, setApiKeyErrorMessage] = useState<string | undefined>(undefined)
const WelcomeView: React.FC<WelcomeViewProps> = ({ apiConfiguration, setApiConfiguration }) => {
const [apiErrorMessage, setApiErrorMessage] = useState<string | undefined>(undefined)
const disableLetsGoButton = apiKeyErrorMessage != null
const validateApiKey = (value: string) => {
if (value.trim() === "") {
setApiKeyErrorMessage("API Key cannot be empty")
} else {
setApiKeyErrorMessage(undefined)
}
}
const disableLetsGoButton = apiErrorMessage != null
const handleSubmit = () => {
vscode.postMessage({ type: "apiKey", text: apiKey })
vscode.postMessage({ type: "apiConfiguration", apiConfiguration })
}
useEffect(() => {
validateApiKey(apiKey)
}, [apiKey])
setApiErrorMessage(validateApiConfiguration(apiConfiguration))
}, [apiConfiguration])
return (
<div style={{ position: "fixed", top: 0, left: 0, right: 0, bottom: 0, padding: "0 20px" }}>
@ -42,35 +37,14 @@ const WelcomeView: React.FC<WelcomeViewProps> = ({ apiKey, setApiKey }) => {
files, analyze project source code, and execute terminal commands (with your permission, of course).
</p>
<b>To get started, this extension needs an Anthropic API key:</b>
<ol style={{ paddingLeft: "15px" }}>
<li>
Go to{" "}
<VSCodeLink href="https://console.anthropic.com" style={{ display: "inline" }}>
https://console.anthropic.com
</VSCodeLink>
</li>
<li>You may need to buy some credits (although Anthropic is offering $5 free credit for new users)</li>
<li>Click 'Get API Keys' and create a new key (you can delete it any time)</li>
</ol>
<b>To get started, this extension needs an API key for Claude 3.5 Sonnet:</b>
<VSCodeDivider />
<div style={{ marginTop: "20px", display: "flex", alignItems: "center" }}>
<VSCodeTextField
style={{ flexGrow: 1, marginRight: "10px" }}
placeholder="Enter API Key..."
value={apiKey}
onInput={(e: any) => setApiKey(e.target.value)}
/>
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton}>
Submit
<div style={{ marginTop: "15px" }}>
<ApiOptions apiConfiguration={apiConfiguration} setApiConfiguration={setApiConfiguration} />
<VSCodeButton onClick={handleSubmit} disabled={disableLetsGoButton} style={{ marginTop: "3px" }}>
Let's go!
</VSCodeButton>
</div>
<p style={{ fontSize: "12px", marginTop: "10px", color: "var(--vscode-descriptionForeground)" }}>
Your API key is stored securely on your computer and used only for interacting with the Anthropic API.
</p>
</div>
)
}

View File

@ -109,3 +109,23 @@ The above scrollbar styling uses some transparent background color magic to acco
.code-block-scrollable::-webkit-scrollbar-corner {
background-color: transparent;
}
/*
Dropdown label
https://github.com/microsoft/vscode-webview-ui-toolkit/tree/main/src/dropdown#with-label
*/
.dropdown-container {
box-sizing: border-box;
display: flex;
flex-flow: column nowrap;
align-items: flex-start;
justify-content: flex-start;
}
.dropdown-container label {
display: block;
color: var(--vscode-foreground);
cursor: pointer;
font-size: var(--vscode-font-size);
line-height: normal;
margin-bottom: 2px;
}

View File

@ -0,0 +1,34 @@
import { ApiConfiguration } from "@shared/api"
export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined {
if (apiConfiguration) {
switch (apiConfiguration.apiProvider) {
case "anthropic":
if (!apiConfiguration.apiKey) {
return "API Key cannot be empty. You must provide an API key to use Claude Dev."
}
break
case "bedrock":
if (!apiConfiguration.awsAccessKey || !apiConfiguration.awsSecretKey || !apiConfiguration.awsRegion) {
return "AWS credentials are incomplete. You must provide an AWS access key, secret key, and region."
}
break
case "openrouter":
if (!apiConfiguration.openRouterApiKey) {
return "API Key cannot be empty. You must provide an API key to use Claude Dev."
}
break
}
}
return undefined
}
export function validateMaxRequestsPerTask(maxRequestsPerTask?: string): string | undefined {
if (maxRequestsPerTask && maxRequestsPerTask.trim()) {
const num = Number(maxRequestsPerTask)
if (isNaN(num) || num < 3 || num > 100) {
return "Maximum requests must be between 3 and 100"
}
}
return undefined
}