Merge pull request #616 from websentry-ai/vs/support-unbound

Supports unbound API provider
This commit is contained in:
Matt Rubens
2025-01-29 00:10:21 -05:00
committed by GitHub
6 changed files with 426 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ import { DeepSeekHandler } from "./providers/deepseek"
import { MistralHandler } from "./providers/mistral" import { MistralHandler } from "./providers/mistral"
import { VsCodeLmHandler } from "./providers/vscode-lm" import { VsCodeLmHandler } from "./providers/vscode-lm"
import { ApiStream } from "./transform/stream" import { ApiStream } from "./transform/stream"
import { UnboundHandler } from "./providers/unbound"
export interface SingleCompletionHandler { export interface SingleCompletionHandler {
completePrompt(prompt: string): Promise<string> completePrompt(prompt: string): Promise<string>
@@ -53,6 +54,8 @@ export function buildApiHandler(configuration: ApiConfiguration): ApiHandler {
return new VsCodeLmHandler(options) return new VsCodeLmHandler(options)
case "mistral": case "mistral":
return new MistralHandler(options) return new MistralHandler(options)
case "unbound":
return new UnboundHandler(options)
default: default:
return new AnthropicHandler(options) return new AnthropicHandler(options)
} }

View File

@@ -0,0 +1,210 @@
import { UnboundHandler } from "../unbound"
import { ApiHandlerOptions } from "../../../shared/api"
import OpenAI from "openai"
import { Anthropic } from "@anthropic-ai/sdk"
// 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", () => {
let handler: UnboundHandler
let mockOptions: ApiHandlerOptions
beforeEach(() => {
mockOptions = {
apiModelId: "anthropic/claude-3-5-sonnet-20241022",
unboundApiKey: "test-api-key",
}
handler = new UnboundHandler(mockOptions)
mockCreate.mockClear()
mockWithResponse.mockClear()
// Default mock implementation for non-streaming responses
mockCreate.mockResolvedValue({
id: "test-completion",
choices: [
{
message: { role: "assistant", content: "Test response" },
finish_reason: "stop",
index: 0,
},
],
})
})
describe("constructor", () => {
it("should initialize with provided options", () => {
expect(handler).toBeInstanceOf(UnboundHandler)
expect(handler.getModel().id).toBe(mockOptions.apiModelId)
})
})
describe("createMessage", () => {
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [
{
role: "user",
content: "Hello!",
},
]
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")
}
})
})
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()
})
})
})

View File

@@ -0,0 +1,151 @@
import { Anthropic } from "@anthropic-ai/sdk"
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, 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 {
// 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" }
}
})
}
// Required by Anthropic
// Other providers default to max tokens allowed.
let maxTokens: number | undefined
if (this.getModel().id.startsWith("anthropic/")) {
maxTokens = 8_192
}
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
const usage = chunk.usage
if (delta?.content) {
yield {
type: "text",
text: delta.content,
}
}
if (usage) {
yield {
type: "usage",
inputTokens: usage?.prompt_tokens || 0,
outputTokens: 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],
}
}
async completePrompt(prompt: string): Promise<string> {
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
}
}
}

View File

