Add Kodu provider

This commit is contained in:
Saoud Rizwan
2024-08-24 09:18:27 -04:00
parent cc9637e0fe
commit df4e8e7afc
19 changed files with 380 additions and 305 deletions

View File

@@ -3,7 +3,7 @@ import { ApiConfiguration, ApiModelId, ModelInfo } from "../shared/api"
import { AnthropicHandler } from "./anthropic"
import { AwsBedrockHandler } from "./bedrock"
import { OpenRouterHandler } from "./openrouter"
import { MaestroHandler } from "./maestro"
import { KoduHandler } from "./kodu"
export interface ApiHandler {
createMessage(
@@ -33,8 +33,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new OpenRouterHandler(options)
case "bedrock":
return new AwsBedrockHandler(options)
case "maestro":
return new MaestroHandler(options)
case "kodu":
return new KoduHandler(options)
default:
return new AnthropicHandler(options)
}

125
src/api/kodu.ts Normal file
View File

@@ -0,0 +1,125 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, withoutImageData } from "."
import { ApiHandlerOptions, koduDefaultModelId, KoduModelId, koduModels, ModelInfo } from "../shared/api"
import axios from "axios"
import * as vscode from "vscode"
const KODU_BASE_URL = "https://claude-dev.com"
export function didClickKoduSignIn() {
const loginUrl = `${KODU_BASE_URL}/auth/login?redirectTo=${vscode.env.uriScheme}://saoudrizwan.claude-dev&ext=1`
vscode.env.openExternal(vscode.Uri.parse(loginUrl))
}
export function didClickKoduAddCredits() {
const addCreditsUrl = `${KODU_BASE_URL}/user/addCredits?redirectTo=${vscode.env.uriScheme}://saoudrizwan.claude-dev&ext=1`
vscode.env.openExternal(vscode.Uri.parse(addCreditsUrl))
}
export async function fetchKoduCredits({ apiKey }: { apiKey: string }) {
const response = await axios.get(`${KODU_BASE_URL}/api/credits`, {
headers: {
"x-api-key": apiKey,
},
})
return (response.data.credits as number) || 0
}
export class KoduHandler implements ApiHandler {
private options: ApiHandlerOptions
constructor(options: ApiHandlerOptions) {
this.options = options
}
async createMessage(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
tools: Anthropic.Messages.Tool[]
): Promise<Anthropic.Messages.Message> {
const modelId = this.getModel().id
let requestBody: Anthropic.Beta.PromptCaching.Messages.MessageCreateParamsNonStreaming
switch (modelId) {
case "claude-3-5-sonnet-20240620":
case "claude-3-haiku-20240307":
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[]
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
requestBody = {
model: modelId,
max_tokens: this.getModel().info.maxTokens,
system: [{ text: systemPrompt, type: "text", cache_control: { type: "ephemeral" } }],
messages: messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
cache_control: { type: "ephemeral" },
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? { ...content, cache_control: { type: "ephemeral" } }
: content
),
}
}
return message
}),
tools,
tool_choice: { type: "auto" },
}
break
default:
requestBody = {
model: modelId,
max_tokens: this.getModel().info.maxTokens,
system: [{ text: systemPrompt, type: "text" }],
messages,
tools,
tool_choice: { type: "auto" },
}
}
const response = await axios.post(`${KODU_BASE_URL}/api/inference`, requestBody, {
headers: {
"x-api-key": this.options.koduApiKey,
},
})
return response.data
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
}
}
getModel(): { id: KoduModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in koduModels) {
const id = modelId as KoduModelId
return { id, info: koduModels[id] }
}
return { id: koduDefaultModelId, info: koduModels[koduDefaultModelId] }
}
}

View File

