diff --git a/src/api/index.ts b/src/api/index.ts index 641c50d..b3927b4 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -14,6 +14,7 @@ import { DeepSeekHandler } from "./providers/deepseek" import { MistralHandler } from "./providers/mistral" import { VsCodeLmHandler } from "./providers/vscode-lm" import { ApiStream } from "./transform/stream" +import { UnboundHandler } from "./providers/unbound" export interface SingleCompletionHandler { completePrompt(prompt: string): Promise @@ -53,6 +54,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler { return new VsCodeLmHandler(options) case "mistral": return new MistralHandler(options) + case "unbound": + return new UnboundHandler(options) default: return new AnthropicHandler(options) } diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts new file mode 100644 index 0000000..7350398 --- /dev/null +++ b/src/api/providers/unbound.ts @@ -0,0 +1,59 @@ +import { ApiHandlerOptions, unboundModels, UnboundModelId, unboundDefaultModelId, ModelInfo } from "../../shared/api" +import { ApiStream } from "../transform/stream" +import { Anthropic } from "@anthropic-ai/sdk" +import { ApiHandler } from "../index" + +export class UnboundHandler implements ApiHandler { + private unboundApiKey: string + private unboundModelId: string + private unboundBaseUrl: string = "https://ai-gateway-43843357113.us-west1.run.app/v1" + private options: ApiHandlerOptions + + constructor(options: ApiHandlerOptions) { + this.options = options + this.unboundApiKey = options.unboundApiKey || "" + this.unboundModelId = options.unboundModelId || "" + } + + async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { + const response = await fetch(`${this.unboundBaseUrl}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.unboundApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: this.unboundModelId, + messages: [{ role: "system", content: systemPrompt }, ...messages], + }), + }) + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`) + } + + const data = await response.json() + + yield { + type: "text", + text: data.choices[0]?.message?.content || "", + } + yield { + type: "usage", + inputTokens: data.usage?.prompt_tokens || 0, + outputTokens: data.usage?.completion_tokens || 0, + } + } + + getModel(): { id: UnboundModelId; info: ModelInfo } { + const modelId = this.options.apiModelId + if (modelId && modelId in unboundModels) { + const id = modelId as UnboundModelId + return { id, info: unboundModels[id] } + } + return { + id: unboundDefaultModelId, + info: unboundModels[unboundDefaultModelId], + } + } +} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 808a50e..7b42a66 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -62,6 +62,7 @@ type SecretKey = | "openAiNativeApiKey" | "deepSeekApiKey" | "mistralApiKey" + | "unboundApiKey" type GlobalStateKey = | "apiProvider" | "apiModelId" @@ -120,6 +121,7 @@ type GlobalStateKey = | "experimentalDiffStrategy" | "autoApprovalEnabled" | "customModes" // Array of custom modes + | "unboundModelId" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -1309,6 +1311,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { openRouterUseMiddleOutTransform, vsCodeLmModelSelector, mistralApiKey, + unboundApiKey, + unboundModelId, } = apiConfiguration await this.updateGlobalState("apiProvider", apiProvider) await this.updateGlobalState("apiModelId", apiModelId) @@ -1347,6 +1351,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform) await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector) await this.storeSecret("mistralApiKey", mistralApiKey) + await this.storeSecret("unboundApiKey", unboundApiKey) + await this.updateGlobalState("unboundModelId", unboundModelId) if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) } @@ -2001,6 +2007,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { experimentalDiffStrategy, autoApprovalEnabled, customModes, + unboundApiKey, + unboundModelId, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -2070,6 +2078,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("experimentalDiffStrategy") as Promise, this.getGlobalState("autoApprovalEnabled") as Promise, this.customModesManager.getCustomModes(), + this.getSecret("unboundApiKey") as Promise, + this.getGlobalState("unboundModelId") as Promise, ]) let apiProvider: ApiProvider @@ -2125,6 +2135,8 @@ export class ClineProvider implements vscode.WebviewViewProvider { openRouterBaseUrl, openRouterUseMiddleOutTransform, vsCodeLmModelSelector, + unboundApiKey, + unboundModelId, }, lastShownAnnouncementId, customInstructions, @@ -2273,6 +2285,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { "openAiNativeApiKey", "deepSeekApiKey", "mistralApiKey", + "unboundApiKey", ] for (const key of secretKeys) { await this.storeSecret(key, undefined) diff --git a/src/shared/api.ts b/src/shared/api.ts index 950b94b..8ba1730 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -14,6 +14,7 @@ export type ApiProvider = | "deepseek" | "vscode-lm" | "mistral" + | "unbound" export interface ApiHandlerOptions { apiModelId?: string @@ -57,6 +58,8 @@ export interface ApiHandlerOptions { deepSeekBaseUrl?: string deepSeekApiKey?: string includeMaxTokens?: boolean + unboundApiKey?: string + unboundModelId?: string } export type ApiConfiguration = ApiHandlerOptions & { @@ -593,3 +596,11 @@ export const mistralModels = { outputPrice: 0.9, }, } as const satisfies Record + +// Unbound Security +export type UnboundModelId = keyof typeof unboundModels +export const unboundDefaultModelId = "gpt-4o" +export const unboundModels = { + "gpt-4o": openAiNativeModels["gpt-4o"], + "claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"], +} as const satisfies Record diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 1be00c7..d42d167 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -26,6 +26,8 @@ import { openRouterDefaultModelInfo, vertexDefaultModelId, vertexModels, + unboundDefaultModelId, + unboundModels, } from "../../../../src/shared/api" import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { useExtensionState } from "../../context/ExtensionStateContext" @@ -147,6 +149,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) = { value: "mistral", label: "Mistral" }, { value: "lmstudio", label: "LM Studio" }, { value: "ollama", label: "Ollama" }, + { value: "unbound", label: "Unbound" }, ]} /> @@ -1283,6 +1286,27 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) = )} + {selectedProvider === "unbound" && ( +
+ + Unbound API Key + +

+ This key is stored locally and only used to make API requests from this extension. +

+
+ )} + {apiErrorMessage && (