@@ -63,6 +63,7 @@ type SecretKey =
| "openAiNativeApiKey" | "openAiNativeApiKey"
| "deepSeekApiKey" | "deepSeekApiKey"
| "mistralApiKey" | "mistralApiKey"
| "unboundApiKey"
type GlobalStateKey = type GlobalStateKey =
| "apiProvider" | "apiProvider"
| "apiModelId" | "apiModelId"
@@ -122,6 +123,7 @@ type GlobalStateKey =
| "experiments" // Map of experiment IDs to their enabled state | "experiments" // Map of experiment IDs to their enabled state
| "autoApprovalEnabled" | "autoApprovalEnabled"
| "customModes" // Array of custom modes | "customModes" // Array of custom modes
| "unboundModelId"
export const GlobalFileNames = { export const GlobalFileNames = {
apiConversationHistory: "api_conversation_history.json", apiConversationHistory: "api_conversation_history.json",
@@ -1397,6 +1399,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
openRouterUseMiddleOutTransform, openRouterUseMiddleOutTransform,
vsCodeLmModelSelector, vsCodeLmModelSelector,
mistralApiKey, mistralApiKey,
unboundApiKey,
unboundModelId,
} = apiConfiguration } = apiConfiguration
await this.updateGlobalState("apiProvider", apiProvider) await this.updateGlobalState("apiProvider", apiProvider)
await this.updateGlobalState("apiModelId", apiModelId) await this.updateGlobalState("apiModelId", apiModelId)
@@ -1435,6 +1439,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform) await this.updateGlobalState("openRouterUseMiddleOutTransform", openRouterUseMiddleOutTransform)
await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector) await this.updateGlobalState("vsCodeLmModelSelector", vsCodeLmModelSelector)
await this.storeSecret("mistralApiKey", mistralApiKey) await this.storeSecret("mistralApiKey", mistralApiKey)
await this.storeSecret("unboundApiKey", unboundApiKey)
await this.updateGlobalState("unboundModelId", unboundModelId)
if (this.cline) { if (this.cline) {
this.cline.api = buildApiHandler(apiConfiguration) this.cline.api = buildApiHandler(apiConfiguration)
} }
@@ -2102,6 +2108,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
autoApprovalEnabled, autoApprovalEnabled,
customModes, customModes,
experiments, experiments,
unboundApiKey,
unboundModelId,
] = await Promise.all([ ] = await Promise.all([
this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>, this.getGlobalState("apiProvider") as Promise<ApiProvider | undefined>,
this.getGlobalState("apiModelId") as Promise<string | undefined>, this.getGlobalState("apiModelId") as Promise<string | undefined>,
@@ -2172,6 +2180,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
this.getGlobalState("autoApprovalEnabled") as Promise<boolean | undefined>, this.getGlobalState("autoApprovalEnabled") as Promise<boolean | undefined>,
this.customModesManager.getCustomModes(), this.customModesManager.getCustomModes(),
this.getGlobalState("experiments") as Promise<Record<ExperimentId, boolean> | undefined>, this.getGlobalState("experiments") as Promise<Record<ExperimentId, boolean> | undefined>,
this.getSecret("unboundApiKey") as Promise<string | undefined>,
this.getGlobalState("unboundModelId") as Promise<string | undefined>,
]) ])
let apiProvider: ApiProvider let apiProvider: ApiProvider
@@ -2227,6 +2237,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
openRouterBaseUrl, openRouterBaseUrl,
openRouterUseMiddleOutTransform, openRouterUseMiddleOutTransform,
vsCodeLmModelSelector, vsCodeLmModelSelector,
unboundApiKey,
unboundModelId,
}, },
lastShownAnnouncementId, lastShownAnnouncementId,
customInstructions, customInstructions,
@@ -2376,6 +2388,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
"openAiNativeApiKey", "openAiNativeApiKey",
"deepSeekApiKey", "deepSeekApiKey",
"mistralApiKey", "mistralApiKey",
"unboundApiKey",
] ]
for (const key of secretKeys) { for (const key of secretKeys) {
await this.storeSecret(key, undefined) await this.storeSecret(key, undefined)

View File

@@ -14,6 +14,7 @@ export type ApiProvider =
| "deepseek" | "deepseek"
| "vscode-lm" | "vscode-lm"
| "mistral" | "mistral"
| "unbound"
export interface ApiHandlerOptions { export interface ApiHandlerOptions {
apiModelId?: string apiModelId?: string
@@ -57,6 +58,8 @@ export interface ApiHandlerOptions {
deepSeekBaseUrl?: string deepSeekBaseUrl?: string
deepSeekApiKey?: string deepSeekApiKey?: string
includeMaxTokens?: boolean includeMaxTokens?: boolean
unboundApiKey?: string
unboundModelId?: string
} }
export type ApiConfiguration = ApiHandlerOptions & { export type ApiConfiguration = ApiHandlerOptions & {
@@ -593,3 +596,14 @@ export const mistralModels = {
outputPrice: 0.9, outputPrice: 0.9,
}, },
} as const satisfies Record<string, ModelInfo> } as const satisfies Record<string, ModelInfo>
// 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<string, ModelInfo>

View File

@@ -26,6 +26,8 @@ import {
openRouterDefaultModelInfo, openRouterDefaultModelInfo,
vertexDefaultModelId, vertexDefaultModelId,
vertexModels, vertexModels,
unboundDefaultModelId,
unboundModels,
} from "../../../../src/shared/api" } from "../../../../src/shared/api"
import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage" import { ExtensionMessage } from "../../../../src/shared/ExtensionMessage"
import { useExtensionState } from "../../context/ExtensionStateContext" import { useExtensionState } from "../../context/ExtensionStateContext"
@@ -147,6 +149,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
{ value: "mistral", label: "Mistral" }, { value: "mistral", label: "Mistral" },
{ value: "lmstudio", label: "LM Studio" }, { value: "lmstudio", label: "LM Studio" },
{ value: "ollama", label: "Ollama" }, { value: "ollama", label: "Ollama" },
{ value: "unbound", label: "Unbound" },
]} ]}
/> />
</div> </div>
@@ -1283,6 +1286,35 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
</div> </div>
)} )}
{selectedProvider === "unbound" && (
<div>
<VSCodeTextField
value={apiConfiguration?.unboundApiKey || ""}
style={{ width: "100%" }}
type="password"
onChange={handleInputChange("unboundApiKey")}
placeholder="Enter API Key...">
<span style={{ fontWeight: 500 }}>Unbound API Key</span>
</VSCodeTextField>
{!apiConfiguration?.unboundApiKey && (
<VSCodeButtonLink
href="https://gateway.getunbound.ai"
style={{ margin: "5px 0 0 0" }}
appearance="secondary">
Get Unbound API Key
</VSCodeButtonLink>
)}
<p
style={{
fontSize: "12px",
marginTop: 3,
color: "var(--vscode-descriptionForeground)",
}}>
This key is stored locally and only used to make API requests from this extension.
</p>
</div>
)}
{apiErrorMessage && ( {apiErrorMessage && (
<p <p
style={{ style={{
@@ -1315,6 +1347,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)} {selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)} {selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
{selectedProvider === "mistral" && createDropdown(mistralModels)} {selectedProvider === "mistral" && createDropdown(mistralModels)}
{selectedProvider === "unbound" && createDropdown(unboundModels)}
</div> </div>
<ModelInfoView <ModelInfoView
@@ -1552,6 +1585,8 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
supportsImages: false, // VSCode LM API currently doesn't support images supportsImages: false, // VSCode LM API currently doesn't support images
}, },
} }
case "unbound":
return getProviderData(unboundModels, unboundDefaultModelId)
default: default:
return getProviderData(anthropicModels, anthropicDefaultModelId) return getProviderData(anthropicModels, anthropicDefaultModelId)
} }