@@ -1,114 +0,0 @@
import { Anthropic } from "@anthropic-ai/sdk"
import { ApiHandler, withoutImageData } from "."
import { ApiHandlerOptions, maestroDefaultModelId, MaestroModelId, maestroModels, ModelInfo } from "../shared/api"
export class MaestroHandler 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> {
const modelId = this.getModel().id
switch (modelId) {
case "claude-3-5-sonnet-20240620":
case "claude-3-haiku-20240307":
const userMsgIndices = messages.reduce(
(acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc),
[] as number[]
)
const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
return await this.client.beta.promptCaching.messages.create(
{
model: modelId,
max_tokens: this.getModel().info.maxTokens,
system: [{ text: systemPrompt, type: "text", cache_control: { type: "ephemeral" } }],
messages: messages.map((message, index) => {
if (index === lastUserMsgIndex || index === secondLastMsgUserIndex) {
return {
...message,
content:
typeof message.content === "string"
? [
{
type: "text",
text: message.content,
cache_control: { type: "ephemeral" },
},
]
: message.content.map((content, contentIndex) =>
contentIndex === message.content.length - 1
? { ...content, cache_control: { type: "ephemeral" } }
: content
),
}
}
return message
}),
tools,
tool_choice: { type: "auto" },
},
(() => {
switch (modelId) {
case "claude-3-5-sonnet-20240620":
return {
headers: {
"anthropic-beta": "prompt-caching-2024-07-31",
},
}
case "claude-3-haiku-20240307":
return {
headers: { "anthropic-beta": "prompt-caching-2024-07-31" },
}
default:
return undefined
}
})()
)
default:
return await this.client.messages.create({
model: modelId,
max_tokens: this.getModel().info.maxTokens,
system: [{ text: systemPrompt, type: "text" }],
messages,
tools,
tool_choice: { type: "auto" },
})
}
}
createUserReadableRequest(
userContent: Array<
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
| Anthropic.ToolUseBlockParam
| Anthropic.ToolResultBlockParam
>
): any {
return {
model: this.getModel().id,
max_tokens: this.getModel().info.maxTokens,
system: "(see SYSTEM_PROMPT in src/ClaudeDev.ts)",
messages: [{ conversation_history: "..." }, { role: "user", content: withoutImageData(userContent) }],
tools: "(see tools in src/ClaudeDev.ts)",
tool_choice: { type: "auto" },
}
}
getModel(): { id: MaestroModelId; info: ModelInfo } {
const modelId = this.options.apiModelId
if (modelId && modelId in maestroModels) {
const id = modelId as MaestroModelId
return { id, info: maestroModels[id] }
}
return { id: maestroDefaultModelId, info: maestroModels[maestroDefaultModelId] }
}
}

View File

