diff --git a/src/api/providers/__tests__/unbound.test.ts b/src/api/providers/__tests__/unbound.test.ts index 3a0fe4868e..790c0f0129 100644 --- a/src/api/providers/__tests__/unbound.test.ts +++ b/src/api/providers/__tests__/unbound.test.ts @@ -73,6 +73,15 @@ describe("UnboundHandler", () => { mockOptions = { apiModelId: "anthropic/claude-3-5-sonnet-20241022", unboundApiKey: "test-api-key", + unboundModelId: "anthropic/claude-3-5-sonnet-20241022", + unboundModelInfo: { + description: "Anthropic's Claude 3 Sonnet model", + maxTokens: 8192, + contextWindow: 200000, + supportsPromptCache: true, + inputPrice: 0.01, + outputPrice: 0.02, + }, } handler = new UnboundHandler(mockOptions) mockCreate.mockClear() @@ -205,6 +214,15 @@ describe("UnboundHandler", () => { const nonAnthropicOptions = { apiModelId: "openai/gpt-4o", unboundApiKey: "test-key", + unboundModelId: "openai/gpt-4o", + unboundModelInfo: { + description: "OpenAI's GPT-4", + maxTokens: undefined, + contextWindow: 128000, + supportsPromptCache: true, + inputPrice: 0.01, + outputPrice: 0.03, + }, } const nonAnthropicHandler = new UnboundHandler(nonAnthropicOptions) @@ -230,10 +248,11 @@ describe("UnboundHandler", () => { it("should return default model when invalid model provided", () => { const handlerWithInvalidModel = new UnboundHandler({ ...mockOptions, - apiModelId: "invalid/model", + unboundModelId: "invalid/model", + unboundModelInfo: undefined, }) const modelInfo = handlerWithInvalidModel.getModel() - expect(modelInfo.id).toBe("openai/gpt-4o") // Default model + expect(modelInfo.id).toBe("anthropic/claude-3-5-sonnet-20241022") // Default model expect(modelInfo.info).toBeDefined() }) }) diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 305bd282ad..46b286f5c5 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -1,7 +1,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { ApiHandler, SingleCompletionHandler } from "../" -import { ApiHandlerOptions, ModelInfo, UnboundModelId, unboundDefaultModelId, unboundModels } from "../../shared/api" +import { ApiHandlerOptions, ModelInfo, unboundDefaultModelId, unboundDefaultModelInfo } from "../../shared/api" import { convertToOpenAiMessages } from "../transform/openai-format" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" @@ -129,15 +129,15 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler { } } - 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] } + getModel(): { id: string; info: ModelInfo } { + const modelId = this.options.unboundModelId + const modelInfo = this.options.unboundModelInfo + if (modelId && modelInfo) { + return { id: modelId, info: modelInfo } } return { id: unboundDefaultModelId, - info: unboundModels[unboundDefaultModelId], + info: unboundDefaultModelInfo, } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 039a0f0554..ed02b7be03 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -122,6 +122,7 @@ type GlobalStateKey = | "autoApprovalEnabled" | "customModes" // Array of custom modes | "unboundModelId" + | "unboundModelInfo" export const GlobalFileNames = { apiConversationHistory: "api_conversation_history.json", @@ -129,6 +130,7 @@ export const GlobalFileNames = { glamaModels: "glama_models.json", openRouterModels: "openrouter_models.json", mcpSettings: "cline_mcp_settings.json", + unboundModels: "unbound_models.json", } export class ClineProvider implements vscode.WebviewViewProvider { @@ -665,6 +667,24 @@ export class ClineProvider implements vscode.WebviewViewProvider { } }) + this.readUnboundModels().then((unboundModels) => { + if (unboundModels) { + this.postMessageToWebview({ type: "unboundModels", unboundModels }) + } + }) + this.refreshUnboundModels().then(async (unboundModels) => { + if (unboundModels) { + const { apiConfiguration } = await this.getState() + if (apiConfiguration?.unboundModelId) { + await this.updateGlobalState( + "unboundModelInfo", + unboundModels[apiConfiguration.unboundModelId], + ) + await this.postStateToWebview() + } + } + }) + this.configManager .listConfig() .then(async (listApiConfig) => { @@ -824,6 +844,9 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.postMessageToWebview({ type: "openAiModels", openAiModels }) } break + case "refreshUnboundModels": + await this.refreshUnboundModels() + break case "openImage": openImage(message.text!) break @@ -1563,6 +1586,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { mistralApiKey, unboundApiKey, unboundModelId, + unboundModelInfo, } = apiConfiguration await this.updateGlobalState("apiProvider", apiProvider) await this.updateGlobalState("apiModelId", apiModelId) @@ -1603,6 +1627,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { await this.storeSecret("mistralApiKey", mistralApiKey) await this.storeSecret("unboundApiKey", unboundApiKey) await this.updateGlobalState("unboundModelId", unboundModelId) + await this.updateGlobalState("unboundModelInfo", unboundModelInfo) if (this.cline) { this.cline.api = buildApiHandler(apiConfiguration) } @@ -1808,16 +1833,20 @@ export class ClineProvider implements vscode.WebviewViewProvider { // await this.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) // bad ux if user is on welcome } - async readGlamaModels(): Promise | undefined> { - const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels) - const fileExists = await fileExistsAtPath(glamaModelsFilePath) + private async readModelsFromCache(filename: string): Promise | undefined> { + const filePath = path.join(await this.ensureCacheDirectoryExists(), filename) + const fileExists = await fileExistsAtPath(filePath) if (fileExists) { - const fileContents = await fs.readFile(glamaModelsFilePath, "utf8") + const fileContents = await fs.readFile(filePath, "utf8") return JSON.parse(fileContents) } return undefined } + async readGlamaModels(): Promise | undefined> { + return this.readModelsFromCache(GlobalFileNames.glamaModels) + } + async refreshGlamaModels() { const glamaModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.glamaModels) @@ -1893,16 +1922,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { } async readOpenRouterModels(): Promise | undefined> { - const openRouterModelsFilePath = path.join( - await this.ensureCacheDirectoryExists(), - GlobalFileNames.openRouterModels, - ) - const fileExists = await fileExistsAtPath(openRouterModelsFilePath) - if (fileExists) { - const fileContents = await fs.readFile(openRouterModelsFilePath, "utf8") - return JSON.parse(fileContents) - } - return undefined + return this.readModelsFromCache(GlobalFileNames.openRouterModels) } async refreshOpenRouterModels() { @@ -2017,6 +2037,46 @@ export class ClineProvider implements vscode.WebviewViewProvider { return models } + async readUnboundModels(): Promise | undefined> { + return this.readModelsFromCache(GlobalFileNames.unboundModels) + } + + async refreshUnboundModels() { + const unboundModelsFilePath = path.join(await this.ensureCacheDirectoryExists(), GlobalFileNames.unboundModels) + + const models: Record = {} + try { + const response = await axios.get("https://api.getunbound.ai/models") + + if (response.data) { + const rawModels: Record = response.data + + for (const [modelId, model] of Object.entries(rawModels)) { + models[modelId] = { + maxTokens: model.maxTokens ? parseInt(model.maxTokens) : undefined, + contextWindow: model.contextWindow ? parseInt(model.contextWindow) : 0, + supportsImages: model.supportsImages ?? false, + supportsPromptCache: model.supportsPromptCaching ?? false, + supportsComputerUse: model.supportsComputerUse ?? false, + inputPrice: model.inputTokenPrice ? parseFloat(model.inputTokenPrice) : undefined, + outputPrice: model.outputTokenPrice ? parseFloat(model.outputTokenPrice) : undefined, + cacheWritesPrice: model.cacheWritePrice ? parseFloat(model.cacheWritePrice) : undefined, + cacheReadsPrice: model.cacheReadPrice ? parseFloat(model.cacheReadPrice) : undefined, + } + } + } + await fs.writeFile(unboundModelsFilePath, JSON.stringify(models)) + this.outputChannel.appendLine(`Unbound models fetched and saved: ${JSON.stringify(models, null, 2)}`) + } catch (error) { + this.outputChannel.appendLine( + `Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + } + + await this.postMessageToWebview({ type: "unboundModels", unboundModels: models }) + return models + } + // Task history async getTaskWithId(id: string): Promise<{ @@ -2330,6 +2390,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { experiments, unboundApiKey, unboundModelId, + unboundModelInfo, ] = await Promise.all([ this.getGlobalState("apiProvider") as Promise, this.getGlobalState("apiModelId") as Promise, @@ -2405,6 +2466,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { this.getGlobalState("experiments") as Promise | undefined>, this.getSecret("unboundApiKey") as Promise, this.getGlobalState("unboundModelId") as Promise, + this.getGlobalState("unboundModelInfo") as Promise, ]) let apiProvider: ApiProvider @@ -2462,6 +2524,7 @@ export class ClineProvider implements vscode.WebviewViewProvider { vsCodeLmModelSelector, unboundApiKey, unboundModelId, + unboundModelInfo, }, lastShownAnnouncementId, customInstructions, diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 18cee3ef95..f22ad958a0 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -42,6 +42,8 @@ export interface ExtensionMessage { | "autoApprovalEnabled" | "updateCustomMode" | "deleteCustomMode" + | "unboundModels" + | "refreshUnboundModels" | "currentCheckpointUpdated" text?: string action?: @@ -67,6 +69,7 @@ export interface ExtensionMessage { glamaModels?: Record openRouterModels?: Record openAiModels?: string[] + unboundModels?: Record mcpServers?: McpServer[] commits?: GitCommit[] listApiConfig?: ApiConfigMeta[] diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 7d54aafb9a..9d9c00872c 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -42,6 +42,7 @@ export interface WebviewMessage { | "refreshGlamaModels" | "refreshOpenRouterModels" | "refreshOpenAiModels" + | "refreshUnboundModels" | "alwaysAllowBrowser" | "alwaysAllowMcp" | "alwaysAllowModeSwitch" diff --git a/src/shared/api.ts b/src/shared/api.ts index 39bc2a69ca..77c73a8c36 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -60,6 +60,7 @@ export interface ApiHandlerOptions { includeMaxTokens?: boolean unboundApiKey?: string unboundModelId?: string + unboundModelInfo?: ModelInfo } export type ApiConfiguration = ApiHandlerOptions & { @@ -650,12 +651,14 @@ export const mistralModels = { } as const satisfies Record // Unbound Security -export type UnboundModelId = keyof typeof unboundModels -export const unboundDefaultModelId = "openai/gpt-4o" -export const unboundModels = { - "anthropic/claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"], - "openai/gpt-4o": openAiNativeModels["gpt-4o"], - "deepseek/deepseek-chat": deepSeekModels["deepseek-chat"], - "deepseek/deepseek-reasoner": deepSeekModels["deepseek-reasoner"], - "mistral/codestral-latest": mistralModels["codestral-latest"], -} as const satisfies Record +export const unboundDefaultModelId = "anthropic/claude-3-5-sonnet-20241022" +export const unboundDefaultModelInfo: ModelInfo = { + maxTokens: 8192, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3.0, + outputPrice: 15.0, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, +} diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 4f9c8e1b78..3ba6b245f2 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -28,7 +28,7 @@ import { vertexDefaultModelId, vertexModels, unboundDefaultModelId, - unboundModels, + unboundDefaultModelInfo, } from "../../../../src/shared/api" import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { useExtensionState } from "../../context/ExtensionStateContext" @@ -37,9 +37,11 @@ import VSCodeButtonLink from "../common/VSCodeButtonLink" import { OpenRouterModelPicker } from "./OpenRouterModelPicker" import OpenAiModelPicker from "./OpenAiModelPicker" import { GlamaModelPicker } from "./GlamaModelPicker" +import { UnboundModelPicker } from "./UnboundModelPicker" import { ModelInfoView } from "./ModelInfoView" import { DROPDOWN_Z_INDEX } from "./styles" + interface ApiOptionsProps { apiErrorMessage?: string modelIdErrorMessage?: string @@ -1314,6 +1316,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) = }}> This key is stored locally and only used to make API requests from this extension.

+ )} @@ -1336,7 +1339,8 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) = selectedProvider !== "openrouter" && selectedProvider !== "openai" && selectedProvider !== "ollama" && - selectedProvider !== "lmstudio" && ( + selectedProvider !== "lmstudio" && + selectedProvider !== "unbound" && ( <>
( + +) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index d518878aae..79edcc6a35 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -8,6 +8,8 @@ import { glamaDefaultModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo, + unboundDefaultModelId, + unboundDefaultModelInfo, } from "../../../src/shared/api" import { vscode } from "../utils/vscode" import { convertTextMateToHljs } from "../utils/textMateToHljs" @@ -24,6 +26,7 @@ export interface ExtensionStateContextType extends ExtensionState { theme: any glamaModels: Record openRouterModels: Record + unboundModels: Record openAiModels: string[] mcpServers: McpServer[] currentCheckpoint?: string @@ -124,6 +127,9 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode const [openRouterModels, setOpenRouterModels] = useState>({ [openRouterDefaultModelId]: openRouterDefaultModelInfo, }) + const [unboundModels, setUnboundModels] = useState>({ + [unboundDefaultModelId]: unboundDefaultModelInfo, + }) const [openAiModels, setOpenAiModels] = useState([]) const [mcpServers, setMcpServers] = useState([]) @@ -239,6 +245,11 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setOpenAiModels(updatedModels) break } + case "unboundModels": { + const updatedModels = message.unboundModels ?? {} + setUnboundModels(updatedModels) + break + } case "mcpServers": { setMcpServers(message.mcpServers ?? []) break @@ -270,6 +281,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode glamaModels, openRouterModels, openAiModels, + unboundModels, mcpServers, currentCheckpoint, filePaths, diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 58c34f5c5a..edbaac40a0 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -1,4 +1,4 @@ -import { ApiConfiguration, glamaDefaultModelId, openRouterDefaultModelId } from "../../../src/shared/api" +import { ApiConfiguration, glamaDefaultModelId, openRouterDefaultModelId, unboundDefaultModelId } from "../../../src/shared/api" import { ModelInfo } from "../../../src/shared/api" export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): string | undefined { if (apiConfiguration) { @@ -76,6 +76,7 @@ export function validateModelId( apiConfiguration?: ApiConfiguration, glamaModels?: Record, openRouterModels?: Record, + unboundModels?: Record, ): string | undefined { if (apiConfiguration) { switch (apiConfiguration.apiProvider) { @@ -99,6 +100,16 @@ export function validateModelId( return "The model ID you provided is not available. Please choose a different model." } break + case "unbound": + const unboundModelId = apiConfiguration.unboundModelId || unboundDefaultModelId + if (!unboundModelId) { + return "You must provide a model ID." + } + if (unboundModels && !Object.keys(unboundModels).includes(unboundModelId)) { + // even if the model list endpoint failed, extensionstatecontext will always have the default model info + return "The model ID you provided is not available. Please choose a different model." + } + break } } return undefined