mirror of
https://github.com/pacnpal/Roo-Code.git
synced 2025-12-20 04:11:10 -05:00
Merge pull request #616 from websentry-ai/vs/support-unbound
Supports unbound API provider
This commit is contained in:
@@ -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<string>
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
210
src/api/providers/__tests__/unbound.test.ts
Normal file
210
src/api/providers/__tests__/unbound.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
151
src/api/providers/unbound.ts
Normal file
151
src/api/providers/unbound.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ type SecretKey =
|
||||
| "openAiNativeApiKey"
|
||||
| "deepSeekApiKey"
|
||||
| "mistralApiKey"
|
||||
| "unboundApiKey"
|
||||
type GlobalStateKey =
|
||||
| "apiProvider"
|
||||
| "apiModelId"
|
||||
@@ -122,6 +123,7 @@ type GlobalStateKey =
|
||||
| "experiments" // Map of experiment IDs to their enabled state
|
||||
| "autoApprovalEnabled"
|
||||
| "customModes" // Array of custom modes
|
||||
| "unboundModelId"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
@@ -1397,6 +1399,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
openRouterUseMiddleOutTransform,
|
||||
vsCodeLmModelSelector,
|
||||
mistralApiKey,
|
||||
unboundApiKey,
|
||||
unboundModelId,
|
||||
} = apiConfiguration
|
||||
await this.updateGlobalState("apiProvider", apiProvider)
|
||||
await this.updateGlobalState("apiModelId", apiModelId)
|
||||
@@ -1435,6 +1439,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)
|
||||
}
|
||||
@@ -2102,6 +2108,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
autoApprovalEnabled,
|
||||
customModes,
|
||||
experiments,
|
||||
unboundApiKey,
|
||||
unboundModelId,
|
||||
] = await Promise.all([
|
||||
this.getGlobalState("apiProvider") as Promise<ApiProvider | 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.customModesManager.getCustomModes(),
|
||||
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
|
||||
@@ -2227,6 +2237,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
openRouterBaseUrl,
|
||||
openRouterUseMiddleOutTransform,
|
||||
vsCodeLmModelSelector,
|
||||
unboundApiKey,
|
||||
unboundModelId,
|
||||
},
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
@@ -2376,6 +2388,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
||||
"openAiNativeApiKey",
|
||||
"deepSeekApiKey",
|
||||
"mistralApiKey",
|
||||
"unboundApiKey",
|
||||
]
|
||||
for (const key of secretKeys) {
|
||||
await this.storeSecret(key, undefined)
|
||||
|
||||
@@ -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,14 @@ export const mistralModels = {
|
||||
outputPrice: 0.9,
|
||||
},
|
||||
} 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>
|
||||
|
||||
@@ -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" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
@@ -1283,6 +1286,35 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
|
||||
</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 && (
|
||||
<p
|
||||
style={{
|
||||
@@ -1315,6 +1347,7 @@ const ApiOptions = ({ apiErrorMessage, modelIdErrorMessage }: ApiOptionsProps) =
|
||||
{selectedProvider === "openai-native" && createDropdown(openAiNativeModels)}
|
||||
{selectedProvider === "deepseek" && createDropdown(deepSeekModels)}
|
||||
{selectedProvider === "mistral" && createDropdown(mistralModels)}
|
||||
{selectedProvider === "unbound" && createDropdown(unboundModels)}
|
||||
</div>
|
||||
|
||||
<ModelInfoView
|
||||
@@ -1552,6 +1585,8 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
||||
supportsImages: false, // VSCode LM API currently doesn't support images
|
||||
},
|
||||
}
|
||||
case "unbound":
|
||||
return getProviderData(unboundModels, unboundDefaultModelId)
|
||||
default:
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user