@@ -111,14 +111,11 @@ export function activate(context: vscode.ExtensionContext) {
// URI Handler
const handleUri = async (uri: vscode.Uri) => {
const query = new URLSearchParams(uri.query)
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
const token = query.get("token")
const fixedToken = token?.replaceAll("jwt?token=", "")
console.log(fixedToken)
console.log(uri)
if (fixedToken) {
await sidebarProvider.saveMaestroToken(fixedToken)
const email = query.get("email")
if (token) {
await sidebarProvider.saveKoduApiKey(token, email || undefined)
}
}
context.subscriptions.push(vscode.window.registerUriHandler({ handleUri }))

View File

@@ -1,38 +0,0 @@
import axios from "axios"
import * as vscode from "vscode"
import { MaestroUser, MaestroUserSchema } from "../shared/maestro"
const MAESTRO_BASE_URL = "https://maestro.im-ada.ai"
export function didClickMaestroSignIn() {
const loginUrl = `${MAESTRO_BASE_URL}/auth/login?ext=1&redirectTo=${vscode.env.uriScheme}://saoudrizwan.claude-dev?token=jwt`
vscode.env.openExternal(vscode.Uri.parse(loginUrl))
}
export async function validateMaestroToken({
token,
showError = false,
}: {
token: string
showError?: boolean
}): Promise<MaestroUser> {
try {
const response = await axios.post(`${MAESTRO_BASE_URL}/api/extension/auth/callback`, { token })
const user = MaestroUserSchema.parse(response.data.user)
console.log("retrieved user", user)
return user
} catch (error) {
if (showError) {
if (axios.isAxiosError(error)) {
vscode.window.showErrorMessage(
"Failed to validate token:",
error.response?.status,
error.response?.data
)
} else {
vscode.window.showErrorMessage("An unexpected error occurred:", error)
}
}
throw error
}
}

View File

@@ -1 +0,0 @@
export * from "./auth"

View File

@@ -8,8 +8,7 @@ import { downloadTask, getNonce, getUri, selectImages } from "../utils"
import * as path from "path"
import fs from "fs/promises"
import { HistoryItem } from "../shared/HistoryItem"
import { didClickMaestroSignIn, validateMaestroToken } from "../maestro"
import { MaestroUser } from "../shared/maestro"
import { didClickKoduAddCredits, didClickKoduSignIn, fetchKoduCredits } from "../api/kodu"
/*
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
@@ -17,11 +16,13 @@ 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" | "maestroToken"
type SecretKey = "apiKey" | "openRouterApiKey" | "awsAccessKey" | "awsSecretKey" | "koduApiKey"
type GlobalStateKey =
| "apiProvider"
| "apiModelId"
| "awsRegion"
| "koduEmail"
| "koduCredits"
| "maxRequestsPerTask"
| "lastShownAnnouncementId"
| "customInstructions"
@@ -34,11 +35,9 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
private view?: vscode.WebviewView | vscode.WebviewPanel
private claudeDev?: ClaudeDev
private latestAnnouncementId = "aug-17-2024" // update to some unique identifier when we add a new announcement
private maestroUser?: MaestroUser
constructor(readonly context: vscode.ExtensionContext, private readonly outputChannel: vscode.OutputChannel) {
this.outputChannel.appendLine("ClaudeDevProvider instantiated")
this.fetchMaestroUser({})
}
/*
@@ -344,11 +343,23 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
case "exportTaskWithId":
this.exportTaskWithId(message.text!)
break
case "didClickMaestroSignIn":
didClickMaestroSignIn()
case "didClickKoduSignIn":
didClickKoduSignIn()
break
case "didClickMaestroSignOut":
await this.signOutMaestro()
case "didClickKoduSignOut":
await this.signOutKodu()
break
case "didClickKoduAddCredits":
didClickKoduAddCredits()
break
case "fetchKoduCredits":
const koduApiKey = await this.getSecret("koduApiKey")
if (koduApiKey) {
const credits = await fetchKoduCredits({ apiKey: koduApiKey })
await this.updateGlobalState("koduCredits", credits)
await this.postStateToWebview()
await this.postMessageToWebview({ type: "action", action: "koduCreditsFetched" })
}
break
// Add more switch case statements here as more webview message commands
// are created within the webview context (i.e. inside media/main.js)
@@ -359,33 +370,21 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
)
}
// Maestro
// Kodu
async saveMaestroToken(token: string) {
await this.storeSecret("maestroToken", token)
await this.updateGlobalState("apiProvider", "maestro")
await this.fetchMaestroUser({ showError: true })
this.claudeDev?.updateApi({ apiProvider: "maestro", maestroToken: token })
}
async fetchMaestroUser({ showError = false }: { showError?: boolean }): Promise<MaestroUser | undefined> {
if (this.maestroUser) {
return this.maestroUser
}
const token = await this.getSecret("maestroToken")
if (!token) {
return undefined
}
const user = await validateMaestroToken({ token, showError })
this.maestroUser = user
async saveKoduApiKey(apiKey: string, email?: string) {
await this.storeSecret("koduApiKey", apiKey)
await this.updateGlobalState("koduEmail", email)
await this.updateGlobalState("apiProvider", "kodu")
await this.postStateToWebview()
return user
this.claudeDev?.updateApi({ apiProvider: "kodu", koduApiKey: apiKey })
}
async signOutMaestro() {
await this.storeSecret("maestroToken", undefined)
this.claudeDev?.updateApi({ apiProvider: "maestro", maestroToken: undefined })
this.maestroUser = undefined
async signOutKodu() {
await this.storeSecret("koduApiKey", undefined)
await this.updateGlobalState("koduEmail", undefined)
await this.updateGlobalState("koduCredits", undefined)
this.claudeDev?.updateApi({ apiProvider: "kodu", koduApiKey: undefined })
await this.postStateToWebview()
}
@@ -476,8 +475,14 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
}
async postStateToWebview() {
const { apiConfiguration, maxRequestsPerTask, lastShownAnnouncementId, customInstructions, taskHistory } =
await this.getState()
const {
apiConfiguration,
maxRequestsPerTask,
lastShownAnnouncementId,
customInstructions,
taskHistory,
koduCredits,
} = await this.getState()
this.postMessageToWebview({
type: "state",
state: {
@@ -489,7 +494,7 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
claudeMessages: this.claudeDev?.claudeMessages || [],
taskHistory: (taskHistory || []).filter((item) => item.ts && item.task).sort((a, b) => b.ts - a.ts),
shouldShowAnnouncement: lastShownAnnouncementId !== this.latestAnnouncementId,
maestroUser: this.maestroUser,
koduCredits,
},
})
}
@@ -589,6 +594,9 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
awsAccessKey,
awsSecretKey,
awsRegion,
koduApiKey,
koduEmail,
koduCredits,
maxRequestsPerTask,
lastShownAnnouncementId,
customInstructions,
@@ -601,6 +609,9 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
this.getSecret("awsAccessKey") as Promise<string | undefined>,
this.getSecret("awsSecretKey") as Promise<string | undefined>,
this.getGlobalState("awsRegion") as Promise<string | undefined>,
this.getSecret("koduApiKey") as Promise<string | undefined>,
this.getGlobalState("koduEmail") as Promise<string | undefined>,
this.getGlobalState("koduCredits") as Promise<number | undefined>,
this.getGlobalState("maxRequestsPerTask") as Promise<number | undefined>,
this.getGlobalState("lastShownAnnouncementId") as Promise<string | undefined>,
this.getGlobalState("customInstructions") as Promise<string | undefined>,
@@ -616,8 +627,8 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
if (apiKey) {
apiProvider = "anthropic"
} else {
// New users should default to anthropic (openrouter has issues, bedrock is complicated)
apiProvider = "anthropic"
// New users should default to kodu
apiProvider = "kodu"
}
}
@@ -630,11 +641,14 @@ export class ClaudeDevProvider implements vscode.WebviewViewProvider {
awsAccessKey,
awsSecretKey,
awsRegion,
koduApiKey,
koduEmail,
},
maxRequestsPerTask,
lastShownAnnouncementId,
customInstructions,
taskHistory,
koduCredits,
}
}

View File

@@ -2,13 +2,17 @@
import { ApiConfiguration } from "./api"
import { HistoryItem } from "./HistoryItem"
import { MaestroUser } from "./maestro"
// webview will hold state
export interface ExtensionMessage {
type: "action" | "state" | "selectedImages"
text?: string
action?: "chatButtonTapped" | "settingsButtonTapped" | "historyButtonTapped" | "didBecomeVisible"
action?:
| "chatButtonTapped"
| "settingsButtonTapped"
| "historyButtonTapped"
| "didBecomeVisible"
| "koduCreditsFetched"
state?: ExtensionState
images?: string[]
}
@@ -22,7 +26,7 @@ export interface ExtensionState {
claudeMessages: ClaudeMessage[]
taskHistory: HistoryItem[]
shouldShowAnnouncement: boolean
maestroUser?: MaestroUser
koduCredits?: number
}
export interface ClaudeMessage {

View File

@@ -15,8 +15,10 @@ export interface WebviewMessage {
| "showTaskWithId"
| "deleteTaskWithId"
| "exportTaskWithId"
| "didClickMaestroSignIn"
| "didClickMaestroSignOut"
| "didClickKoduSignIn"
| "didClickKoduSignOut"
| "didClickKoduAddCredits"
| "fetchKoduCredits"
text?: string
askResponse?: ClaudeAskResponse
apiConfiguration?: ApiConfiguration

View File

@@ -1,4 +1,4 @@
export type ApiProvider = "anthropic" | "openrouter" | "bedrock" | "maestro"
export type ApiProvider = "anthropic" | "openrouter" | "bedrock" | "kodu"
export interface ApiHandlerOptions {
apiModelId?: ApiModelId
@@ -7,7 +7,8 @@ export interface ApiHandlerOptions {
awsAccessKey?: string
awsSecretKey?: string
awsRegion?: string
maestroToken?: string
koduApiKey?: string
koduEmail?: string
}
export type ApiConfiguration = ApiHandlerOptions & {
@@ -234,9 +235,9 @@ export const openRouterModels = {
// },
} as const satisfies Record<string, ModelInfo>
// Maestro
export type MaestroModelId = keyof typeof maestroModels
export const maestroDefaultModelId: MaestroModelId = "claude-3-5-sonnet-20240620"
export const maestroModels = {
// Kodu
export type KoduModelId = keyof typeof koduModels
export const koduDefaultModelId: KoduModelId = "claude-3-5-sonnet-20240620"
export const koduModels = {
...anthropicModels,
} as const satisfies Record<string, ModelInfo>

View File

@@ -1,10 +0,0 @@
import { z } from "zod"
export const MaestroUserSchema = z.object({
id: z.string(),
image: z.string().nullable(),
email: z.string().email(),
name: z.string().nullable(),
emailVerified: z.coerce.date().nullable(),
})
export type MaestroUser = z.infer<typeof MaestroUserSchema>