From 11a74a76c77a683c011c92164104d960843c37a5 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Fri, 20 Feb 2026 13:35:49 +0530 Subject: [PATCH 1/3] feat: restore Unbound as a provider --- apps/cli/src/lib/utils/context-window.ts | 2 + packages/types/src/global-settings.ts | 1 + packages/types/src/provider-settings.ts | 13 +- packages/types/src/providers/index.ts | 4 + packages/types/src/providers/unbound.ts | 16 ++ src/api/index.ts | 3 + src/api/providers/fetchers/modelCache.ts | 4 + src/api/providers/fetchers/unbound.ts | 40 ++++ src/api/providers/index.ts | 1 + src/api/providers/unbound.ts | 212 ++++++++++++++++++ src/core/webview/webviewMessageHandler.ts | 8 + src/shared/ProfileValidator.ts | 2 + src/shared/api.ts | 1 + .../src/components/settings/ApiOptions.tsx | 15 ++ .../src/components/settings/ModelPicker.tsx | 1 + .../src/components/settings/constants.ts | 1 + .../components/settings/providers/Unbound.tsx | 101 +++++++++ .../components/settings/providers/index.ts | 1 + .../settings/utils/providerModelConfig.ts | 1 + .../components/ui/hooks/useSelectedModel.ts | 5 + webview-ui/src/i18n/locales/en/settings.json | 2 + .../src/utils/__tests__/validate.spec.ts | 1 + webview-ui/src/utils/validate.ts | 5 + 23 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 packages/types/src/providers/unbound.ts create mode 100644 src/api/providers/fetchers/unbound.ts create mode 100644 src/api/providers/unbound.ts create mode 100644 webview-ui/src/components/settings/providers/Unbound.tsx diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index df878e16b02..5cd58b55a8f 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -46,6 +46,8 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { return config.openAiModelId case "requesty": return config.requestyModelId + case "unbound": + return config.unboundModelId case "litellm": return config.litellmModelId case "vercel-ai-gateway": diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index de3bd076616..91b37f3d6d1 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -264,6 +264,7 @@ export const SECRET_STATE_KEYS = [ "mistralApiKey", "minimaxApiKey", "requestyApiKey", + "unboundApiKey", "xaiApiKey", "litellmApiKey", "codeIndexOpenAiKey", diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index fef422666d2..859792d7c36 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -34,7 +34,7 @@ export const DEFAULT_CONSECUTIVE_MISTAKE_LIMIT = 3 * Dynamic provider requires external API calls in order to get the model list. */ -export const dynamicProviders = ["openrouter", "vercel-ai-gateway", "litellm", "requesty", "roo"] as const +export const dynamicProviders = ["openrouter", "vercel-ai-gateway", "litellm", "requesty", "roo", "unbound"] as const export type DynamicProvider = (typeof dynamicProviders)[number] @@ -142,7 +142,6 @@ export const retiredProviderNames = [ "groq", "huggingface", "io-intelligence", - "unbound", ] as const export const retiredProviderNamesSchema = z.enum(retiredProviderNames) @@ -327,6 +326,11 @@ const requestySchema = baseProviderSettingsSchema.extend({ requestyModelId: z.string().optional(), }) +const unboundSchema = baseProviderSettingsSchema.extend({ + unboundApiKey: z.string().optional(), + unboundModelId: z.string().optional(), +}) + const fakeAiSchema = baseProviderSettingsSchema.extend({ fakeAi: z.unknown().optional(), }) @@ -399,6 +403,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })), minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })), requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })), + unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })), fakeAiSchema.merge(z.object({ apiProvider: z.literal("fake-ai") })), xaiSchema.merge(z.object({ apiProvider: z.literal("xai") })), basetenSchema.merge(z.object({ apiProvider: z.literal("baseten") })), @@ -431,6 +436,7 @@ export const providerSettingsSchema = z.object({ ...moonshotSchema.shape, ...minimaxSchema.shape, ...requestySchema.shape, + ...unboundSchema.shape, ...fakeAiSchema.shape, ...xaiSchema.shape, ...basetenSchema.shape, @@ -468,6 +474,7 @@ export const modelIdKeys = [ "lmStudioModelId", "lmStudioDraftModelId", "requestyModelId", + "unboundModelId", "litellmModelId", "vercelAiGatewayModelId", ] as const satisfies readonly (keyof ProviderSettings)[] @@ -505,6 +512,7 @@ export const modelIdKeysByProvider: Record = { deepseek: "apiModelId", "qwen-code": "apiModelId", requesty: "requestyModelId", + unbound: "unboundModelId", xai: "apiModelId", baseten: "apiModelId", litellm: "litellmModelId", @@ -627,6 +635,7 @@ export const MODELS_BY_PROVIDER: Record< litellm: { id: "litellm", label: "LiteLLM", models: [] }, openrouter: { id: "openrouter", label: "OpenRouter", models: [] }, requesty: { id: "requesty", label: "Requesty", models: [] }, + unbound: { id: "unbound", label: "Unbound", models: [] }, "vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] }, // Local providers; models discovered from localhost endpoints. diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index a9c1e8804c4..6bb959c7056 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -17,6 +17,7 @@ export * from "./qwen-code.js" export * from "./requesty.js" export * from "./roo.js" export * from "./sambanova.js" +export * from "./unbound.js" export * from "./vertex.js" export * from "./vscode-llm.js" export * from "./xai.js" @@ -39,6 +40,7 @@ import { qwenCodeDefaultModelId } from "./qwen-code.js" import { requestyDefaultModelId } from "./requesty.js" import { rooDefaultModelId } from "./roo.js" import { sambaNovaDefaultModelId } from "./sambanova.js" +import { unboundDefaultModelId } from "./unbound.js" import { vertexDefaultModelId } from "./vertex.js" import { vscodeLlmDefaultModelId } from "./vscode-llm.js" import { xaiDefaultModelId } from "./xai.js" @@ -105,6 +107,8 @@ export function getProviderDefaultModelId( return rooDefaultModelId case "qwen-code": return qwenCodeDefaultModelId + case "unbound": + return unboundDefaultModelId case "vercel-ai-gateway": return vercelAiGatewayDefaultModelId case "anthropic": diff --git a/packages/types/src/providers/unbound.ts b/packages/types/src/providers/unbound.ts new file mode 100644 index 00000000000..f45c986dd00 --- /dev/null +++ b/packages/types/src/providers/unbound.ts @@ -0,0 +1,16 @@ +import type { ModelInfo } from "../model.js" + +// Unbound +// https://gateway.getunbound.ai +export const unboundDefaultModelId = "anthropic/claude-sonnet-4-5" + +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/src/api/index.ts b/src/api/index.ts index a527b7e1330..ebc2682a1a8 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -21,6 +21,7 @@ import { MistralHandler, VsCodeLmHandler, RequestyHandler, + UnboundHandler, FakeAIHandler, XAIHandler, LiteLLMHandler, @@ -151,6 +152,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new MistralHandler(options) case "requesty": return new RequestyHandler(options) + case "unbound": + return new UnboundHandler(options) case "fake-ai": return new FakeAIHandler(options) case "xai": diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index 3ac8c2296cc..a574a660bc5 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -19,6 +19,7 @@ import { fileExistsAtPath } from "../../../utils/fs" import { getOpenRouterModels } from "./openrouter" import { getVercelAiGatewayModels } from "./vercel-ai-gateway" import { getRequestyModels } from "./requesty" +import { getUnboundModels } from "./unbound" import { getLiteLLMModels } from "./litellm" import { GetModelsOptions } from "../../../shared/api" import { getOllamaModels } from "./ollama" @@ -68,6 +69,9 @@ async function fetchModelsFromProvider(options: GetModelsOptions): Promise> { + const models: Record = {} + + try { + const headers: Record = {} + + if (apiKey) { + headers["Authorization"] = `Bearer ${apiKey}` + } + + const response = await axios.get("https://api.getunbound.ai/models", { headers }) + const rawModels = response.data?.data ?? response.data + + for (const rawModel of rawModels) { + const modelInfo: ModelInfo = { + maxTokens: rawModel.max_output_tokens ?? 8192, + contextWindow: rawModel.context_window ?? 200_000, + supportsPromptCache: rawModel.supports_caching ?? false, + supportsImages: rawModel.supports_vision ?? false, + inputPrice: parseApiPrice(rawModel.input_price), + outputPrice: parseApiPrice(rawModel.output_price), + description: rawModel.description, + cacheWritesPrice: parseApiPrice(rawModel.caching_price), + cacheReadsPrice: parseApiPrice(rawModel.cached_price), + } + + models[rawModel.id] = modelInfo + } + } catch (error) { + console.error(`Error fetching Unbound models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + } + + return models +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index 51eafc200d9..b6de7952104 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -17,6 +17,7 @@ export { OpenRouterHandler } from "./openrouter" export { QwenCodeHandler } from "./qwen-code" export { RequestyHandler } from "./requesty" export { SambaNovaHandler } from "./sambanova" +export { UnboundHandler } from "./unbound" export { VertexHandler } from "./vertex" export { VsCodeLmHandler } from "./vscode-lm" export { XAIHandler } from "./xai" diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts new file mode 100644 index 00000000000..d50bfcc85d2 --- /dev/null +++ b/src/api/providers/unbound.ts @@ -0,0 +1,212 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { type ModelInfo, type ModelRecord, unboundDefaultModelId, unboundDefaultModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../shared/api" +import { calculateApiCostOpenAI } from "../../shared/cost" + +import { convertToOpenAiMessages } from "../transform/openai-format" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" +import { OpenAiReasoningParams } from "../transform/reasoning" + +import { DEFAULT_HEADERS } from "./constants" +import { getModels } from "./fetchers/modelCache" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { handleOpenAIError } from "./utils/openai-error-handler" +import { applyRouterToolPreferences } from "./utils/router-tool-preferences" + +// Unbound usage includes extra fields for Anthropic cache tokens. +interface UnboundUsage extends OpenAI.CompletionUsage { + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +type UnboundChatCompletionParamsStreaming = OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { + unbound_metadata?: { + originApp?: string + taskId?: string + mode?: string + } + thinking?: OpenAiReasoningParams +} + +type UnboundChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParams & { + unbound_metadata?: { + originApp?: string + taskId?: string + mode?: string + } + thinking?: OpenAiReasoningParams +} + +export class UnboundHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected models: ModelRecord = {} + private client: OpenAI + private readonly providerName = "Unbound" + + constructor(options: ApiHandlerOptions) { + super() + + this.options = options + + const apiKey = this.options.unboundApiKey ?? "not-provided" + + this.client = new OpenAI({ + baseURL: "https://api.getunbound.ai/v1", + apiKey: apiKey, + defaultHeaders: { + ...DEFAULT_HEADERS, + "X-Unbound-Metadata": JSON.stringify({ labels: [{ key: "app", value: "roo-code" }] }), + }, + }) + } + + public async fetchModel() { + this.models = await getModels({ provider: "unbound", apiKey: this.options.unboundApiKey }) + return this.getModel() + } + + override getModel() { + const id = this.options.unboundModelId ?? unboundDefaultModelId + const cachedInfo = this.models[id] ?? unboundDefaultModelInfo + let info: ModelInfo = cachedInfo + + // Apply tool preferences for models accessed through routers (OpenAI, Gemini) + info = applyRouterToolPreferences(id, info) + + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) + + return { id, info, ...params } + } + + protected processUsageMetrics(usage: any, modelInfo?: ModelInfo): ApiStreamUsageChunk { + const unboundUsage = usage as UnboundUsage + const inputTokens = unboundUsage?.prompt_tokens || 0 + const outputTokens = unboundUsage?.completion_tokens || 0 + const cacheWriteTokens = unboundUsage?.cache_creation_input_tokens || 0 + const cacheReadTokens = unboundUsage?.cache_read_input_tokens || 0 + const { totalCost } = modelInfo + ? calculateApiCostOpenAI(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens) + : { totalCost: 0 } + + return { + type: "usage", + inputTokens: inputTokens, + outputTokens: outputTokens, + cacheWriteTokens: cacheWriteTokens, + cacheReadTokens: cacheReadTokens, + totalCost: totalCost, + } + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { + id: model, + info, + maxTokens: max_tokens, + temperature, + reasoningEffort: reasoning_effort, + reasoning: thinking, + } = await this.fetchModel() + + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + // Map extended efforts to OpenAI Chat Completions-accepted values (omit unsupported) + const allowedEffort = (["low", "medium", "high"] as const).includes(reasoning_effort as any) + ? (reasoning_effort as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming["reasoning_effort"]) + : undefined + + const completionParams: UnboundChatCompletionParamsStreaming = { + messages: openAiMessages, + model, + max_tokens, + temperature, + ...(allowedEffort && { reasoning_effort: allowedEffort }), + ...(thinking && { thinking }), + stream: true, + stream_options: { include_usage: true }, + unbound_metadata: { originApp: "roo-code", taskId: metadata?.taskId, mode: metadata?.mode }, + tools: this.convertToolsForOpenAI(metadata?.tools), + tool_choice: metadata?.tool_choice, + } + + let stream + try { + stream = await this.client.chat.completions.create(completionParams) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + let lastUsage: any = undefined + + for await (const chunk of stream) { + const delta = chunk.choices[0]?.delta + + if (delta?.content) { + yield { type: "text", text: delta.content } + } + + if (delta && "reasoning_content" in delta && delta.reasoning_content) { + yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" } + } + + // Handle native tool calls + if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { + for (const toolCall of delta.tool_calls) { + yield { + type: "tool_call_partial", + index: toolCall.index, + id: toolCall.id, + name: toolCall.function?.name, + arguments: toolCall.function?.arguments, + } + } + } + + if (chunk.usage) { + lastUsage = chunk.usage + } + } + + if (lastUsage) { + yield this.processUsageMetrics(lastUsage, info) + } + } + + async completePrompt(prompt: string): Promise { + const { id: model, maxTokens: max_tokens, temperature } = await this.fetchModel() + + let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [{ role: "system", content: prompt }] + + const completionParams: UnboundChatCompletionParams = { + model, + max_tokens, + messages: openAiMessages, + temperature: temperature, + } + + let response: OpenAI.Chat.ChatCompletion + try { + response = await this.client.chat.completions.create(completionParams) + } catch (error) { + throw handleOpenAIError(error, this.providerName) + } + return response.choices[0]?.message.content || "" + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 19d7e5adb39..5194b16df9d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -876,6 +876,7 @@ export const webviewMessageHandler = async ( "vercel-ai-gateway": {}, litellm: {}, requesty: {}, + unbound: {}, ollama: {}, lmstudio: {}, roo: {}, @@ -905,6 +906,13 @@ export const webviewMessageHandler = async ( baseUrl: apiConfiguration.requestyBaseUrl, }, }, + { + key: "unbound", + options: { + provider: "unbound", + apiKey: apiConfiguration.unboundApiKey, + }, + }, { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, { key: "roo", diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index ae58763d6ac..7246a90177a 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -77,6 +77,8 @@ export class ProfileValidator { return profile.ollamaModelId case "requesty": return profile.requestyModelId + case "unbound": + return profile.unboundModelId case "fake-ai": default: return undefined diff --git a/src/shared/api.ts b/src/shared/api.ts index 7e999e12890..52af6b20727 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -173,6 +173,7 @@ const dynamicProviderExtras = { "vercel-ai-gateway": {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type litellm: {} as { apiKey: string; baseUrl: string }, requesty: {} as { apiKey?: string; baseUrl?: string }, + unbound: {} as { apiKey?: string }, ollama: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type roo: {} as { apiKey?: string; baseUrl?: string }, diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 8aa14e2dc97..4d914a4833a 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -31,6 +31,7 @@ import { rooDefaultModelId, vercelAiGatewayDefaultModelId, minimaxDefaultModelId, + unboundDefaultModelId, } from "@roo-code/types" import { @@ -83,6 +84,7 @@ import { Requesty, Roo, SambaNova, + Unbound, Vertex, VSCodeLM, XAI, @@ -330,6 +332,7 @@ const ApiOptions = ({ > = { openrouter: { field: "openRouterModelId", default: openRouterDefaultModelId }, requesty: { field: "requestyModelId", default: requestyDefaultModelId }, + unbound: { field: "unboundModelId", default: unboundDefaultModelId }, litellm: { field: "litellmModelId", default: litellmDefaultModelId }, anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, "openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId }, @@ -518,6 +521,18 @@ const ApiOptions = ({ /> )} + {selectedProvider === "unbound" && ( + + )} + {selectedProvider === "anthropic" && ( a.label.localeCompare(b.label)) diff --git a/webview-ui/src/components/settings/providers/Unbound.tsx b/webview-ui/src/components/settings/providers/Unbound.tsx new file mode 100644 index 00000000000..8c682414156 --- /dev/null +++ b/webview-ui/src/components/settings/providers/Unbound.tsx @@ -0,0 +1,101 @@ +import { useCallback } from "react" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { + type ProviderSettings, + type OrganizationAllowList, + type RouterModels, + unboundDefaultModelId, +} from "@roo-code/types" + +import { vscode } from "@src/utils/vscode" +import { useAppTranslation } from "@src/i18n/TranslationContext" +import { Button } from "@src/components/ui" + +import { inputEventTransform } from "../transforms" +import { ModelPicker } from "../ModelPicker" + +type UnboundProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + routerModels?: RouterModels + refetchRouterModels: () => void + organizationAllowList: OrganizationAllowList + modelValidationError?: string + simplifySettings?: boolean +} + +export const Unbound = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, + simplifySettings, +}: UnboundProps) => { + const { t } = useAppTranslation() + + const handleInputChange = useCallback( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + +
+ +
+
+
+ {t("settings:providers.apiKeyStorageNotice")} +
+ + {t("settings:providers.getUnboundApiKey")} + + + + + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index d7684fb945e..597caffd1d7 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -14,6 +14,7 @@ export { QwenCode } from "./QwenCode" export { Roo } from "./Roo" export { Requesty } from "./Requesty" export { SambaNova } from "./SambaNova" +export { Unbound } from "./Unbound" export { Vertex } from "./Vertex" export { VSCodeLM } from "./VSCodeLM" export { XAI } from "./XAI" diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index 85fb54d6e92..fa718143905 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -118,6 +118,7 @@ export const isStaticModelProvider = (provider: ProviderName): boolean => { export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ "openrouter", "requesty", + "unbound", "openai", // OpenAI Compatible "openai-codex", // OpenAI Codex has custom UI with auth and rate limits "litellm", diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 8a6e49e212f..0e81690d9f3 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -159,6 +159,11 @@ function getSelectedModel({ const routerInfo = routerModels.requesty?.[id] return { id, info: routerInfo } } + case "unbound": { + const id = getValidatedModelId(apiConfiguration.unboundModelId, routerModels.unbound, defaultModelId) + const routerInfo = routerModels.unbound?.[id] + return { id, info: routerInfo } + } case "litellm": { const id = getValidatedModelId(apiConfiguration.litellmModelId, routerModels.litellm, defaultModelId) const routerInfo = routerModels.litellm?.[id] diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index af825fafe80..3b2497aaee7 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -419,6 +419,8 @@ "headerName": "Header name", "headerValue": "Header value", "noCustomHeaders": "No custom headers defined. Click the + button to add one.", + "unboundApiKey": "Unbound API Key", + "getUnboundApiKey": "Get Unbound API Key", "requestyApiKey": "Requesty API Key", "refreshModels": { "label": "Refresh Models", diff --git a/webview-ui/src/utils/__tests__/validate.spec.ts b/webview-ui/src/utils/__tests__/validate.spec.ts index 0a046adc54d..9b0b7a66e0d 100644 --- a/webview-ui/src/utils/__tests__/validate.spec.ts +++ b/webview-ui/src/utils/__tests__/validate.spec.ts @@ -39,6 +39,7 @@ describe("Model Validation Functions", () => { }, }, requesty: {}, + unbound: {}, litellm: {}, ollama: {}, lmstudio: {}, diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 116013d03fa..a4c950f8dd4 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -48,6 +48,11 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri return i18next.t("settings:validation.apiKey") } break + case "unbound": + if (!apiConfiguration.unboundApiKey) { + return i18next.t("settings:validation.apiKey") + } + break case "litellm": if (!apiConfiguration.litellmApiKey) { return i18next.t("settings:validation.apiKey") From ad2b6f64aa33ba932786e4d13e1508873007162b Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Fri, 20 Feb 2026 15:03:26 +0530 Subject: [PATCH 2/3] Adds translations --- .../__tests__/webviewMessageHandler.spec.ts | 17 +++++++++++++++++ webview-ui/src/i18n/locales/ca/settings.json | 2 ++ webview-ui/src/i18n/locales/de/settings.json | 2 ++ webview-ui/src/i18n/locales/es/settings.json | 2 ++ webview-ui/src/i18n/locales/fr/settings.json | 2 ++ webview-ui/src/i18n/locales/hi/settings.json | 2 ++ webview-ui/src/i18n/locales/id/settings.json | 2 ++ webview-ui/src/i18n/locales/it/settings.json | 2 ++ webview-ui/src/i18n/locales/ja/settings.json | 2 ++ webview-ui/src/i18n/locales/ko/settings.json | 2 ++ webview-ui/src/i18n/locales/nl/settings.json | 2 ++ webview-ui/src/i18n/locales/pl/settings.json | 2 ++ webview-ui/src/i18n/locales/pt-BR/settings.json | 2 ++ webview-ui/src/i18n/locales/ru/settings.json | 2 ++ webview-ui/src/i18n/locales/tr/settings.json | 2 ++ webview-ui/src/i18n/locales/vi/settings.json | 2 ++ webview-ui/src/i18n/locales/zh-CN/settings.json | 2 ++ webview-ui/src/i18n/locales/zh-TW/settings.json | 2 ++ 18 files changed, 51 insertions(+) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 420d309fb76..1cd8285993c 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -296,6 +296,11 @@ describe("webviewMessageHandler - requestRouterModels", () => { // Verify getModels was called for each provider expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" }) expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) + expect(mockGetModels).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "unbound", + }), + ) expect(mockGetModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) expect(mockGetModels).toHaveBeenCalledWith( expect.objectContaining({ @@ -315,6 +320,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { routerModels: { openrouter: mockModels, requesty: mockModels, + unbound: mockModels, litellm: mockModels, roo: mockModels, ollama: {}, @@ -399,6 +405,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { routerModels: { openrouter: mockModels, requesty: mockModels, + unbound: mockModels, roo: mockModels, litellm: {}, ollama: {}, @@ -423,6 +430,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels .mockResolvedValueOnce(mockModels) // openrouter .mockRejectedValueOnce(new Error("Requesty API error")) // requesty + .mockResolvedValueOnce(mockModels) // unbound .mockResolvedValueOnce(mockModels) // vercel-ai-gateway .mockResolvedValueOnce(mockModels) // roo .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm @@ -452,6 +460,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { routerModels: { openrouter: mockModels, requesty: {}, + unbound: mockModels, roo: mockModels, litellm: {}, ollama: {}, @@ -467,6 +476,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels .mockRejectedValueOnce(new Error("Structured error message")) // openrouter .mockRejectedValueOnce(new Error("Requesty API error")) // requesty + .mockRejectedValueOnce(new Error("Unbound error")) // unbound .mockRejectedValueOnce(new Error("Vercel AI Gateway error")) // vercel-ai-gateway .mockRejectedValueOnce(new Error("Roo API error")) // roo .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm @@ -490,6 +500,13 @@ describe("webviewMessageHandler - requestRouterModels", () => { values: { provider: "requesty" }, }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ + type: "singleRouterModelFetchResponse", + success: false, + error: "Unbound error", + values: { provider: "unbound" }, + }) + expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index a741d9a3d79..2c83cabbbcb 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -356,6 +356,8 @@ "headerName": "Nom de la capçalera", "headerValue": "Valor de la capçalera", "noCustomHeaders": "No hi ha capçaleres personalitzades definides. Feu clic al botó + per afegir-ne una.", + "unboundApiKey": "Clau API de Unbound", + "getUnboundApiKey": "Obtenir clau API de Unbound", "requestyApiKey": "Clau API de Requesty", "refreshModels": { "label": "Actualitzar models", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index aed7867d801..c31d29147d4 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -356,6 +356,8 @@ "headerName": "Header-Name", "headerValue": "Header-Wert", "noCustomHeaders": "Keine benutzerdefinierten Headers definiert. Klicke auf die + Schaltfläche, um einen hinzuzufügen.", + "unboundApiKey": "Unbound API-Schlüssel", + "getUnboundApiKey": "Unbound API-Schlüssel erhalten", "requestyApiKey": "Requesty API-Schlüssel", "refreshModels": { "label": "Modelle aktualisieren", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 946a6f87c00..6595c4f9079 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -356,6 +356,8 @@ "headerName": "Nombre del encabezado", "headerValue": "Valor del encabezado", "noCustomHeaders": "No hay encabezados personalizados definidos. Haga clic en el botón + para añadir uno.", + "unboundApiKey": "Clave API de Unbound", + "getUnboundApiKey": "Obtener clave API de Unbound", "requestyApiKey": "Clave API de Requesty", "refreshModels": { "label": "Actualizar modelos", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index c833ed7950a..56337bda14c 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -356,6 +356,8 @@ "headerName": "Nom de l'en-tête", "headerValue": "Valeur de l'en-tête", "noCustomHeaders": "Aucun en-tête personnalisé défini. Cliquez sur le bouton + pour en ajouter un.", + "unboundApiKey": "Clé API Unbound", + "getUnboundApiKey": "Obtenir la clé API Unbound", "requestyApiKey": "Clé API Requesty", "refreshModels": { "label": "Actualiser les modèles", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 9c20bd44570..abd334bec09 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -356,6 +356,8 @@ "headerName": "हेडर नाम", "headerValue": "हेडर मूल्य", "noCustomHeaders": "कोई कस्टम हेडर परिभाषित नहीं है। एक जोड़ने के लिए + बटन पर क्लिक करें।", + "unboundApiKey": "Unbound API कुंजी", + "getUnboundApiKey": "Unbound API कुंजी प्राप्त करें", "requestyApiKey": "Requesty API कुंजी", "refreshModels": { "label": "मॉडल रिफ्रेश करें", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 6320d2bb340..1ebcf2073b6 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -356,6 +356,8 @@ "headerName": "Nama header", "headerValue": "Nilai header", "noCustomHeaders": "Tidak ada header kustom yang didefinisikan. Klik tombol + untuk menambahkan satu.", + "unboundApiKey": "Unbound API Key", + "getUnboundApiKey": "Dapatkan Unbound API Key", "requestyApiKey": "Requesty API Key", "refreshModels": { "label": "Refresh Model", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 4b29c332475..4a0c7161654 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -356,6 +356,8 @@ "headerName": "Nome intestazione", "headerValue": "Valore intestazione", "noCustomHeaders": "Nessuna intestazione personalizzata definita. Fai clic sul pulsante + per aggiungerne una.", + "unboundApiKey": "Chiave API Unbound", + "getUnboundApiKey": "Ottieni chiave API Unbound", "requestyApiKey": "Chiave API Requesty", "refreshModels": { "label": "Aggiorna modelli", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 3aab3c79628..b0d921571af 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -356,6 +356,8 @@ "headerName": "ヘッダー名", "headerValue": "ヘッダー値", "noCustomHeaders": "カスタムヘッダーが定義されていません。+ ボタンをクリックして追加してください。", + "unboundApiKey": "Unbound API キー", + "getUnboundApiKey": "Unbound APIキーを取得", "requestyApiKey": "Requesty APIキー", "refreshModels": { "label": "モデルを更新", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 7a522e57068..88fc8e6d79e 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -356,6 +356,8 @@ "headerName": "헤더 이름", "headerValue": "헤더 값", "noCustomHeaders": "정의된 사용자 정의 헤더가 없습니다. + 버튼을 클릭하여 추가하세요.", + "unboundApiKey": "Unbound API 키", + "getUnboundApiKey": "Unbound API 키 받기", "requestyApiKey": "Requesty API 키", "refreshModels": { "label": "모델 새로고침", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 854376b2fdd..fcfad37d376 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -356,6 +356,8 @@ "headerName": "Headernaam", "headerValue": "Headerwaarde", "noCustomHeaders": "Geen aangepaste headers gedefinieerd. Klik op de + knop om er een toe te voegen.", + "unboundApiKey": "Unbound API sleutel", + "getUnboundApiKey": "Unbound API-sleutel ophalen", "requestyApiKey": "Requesty API-sleutel", "refreshModels": { "label": "Modellen verversen", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 85094cabfb5..fa48bc6b212 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -356,6 +356,8 @@ "headerName": "Nazwa nagłówka", "headerValue": "Wartość nagłówka", "noCustomHeaders": "Brak zdefiniowanych niestandardowych nagłówków. Kliknij przycisk +, aby dodać.", + "unboundApiKey": "Klucz API Unbound", + "getUnboundApiKey": "Uzyskaj klucz API Unbound", "requestyApiKey": "Klucz API Requesty", "refreshModels": { "label": "Odśwież modele", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 3a59ce226ab..a8387e05121 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -356,6 +356,8 @@ "headerName": "Nome do cabeçalho", "headerValue": "Valor do cabeçalho", "noCustomHeaders": "Nenhum cabeçalho personalizado definido. Clique no botão + para adicionar um.", + "unboundApiKey": "Chave de API Unbound", + "getUnboundApiKey": "Obter chave de API Unbound", "requestyApiKey": "Chave de API Requesty", "refreshModels": { "label": "Atualizar modelos", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 7b7197d9569..fe24ebee299 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -356,6 +356,8 @@ "headerName": "Имя заголовка", "headerValue": "Значение заголовка", "noCustomHeaders": "Пользовательские заголовки не определены. Нажмите кнопку +, чтобы добавить.", + "unboundApiKey": "Unbound API-ключ", + "getUnboundApiKey": "Получить Unbound API-ключ", "requestyApiKey": "Requesty API-ключ", "refreshModels": { "label": "Обновить модели", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 766b8299648..7171718f1c5 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -356,6 +356,8 @@ "headerName": "Başlık adı", "headerValue": "Başlık değeri", "noCustomHeaders": "Tanımlanmış özel başlık yok. Eklemek için + düğmesine tıklayın.", + "unboundApiKey": "Unbound API Anahtarı", + "getUnboundApiKey": "Unbound API Anahtarı Al", "requestyApiKey": "Requesty API Anahtarı", "refreshModels": { "label": "Modelleri Yenile", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index fd2fd64885d..95b4f2d6863 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -356,6 +356,8 @@ "headerName": "Tên tiêu đề", "headerValue": "Giá trị tiêu đề", "noCustomHeaders": "Chưa có tiêu đề tùy chỉnh nào được định nghĩa. Nhấp vào nút + để thêm.", + "unboundApiKey": "Khóa API Unbound", + "getUnboundApiKey": "Lấy khóa API Unbound", "requestyApiKey": "Khóa API Requesty", "refreshModels": { "label": "Làm mới mô hình", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 40d0f4eda33..eeba6bb079d 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -356,6 +356,8 @@ "headerName": "标头名称", "headerValue": "标头值", "noCustomHeaders": "暂无自定义标头。点击 + 按钮添加。", + "unboundApiKey": "Unbound API 密钥", + "getUnboundApiKey": "获取 Unbound API 密钥", "requestyApiKey": "Requesty API 密钥", "refreshModels": { "label": "刷新模型", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 691873ef204..9f4241c3dd9 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -366,6 +366,8 @@ "headerName": "標頭名稱", "headerValue": "標頭值", "noCustomHeaders": "尚未定義自訂標頭。點選 + 按鈕以新增。", + "unboundApiKey": "Unbound API 金鑰", + "getUnboundApiKey": "取得 Unbound API 金鑰", "requestyApiKey": "Requesty API 金鑰", "refreshModels": { "label": "重新整理模型", From f2a9aecc28bd894010bbdd917f142ddad042dcb6 Mon Sep 17 00:00:00 2001 From: Pugazhendhi Date: Fri, 20 Feb 2026 15:12:48 +0530 Subject: [PATCH 3/3] fix: add unbound to ClineProvider test expectations Co-Authored-By: Claude Opus 4.6 --- src/core/webview/__tests__/ClineProvider.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 1e26cd45be7..cfa4b0317f8 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -2468,6 +2468,7 @@ describe("ClineProvider - Router Models", () => { // Verify getModels was called for each provider with correct options expect(getModels).toHaveBeenCalledWith({ provider: "openrouter" }) expect(getModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) + expect(getModels).toHaveBeenCalledWith({ provider: "unbound" }) expect(getModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) expect(getModels).toHaveBeenCalledWith( expect.objectContaining({ @@ -2487,6 +2488,7 @@ describe("ClineProvider - Router Models", () => { routerModels: { openrouter: mockModels, requesty: mockModels, + unbound: mockModels, roo: mockModels, litellm: mockModels, ollama: {}, @@ -2519,6 +2521,7 @@ describe("ClineProvider - Router Models", () => { vi.mocked(getModels) .mockResolvedValueOnce(mockModels) // openrouter success .mockRejectedValueOnce(new Error("Requesty API error")) // requesty fail + .mockResolvedValueOnce(mockModels) // unbound success .mockResolvedValueOnce(mockModels) // vercel-ai-gateway success .mockResolvedValueOnce(mockModels) // roo success .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail @@ -2531,6 +2534,7 @@ describe("ClineProvider - Router Models", () => { routerModels: { openrouter: mockModels, requesty: {}, + unbound: mockModels, roo: mockModels, ollama: {}, lmstudio: {}, @@ -2624,6 +2628,7 @@ describe("ClineProvider - Router Models", () => { routerModels: { openrouter: mockModels, requesty: mockModels, + unbound: mockModels, roo: mockModels, litellm: {}, ollama: {},