From 62dcfbe549540c8952ac0643bc5bae09a1d172e5 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Wed, 22 Jan 2025 16:15:25 +0530 Subject: [PATCH 01/13] Adds unbound provider to roo cline --- src/api/index.ts | 3 + src/api/providers/unbound.ts | 59 +++++++++++++++++++ src/core/webview/ClineProvider.ts | 13 ++++ src/shared/api.ts | 11 ++++ .../src/components/settings/ApiOptions.tsx | 32 ++++++++++ 5 files changed, 118 insertions(+) create mode 100644 src/api/providers/unbound.ts 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 && (

Date: Thu, 23 Jan 2025 10:33:39 +0530 Subject: [PATCH 02/13] Fixed model mismatch --- src/api/providers/unbound.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 7350398..342e031 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -4,26 +4,22 @@ 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}`, + Authorization: `Bearer ${this.options.unboundApiKey}`, "Content-Type": "application/json", }, body: JSON.stringify({ - model: this.unboundModelId, + model: this.getModel().id, messages: [{ role: "system", content: systemPrompt }, ...messages], }), }) From 20d9a88bb404e631128d4c201d9ebfafa6fbe1b6 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Thu, 23 Jan 2025 10:36:53 +0530 Subject: [PATCH 03/13] Removed comments --- webview-ui/src/components/settings/ApiOptions.tsx | 5 ----- 1 file changed, 5 deletions(-) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index d42d167..802abbe 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -1579,11 +1579,6 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) { } case "unbound": return getProviderData(unboundModels, unboundDefaultModelId) - // return { - // selectedProvider: provider, - // selectedModelId: apiConfiguration?.unboundModelId || unboundDefaultModelId, - // selectedModelInfo: openAiModelInfoSaneDefaults, - // } default: return getProviderData(anthropicModels, anthropicDefaultModelId) } From 698ae6566d998112e00927b1c3c8ceacd800e705 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Thu, 23 Jan 2025 11:11:25 +0530 Subject: [PATCH 04/13] Handles error messages --- src/api/providers/unbound.ts | 53 ++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 23 deletions(-) diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 342e031..5e39798 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -12,32 +12,39 @@ export class UnboundHandler implements ApiHandler { } async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - const response = await fetch(`${this.unboundBaseUrl}/chat/completions`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.options.unboundApiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: this.getModel().id, - messages: [{ role: "system", content: systemPrompt }, ...messages], - }), - }) + try { + const response = await fetch(`${this.unboundBaseUrl}/chat/completions`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.options.unboundApiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: this.getModel().id, + messages: [{ role: "system", content: systemPrompt }, ...messages], + }), + }) - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } + const data = await response.json() - const data = await response.json() + if (!response.ok) { + throw new Error(data.error) + } - yield { - type: "text", - text: data.choices[0]?.message?.content || "", - } - yield { - type: "usage", - inputTokens: data.usage?.prompt_tokens || 0, - outputTokens: data.usage?.completion_tokens || 0, + yield { + type: "text", + text: data.choices[0]?.message?.content || "", + } + yield { + type: "usage", + inputTokens: data.usage?.prompt_tokens || 0, + outputTokens: data.usage?.completion_tokens || 0, + } + } catch (error) { + if (error instanceof Error) { + throw new Error(`Unbound Gateway completion error: ${error.message}`) + } + throw error } } From 270105bac85f61059870f28cf61ff5b5aa8ee5d2 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Thu, 23 Jan 2025 11:54:58 +0530 Subject: [PATCH 05/13] Adds test file --- package-lock.json | 29 ++++++++++ package.json | 1 + src/api/providers/__tests__/unbound.test.ts | 64 +++++++++++++++++++++ 3 files changed, 94 insertions(+) create mode 100644 src/api/providers/__tests__/unbound.test.ts diff --git a/package-lock.json b/package-lock.json index 4a1ae45..f5c7857 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,6 +70,7 @@ "eslint": "^8.57.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "jest-simple-dot-reporter": "^1.0.5", "lint-staged": "^15.2.11", "npm-run-all": "^4.1.5", @@ -7348,6 +7349,16 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10347,6 +10358,17 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-fetch-mock": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", + "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.0.4", + "promise-polyfill": "^8.1.3" + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -12933,6 +12955,13 @@ "node": ">=0.4.0" } }, + "node_modules/promise-polyfill": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", + "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/package.json b/package.json index 80d3b23..a3e4b18 100644 --- a/package.json +++ b/package.json @@ -248,6 +248,7 @@ "eslint": "^8.57.0", "husky": "^9.1.7", "jest": "^29.7.0", + "jest-fetch-mock": "^3.0.3", "jest-simple-dot-reporter": "^1.0.5", "lint-staged": "^15.2.11", "npm-run-all": "^4.1.5", diff --git a/src/api/providers/__tests__/unbound.test.ts b/src/api/providers/__tests__/unbound.test.ts new file mode 100644 index 0000000..721ba53 --- /dev/null +++ b/src/api/providers/__tests__/unbound.test.ts @@ -0,0 +1,64 @@ +import { UnboundHandler } from "../unbound" +import { ApiHandlerOptions } from "../../../shared/api" +import fetchMock from "jest-fetch-mock" + +fetchMock.enableMocks() + +describe("UnboundHandler", () => { + const mockOptions: ApiHandlerOptions = { + unboundApiKey: "test-api-key", + apiModelId: "test-model-id", + } + + beforeEach(() => { + fetchMock.resetMocks() + }) + + it("should initialize with options", () => { + const handler = new UnboundHandler(mockOptions) + expect(handler).toBeDefined() + }) + + it("should create a message successfully", async () => { + const handler = new UnboundHandler(mockOptions) + const mockResponse = { + choices: [{ message: { content: "Hello, world!" } }], + usage: { prompt_tokens: 5, completion_tokens: 7 }, + } + + fetchMock.mockResponseOnce(JSON.stringify(mockResponse)) + + const generator = handler.createMessage("system prompt", []) + const textResult = await generator.next() + const usageResult = await generator.next() + + expect(textResult.value).toEqual({ type: "text", text: "Hello, world!" }) + expect(usageResult.value).toEqual({ + type: "usage", + inputTokens: 5, + outputTokens: 7, + }) + }) + + it("should handle API errors", async () => { + const handler = new UnboundHandler(mockOptions) + fetchMock.mockResponseOnce(JSON.stringify({ error: "API error" }), { status: 400 }) + + const generator = handler.createMessage("system prompt", []) + await expect(generator.next()).rejects.toThrow("Unbound Gateway completion error: API error") + }) + + it("should handle network errors", async () => { + const handler = new UnboundHandler(mockOptions) + fetchMock.mockRejectOnce(new Error("Network error")) + + const generator = handler.createMessage("system prompt", []) + await expect(generator.next()).rejects.toThrow("Unbound Gateway completion error: Network error") + }) + + it("should return the correct model", () => { + const handler = new UnboundHandler(mockOptions) + const model = handler.getModel() + expect(model.id).toBe("gpt-4o") + }) +}) From d78c6a686274a59684bcaa2fd8f07a0adf77d64a Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Thu, 23 Jan 2025 12:04:57 +0530 Subject: [PATCH 06/13] Updates test file --- package-lock.json | 29 ----------------------------- package.json | 1 - 2 files changed, 30 deletions(-) diff --git a/package-lock.json b/package-lock.json index f5c7857..4a1ae45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,6 @@ "eslint": "^8.57.0", "husky": "^9.1.7", "jest": "^29.7.0", - "jest-fetch-mock": "^3.0.3", "jest-simple-dot-reporter": "^1.0.5", "lint-staged": "^15.2.11", "npm-run-all": "^4.1.5", @@ -7349,16 +7348,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/cross-fetch": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", - "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "node-fetch": "^2.7.0" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10358,17 +10347,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/jest-fetch-mock": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz", - "integrity": "sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-fetch": "^3.0.4", - "promise-polyfill": "^8.1.3" - } - }, "node_modules/jest-get-type": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", @@ -12955,13 +12933,6 @@ "node": ">=0.4.0" } }, - "node_modules/promise-polyfill": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-8.3.0.tgz", - "integrity": "sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg==", - "dev": true, - "license": "MIT" - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", diff --git a/package.json b/package.json index a3e4b18..80d3b23 100644 --- a/package.json +++ b/package.json @@ -248,7 +248,6 @@ "eslint": "^8.57.0", "husky": "^9.1.7", "jest": "^29.7.0", - "jest-fetch-mock": "^3.0.3", "jest-simple-dot-reporter": "^1.0.5", "lint-staged": "^15.2.11", "npm-run-all": "^4.1.5", From 336f76baa953fd0d159c3a10f94375eb5d010fca Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Thu, 23 Jan 2025 17:53:03 +0530 Subject: [PATCH 07/13] Changes production url --- src/api/providers/unbound.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 5e39798..c88e034 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -4,7 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { ApiHandler } from "../index" export class UnboundHandler implements ApiHandler { - private unboundBaseUrl: string = "https://ai-gateway-43843357113.us-west1.run.app/v1" + private unboundBaseUrl: string = "https://api.getunbound.ai/v1" private options: ApiHandlerOptions constructor(options: ApiHandlerOptions) { From f222e8341c49be4a60d7de65f9c031315a0507d5 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Fri, 24 Jan 2025 19:01:58 +0530 Subject: [PATCH 08/13] Added mistral and deepseek models --- src/shared/api.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/shared/api.ts b/src/shared/api.ts index 8ba1730..3eccf4c 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -601,6 +601,9 @@ export const mistralModels = { 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"], + "gpt-4o": openAiNativeModels["gpt-4o"], + "deepseek-chat": deepSeekModels["deepseek-chat"], + "deepseek-reasoner": deepSeekModels["deepseek-reasoner"], + "codestral-latest": mistralModels["codestral-latest"], } as const satisfies Record From aed51a2bc5336a5349914c3bb0c7989a9f0b05b1 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Mon, 27 Jan 2025 10:49:41 +0530 Subject: [PATCH 09/13] Updates error reading --- src/api/providers/unbound.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index c88e034..91eba17 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -28,7 +28,7 @@ export class UnboundHandler implements ApiHandler { const data = await response.json() if (!response.ok) { - throw new Error(data.error) + throw new Error(data.error.message) } yield { @@ -42,7 +42,7 @@ export class UnboundHandler implements ApiHandler { } } catch (error) { if (error instanceof Error) { - throw new Error(`Unbound Gateway completion error: ${error.message}`) + throw new Error(`Unbound Gateway completion error:\n ${error.message}`) } throw error } From 31ec68776841e68d580f5b8d7239b1342a798446 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Mon, 27 Jan 2025 11:16:57 +0530 Subject: [PATCH 10/13] Adds provider names to dropdown --- src/api/providers/unbound.ts | 2 +- src/shared/api.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 91eba17..842ab26 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -20,7 +20,7 @@ export class UnboundHandler implements ApiHandler { "Content-Type": "application/json", }, body: JSON.stringify({ - model: this.getModel().id, + model: this.getModel().id.split("/")[1], messages: [{ role: "system", content: systemPrompt }, ...messages], }), }) diff --git a/src/shared/api.ts b/src/shared/api.ts index 3eccf4c..e5bcda4 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -599,11 +599,11 @@ export const mistralModels = { // Unbound Security export type UnboundModelId = keyof typeof unboundModels -export const unboundDefaultModelId = "gpt-4o" +export const unboundDefaultModelId = "openai/gpt-4o" export const unboundModels = { - "claude-3-5-sonnet-20241022": anthropicModels["claude-3-5-sonnet-20241022"], - "gpt-4o": openAiNativeModels["gpt-4o"], - "deepseek-chat": deepSeekModels["deepseek-chat"], - "deepseek-reasoner": deepSeekModels["deepseek-reasoner"], - "codestral-latest": mistralModels["codestral-latest"], + "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 From 4008a1a53e3c2c6609f0409c1d92998f4747db27 Mon Sep 17 00:00:00 2001 From: Vignesh Subbiah Date: Tue, 28 Jan 2025 19:43:52 +0530 Subject: [PATCH 11/13] Modifying the usage of unbound.ts in compliance with all providers --- src/api/providers/__tests__/unbound.test.ts | 236 ++++++++++++++++---- src/api/providers/unbound.ts | 146 +++++++++--- 2 files changed, 304 insertions(+), 78 deletions(-) diff --git a/src/api/providers/__tests__/unbound.test.ts b/src/api/providers/__tests__/unbound.test.ts index 721ba53..7d11e6d 100644 --- a/src/api/providers/__tests__/unbound.test.ts +++ b/src/api/providers/__tests__/unbound.test.ts @@ -1,64 +1,210 @@ import { UnboundHandler } from "../unbound" import { ApiHandlerOptions } from "../../../shared/api" -import fetchMock from "jest-fetch-mock" +import OpenAI from "openai" +import { Anthropic } from "@anthropic-ai/sdk" -fetchMock.enableMocks() +// Mock OpenAI client +const mockCreate = jest.fn() +const mockWithResponse = jest.fn() + +jest.mock("openai", () => { + return { + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + chat: { + completions: { + create: (...args: any[]) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { content: "Test response" }, + index: 0, + }, + ], + } + yield { + choices: [ + { + delta: {}, + index: 0, + }, + ], + } + }, + } + + const result = mockCreate(...args) + if (args[0].stream) { + mockWithResponse.mockReturnValue( + Promise.resolve({ + data: stream, + response: { headers: new Map() }, + }), + ) + result.withResponse = mockWithResponse + } + return result + }, + }, + }, + })), + } +}) describe("UnboundHandler", () => { - const mockOptions: ApiHandlerOptions = { - unboundApiKey: "test-api-key", - apiModelId: "test-model-id", - } + let handler: UnboundHandler + let mockOptions: ApiHandlerOptions beforeEach(() => { - fetchMock.resetMocks() - }) - - it("should initialize with options", () => { - const handler = new UnboundHandler(mockOptions) - expect(handler).toBeDefined() - }) - - it("should create a message successfully", async () => { - const handler = new UnboundHandler(mockOptions) - const mockResponse = { - choices: [{ message: { content: "Hello, world!" } }], - usage: { prompt_tokens: 5, completion_tokens: 7 }, + mockOptions = { + apiModelId: "anthropic/claude-3-5-sonnet-20241022", + unboundApiKey: "test-api-key", } + handler = new UnboundHandler(mockOptions) + mockCreate.mockClear() + mockWithResponse.mockClear() - fetchMock.mockResponseOnce(JSON.stringify(mockResponse)) - - const generator = handler.createMessage("system prompt", []) - const textResult = await generator.next() - const usageResult = await generator.next() - - expect(textResult.value).toEqual({ type: "text", text: "Hello, world!" }) - expect(usageResult.value).toEqual({ - type: "usage", - inputTokens: 5, - outputTokens: 7, + // Default mock implementation for non-streaming responses + mockCreate.mockResolvedValue({ + id: "test-completion", + choices: [ + { + message: { role: "assistant", content: "Test response" }, + finish_reason: "stop", + index: 0, + }, + ], }) }) - it("should handle API errors", async () => { - const handler = new UnboundHandler(mockOptions) - fetchMock.mockResponseOnce(JSON.stringify({ error: "API error" }), { status: 400 }) - - const generator = handler.createMessage("system prompt", []) - await expect(generator.next()).rejects.toThrow("Unbound Gateway completion error: API error") + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(UnboundHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) + }) }) - it("should handle network errors", async () => { - const handler = new UnboundHandler(mockOptions) - fetchMock.mockRejectOnce(new Error("Network error")) + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello!", + }, + ] - const generator = handler.createMessage("system prompt", []) - await expect(generator.next()).rejects.toThrow("Unbound Gateway completion error: Network error") + it("should handle streaming responses", async () => { + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBe(1) + expect(chunks[0]).toEqual({ + type: "text", + text: "Test response", + }) + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "claude-3-5-sonnet-20241022", + messages: expect.any(Array), + stream: true, + }), + expect.objectContaining({ + headers: { + "X-Unbound-Metadata": expect.stringContaining("roo-code"), + }, + }), + ) + }) + + it("should handle API errors", async () => { + mockCreate.mockImplementationOnce(() => { + throw new Error("API Error") + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks = [] + + try { + for await (const chunk of stream) { + chunks.push(chunk) + } + fail("Expected error to be thrown") + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect(error.message).toBe("API Error") + } + }) }) - it("should return the correct model", () => { - const handler = new UnboundHandler(mockOptions) - const model = handler.getModel() - expect(model.id).toBe("gpt-4o") + describe("completePrompt", () => { + it("should complete prompt successfully", async () => { + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("Test response") + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "claude-3-5-sonnet-20241022", + messages: [{ role: "user", content: "Test prompt" }], + temperature: 0, + max_tokens: 8192, + }), + ) + }) + + it("should handle API errors", async () => { + mockCreate.mockRejectedValueOnce(new Error("API Error")) + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("Unbound completion error: API Error") + }) + + it("should handle empty response", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "" } }], + }) + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("") + }) + + it("should not set max_tokens for non-Anthropic models", async () => { + mockCreate.mockClear() + + const nonAnthropicOptions = { + apiModelId: "openai/gpt-4o", + unboundApiKey: "test-key", + } + const nonAnthropicHandler = new UnboundHandler(nonAnthropicOptions) + + await nonAnthropicHandler.completePrompt("Test prompt") + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + model: "gpt-4o", + messages: [{ role: "user", content: "Test prompt" }], + temperature: 0, + }), + ) + expect(mockCreate.mock.calls[0][0]).not.toHaveProperty("max_tokens") + }) + }) + + describe("getModel", () => { + it("should return model info", () => { + const modelInfo = handler.getModel() + expect(modelInfo.id).toBe(mockOptions.apiModelId) + expect(modelInfo.info).toBeDefined() + }) + + it("should return default model when invalid model provided", () => { + const handlerWithInvalidModel = new UnboundHandler({ + ...mockOptions, + apiModelId: "invalid/model", + }) + const modelInfo = handlerWithInvalidModel.getModel() + expect(modelInfo.id).toBe("openai/gpt-4o") // Default model + expect(modelInfo.info).toBeDefined() + }) }) }) diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 842ab26..1992d71 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -1,50 +1,108 @@ -import { ApiHandlerOptions, unboundModels, UnboundModelId, unboundDefaultModelId, ModelInfo } from "../../shared/api" -import { ApiStream } from "../transform/stream" import { Anthropic } from "@anthropic-ai/sdk" -import { ApiHandler } from "../index" +import OpenAI from "openai" +import { ApiHandler, SingleCompletionHandler } from "../" +import { ApiHandlerOptions, ModelInfo, UnboundModelId, unboundDefaultModelId, unboundModels } from "../../shared/api" +import { convertToOpenAiMessages } from "../transform/openai-format" +import { ApiStream } from "../transform/stream" -export class UnboundHandler implements ApiHandler { - private unboundBaseUrl: string = "https://api.getunbound.ai/v1" +export class UnboundHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions + private client: OpenAI constructor(options: ApiHandlerOptions) { this.options = options + this.client = new OpenAI({ + baseURL: "https://api.getunbound.ai/v1", + apiKey: this.options.unboundApiKey, + }) } async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { - try { - const response = await fetch(`${this.unboundBaseUrl}/chat/completions`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.options.unboundApiKey}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - model: this.getModel().id.split("/")[1], - messages: [{ role: "system", content: systemPrompt }, ...messages], - }), + // Convert Anthropic messages to OpenAI format + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + // this is specifically for claude models (some models may 'support prompt caching' automatically without this) + if (this.getModel().id.startsWith("anthropic/claude-3")) { + openAiMessages[0] = { + role: "system", + content: [ + { + type: "text", + text: systemPrompt, + // @ts-ignore-next-line + cache_control: { type: "ephemeral" }, + }, + ], + } + + // Add cache_control to the last two user messages + // (note: this works because we only ever add one user message at a time, + // but if we added multiple we'd need to mark the user message before the last assistant message) + const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2) + lastTwoUserMessages.forEach((msg) => { + if (typeof msg.content === "string") { + msg.content = [{ type: "text", text: msg.content }] + } + if (Array.isArray(msg.content)) { + // NOTE: this is fine since env details will always be added at the end. + // but if it weren't there, and the user added a image_url type message, + // it would pop a text part before it and then move it after to the end. + let lastTextPart = msg.content.filter((part) => part.type === "text").pop() + + if (!lastTextPart) { + lastTextPart = { type: "text", text: "..." } + msg.content.push(lastTextPart) + } + // @ts-ignore-next-line + lastTextPart["cache_control"] = { type: "ephemeral" } + } }) + } - const data = await response.json() + // Required by Anthropic + // Other providers default to max tokens allowed. + let maxTokens: number | undefined - if (!response.ok) { - throw new Error(data.error.message) - } + if (this.getModel().id.startsWith("anthropic/")) { + maxTokens = 8_192 + } - yield { - type: "text", - text: data.choices[0]?.message?.content || "", + const { data: completion, response } = await this.client.chat.completions + .create( + { + model: this.getModel().id.split("/")[1], + max_tokens: maxTokens, + temperature: 0, + messages: openAiMessages, + stream: true, + }, + { + headers: { + "X-Unbound-Metadata": JSON.stringify({ + labels: [ + { + key: "app", + value: "roo-code", + }, + ], + }), + }, + }, + ) + .withResponse() + + for await (const chunk of completion) { + const delta = chunk.choices[0]?.delta + + if (delta?.content) { + yield { + type: "text", + text: delta.content, + } } - yield { - type: "usage", - inputTokens: data.usage?.prompt_tokens || 0, - outputTokens: data.usage?.completion_tokens || 0, - } - } catch (error) { - if (error instanceof Error) { - throw new Error(`Unbound Gateway completion error:\n ${error.message}`) - } - throw error } } @@ -59,4 +117,26 @@ export class UnboundHandler implements ApiHandler { info: unboundModels[unboundDefaultModelId], } } + + async completePrompt(prompt: string): Promise { + try { + const requestOptions: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { + model: this.getModel().id.split("/")[1], + messages: [{ role: "user", content: prompt }], + temperature: 0, + } + + if (this.getModel().id.startsWith("anthropic/")) { + requestOptions.max_tokens = 8192 + } + + const response = await this.client.chat.completions.create(requestOptions) + return response.choices[0]?.message.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`Unbound completion error: ${error.message}`) + } + throw error + } + } } From 5329c712de34a07d541a02dc91ab00a55afb785e Mon Sep 17 00:00:00 2001 From: Vignesh Subbiah Date: Tue, 28 Jan 2025 21:11:16 +0530 Subject: [PATCH 12/13] Yields the usage tokens --- src/api/providers/unbound.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 1992d71..23e419c 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -96,6 +96,7 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler { for await (const chunk of completion) { const delta = chunk.choices[0]?.delta + const usage = chunk.usage if (delta?.content) { yield { @@ -103,6 +104,14 @@ export class UnboundHandler implements ApiHandler, SingleCompletionHandler { text: delta.content, } } + + if (usage) { + yield { + type: "usage", + inputTokens: usage?.prompt_tokens || 0, + outputTokens: usage?.completion_tokens || 0, + } + } } } From 63b8e8972f39ce6cc5b7e63fc0bd977f71b6c7ef Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Tue, 28 Jan 2025 21:52:12 +0530 Subject: [PATCH 13/13] Adds button to get unbound api key --- webview-ui/src/components/settings/ApiOptions.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 802abbe..1199914 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -1296,6 +1296,14 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) = placeholder="Enter API Key..."> Unbound API Key + {!apiConfiguration?.unboundApiKey && ( + + Get Unbound API Key + + )}