diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index dd72d72fe9..3b80bba3b2 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -198,6 +198,7 @@ export const SECRET_STATE_KEYS = [ "fireworksApiKey", "featherlessApiKey", "ioIntelligenceApiKey", + "vercelAiGatewayApiKey", ] as const satisfies readonly (keyof ProviderSettings)[] export type SecretState = Pick diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 4f5ca9c3ca..ee301eaa6f 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -66,6 +66,7 @@ export const providerNames = [ "featherless", "io-intelligence", "roo", + "vercel-ai-gateway", ] as const export const providerNamesSchema = z.enum(providerNames) @@ -321,6 +322,11 @@ const rooSchema = apiModelIdProviderModelSchema.extend({ // No additional fields needed - uses cloud authentication }) +const vercelAiGatewaySchema = baseProviderSettingsSchema.extend({ + vercelAiGatewayApiKey: z.string().optional(), + vercelAiGatewayModelId: z.string().optional(), +}) + const defaultSchema = z.object({ apiProvider: z.undefined(), }) @@ -360,6 +366,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv ioIntelligenceSchema.merge(z.object({ apiProvider: z.literal("io-intelligence") })), qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })), rooSchema.merge(z.object({ apiProvider: z.literal("roo") })), + vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })), defaultSchema, ]) @@ -399,6 +406,7 @@ export const providerSettingsSchema = z.object({ ...ioIntelligenceSchema.shape, ...qwenCodeSchema.shape, ...rooSchema.shape, + ...vercelAiGatewaySchema.shape, ...codebaseIndexProviderSchema.shape, }) @@ -425,6 +433,7 @@ export const MODEL_ID_KEYS: Partial[] = [ "litellmModelId", "huggingFaceModelId", "ioIntelligenceModelId", + "vercelAiGatewayModelId", ] export const getModelId = (settings: ProviderSettings): string | undefined => { diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 27951f0f1a..97fa10ca82 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -27,4 +27,5 @@ export * from "./unbound.js" export * from "./vertex.js" export * from "./vscode-llm.js" export * from "./xai.js" +export * from "./vercel-ai-gateway.js" export * from "./zai.js" diff --git a/packages/types/src/providers/vercel-ai-gateway.ts b/packages/types/src/providers/vercel-ai-gateway.ts new file mode 100644 index 0000000000..837baf7c52 --- /dev/null +++ b/packages/types/src/providers/vercel-ai-gateway.ts @@ -0,0 +1,19 @@ +import type { ModelInfo } from "../model.js" + +// https://ai-gateway.vercel.sh/v1/ +export const vercelAiGatewayDefaultModelId = "anthropic/claude-sonnet-4" + +export const vercelAiGatewayDefaultModelInfo: ModelInfo = { + maxTokens: 64000, + contextWindow: 128000, + supportsImages: true, + supportsPromptCache: true, + inputPrice: 3, + outputPrice: 15, + cacheWritesPrice: 3.75, + cacheReadsPrice: 0.3, + description: + "Claude Sonnet 4 significantly improves on Sonnet 3.7's industry-leading capabilities, excelling in coding with a state-of-the-art 72.7% on SWE-bench. The model balances performance and efficiency for internal and external use cases, with enhanced steerability for greater control over implementations. While not matching Opus 4 in most domains, it delivers an optimal mix of capability and practicality.", +} + +export const VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE = 0 diff --git a/src/api/index.ts b/src/api/index.ts index 188cb3930f..59ca681f79 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -38,6 +38,7 @@ import { FireworksHandler, RooHandler, FeatherlessHandler, + VercelAiGatewayHandler, } from "./providers" import { NativeOllamaHandler } from "./providers/native-ollama" @@ -151,6 +152,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { return new RooHandler(options) case "featherless": return new FeatherlessHandler(options) + case "vercel-ai-gateway": + return new VercelAiGatewayHandler(options) default: apiProvider satisfies "gemini-cli" | undefined return new AnthropicHandler(options) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index f4c240a61c..0005e8205f 100644 --- a/src/api/providers/fetchers/modelCache.ts +++ b/src/api/providers/fetchers/modelCache.ts @@ -10,6 +10,7 @@ import { RouterName, ModelRecord } from "../../../shared/api" import { fileExistsAtPath } from "../../../utils/fs" import { getOpenRouterModels } from "./openrouter" +import { getVercelAiGatewayModels } from "./vercel-ai-gateway" import { getRequestyModels } from "./requesty" import { getGlamaModels } from "./glama" import { getUnboundModels } from "./unbound" @@ -81,6 +82,9 @@ export const getModels = async (options: GetModelsOptions): Promise case "io-intelligence": models = await getIOIntelligenceModels(options.apiKey) break + case "vercel-ai-gateway": + models = await getVercelAiGatewayModels() + break default: { // Ensures router is exhaustively checked if RouterName is a strict union const exhaustiveCheck: never = provider diff --git a/src/api/providers/fetchers/vercel-ai-gateway.ts b/src/api/providers/fetchers/vercel-ai-gateway.ts new file mode 100644 index 0000000000..4444a3ec40 --- /dev/null +++ b/src/api/providers/fetchers/vercel-ai-gateway.ts @@ -0,0 +1,115 @@ +import axios from "axios" +import { z } from "zod" + +import type { ModelInfo } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" +import { parseApiPrice } from "../../../shared/cost" + +/** + * VercelAiGatewayPricing + */ + +const vercelAiGatewayPricingSchema = z.object({ + input: z.string(), + output: z.string(), + input_cache_write: z.string().optional(), + input_cache_read: z.string().optional(), +}) + +/** + * VercelAiGatewayModel + */ + +const vercelAiGatewayModelSchema = z.object({ + id: z.string(), + object: z.string(), + created: z.number(), + owned_by: z.string(), + name: z.string(), + description: z.string(), + context_window: z.number(), + max_tokens: z.number(), + type: z.string(), + pricing: vercelAiGatewayPricingSchema, +}) + +export type VercelAiGatewayModel = z.infer + +/** + * VercelAiGatewayModelsResponse + */ + +const vercelAiGatewayModelsResponseSchema = z.object({ + object: z.string(), + data: z.array(vercelAiGatewayModelSchema), +}) + +type VercelAiGatewayModelsResponse = z.infer + +/** + * getVercelAiGatewayModels + */ + +export async function getVercelAiGatewayModels(options?: ApiHandlerOptions): Promise> { + const models: Record = {} + const baseURL = "https://ai-gateway.vercel.sh/v1" + + try { + const response = await axios.get(`${baseURL}/models`) + const result = vercelAiGatewayModelsResponseSchema.safeParse(response.data) + const data = result.success ? result.data.data : response.data.data + + if (!result.success) { + console.error("Vercel AI Gateway models response is invalid", result.error.format()) + } + + for (const model of data) { + const { id } = model + + // Filter out embedding models (models with "embedding" in name) + if (id.toLowerCase().includes("embed")) { + continue + } + + models[id] = parseVercelAiGatewayModel({ + id, + model, + }) + } + } catch (error) { + console.error( + `Error fetching Vercel AI Gateway models: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`, + ) + } + + return models +} + +/** + * parseVercelAiGatewayModel + */ + +export const parseVercelAiGatewayModel = ({ id, model }: { id: string; model: VercelAiGatewayModel }): ModelInfo => { + const cacheWritesPrice = model.pricing?.input_cache_write + ? parseApiPrice(model.pricing?.input_cache_write) + : undefined + + const cacheReadsPrice = model.pricing?.input_cache_read ? parseApiPrice(model.pricing?.input_cache_read) : undefined + + const supportsPromptCache = typeof cacheWritesPrice !== "undefined" && typeof cacheReadsPrice !== "undefined" + + const modelInfo: ModelInfo = { + maxTokens: model.max_tokens, + contextWindow: model.context_window, + supportsImages: false, + supportsPromptCache, + inputPrice: parseApiPrice(model.pricing?.input), + outputPrice: parseApiPrice(model.pricing?.output), + cacheWritesPrice, + cacheReadsPrice, + description: model.description, + } + + return modelInfo +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index e2b9047dfc..c3786c5f56 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -32,3 +32,4 @@ export { ZAiHandler } from "./zai" export { FireworksHandler } from "./fireworks" export { RooHandler } from "./roo" export { FeatherlessHandler } from "./featherless" +export { VercelAiGatewayHandler } from "./vercel-ai-gateway" diff --git a/src/api/providers/vercel-ai-gateway.ts b/src/api/providers/vercel-ai-gateway.ts new file mode 100644 index 0000000000..87fb7bab9c --- /dev/null +++ b/src/api/providers/vercel-ai-gateway.ts @@ -0,0 +1,115 @@ +import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" + +import { + vercelAiGatewayDefaultModelId, + vercelAiGatewayDefaultModelInfo, + VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE, +} from "@roo-code/types" + +import { ApiHandlerOptions } from "../../shared/api" + +import { ApiStream } from "../transform/stream" +import { convertToOpenAiMessages } from "../transform/openai-format" +import { addCacheBreakpoints } from "../transform/caching/anthropic" + +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" +import { RouterProvider } from "./router-provider" + +// Extend OpenAI's CompletionUsage to include Vercel AI Gateway specific fields +interface VercelAiGatewayUsage extends OpenAI.CompletionUsage { + cache_creation_input_tokens?: number + prompt_tokens_details?: { + cached_tokens?: number + } +} + +export class VercelAiGatewayHandler extends RouterProvider implements SingleCompletionHandler { + constructor(options: ApiHandlerOptions) { + super({ + options, + name: "vercel-ai-gateway", + baseURL: "https://ai-gateway.vercel.sh/v1", + apiKey: options.vercelAiGatewayApiKey, + modelId: options.vercelAiGatewayModelId, + defaultModelId: vercelAiGatewayDefaultModelId, + defaultModelInfo: vercelAiGatewayDefaultModelInfo, + }) + } + + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { id: modelId, info } = await this.fetchModel() + + const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [ + { role: "system", content: systemPrompt }, + ...convertToOpenAiMessages(messages), + ] + + if (modelId.startsWith("anthropic/claude-3")) { + addCacheBreakpoints(systemPrompt, openAiMessages) + } //TODO: add cache breakpoints for other models + + const body: OpenAI.Chat.ChatCompletionCreateParams = { + model: modelId, + messages: openAiMessages, + temperature: this.supportsTemperature(modelId) + ? (this.options.modelTemperature ?? VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE) + : undefined, + max_tokens: info.maxTokens, + stream: true, + } + + const completion = await this.client.chat.completions.create(body) + + for await (const chunk of completion) { + const delta = chunk.choices[0]?.delta + if (delta?.content) { + yield { + type: "text", + text: delta.content, + } + } + + if (chunk.usage) { + const usage = chunk.usage as VercelAiGatewayUsage + yield { + type: "usage", + inputTokens: usage.prompt_tokens || 0, + outputTokens: usage.completion_tokens || 0, + cacheWriteTokens: usage.cache_creation_input_tokens || undefined, + cacheReadTokens: usage.prompt_tokens_details?.cached_tokens || undefined, + } + } + } + } + + async completePrompt(prompt: string): Promise { + const { id: modelId, info } = await this.fetchModel() + + try { + const requestOptions: OpenAI.Chat.ChatCompletionCreateParams = { + model: modelId, + messages: [{ role: "user", content: prompt }], + stream: false, + } + + if (this.supportsTemperature(modelId)) { + requestOptions.temperature = this.options.modelTemperature ?? VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE + } + + requestOptions.max_tokens = info.maxTokens + + const response = await this.client.chat.completions.create(requestOptions) + return response.choices[0]?.message.content || "" + } catch (error) { + if (error instanceof Error) { + throw new Error(`Vercel AI Gateway completion error: ${error.message}`) + } + throw error + } + } +} diff --git a/src/assets/images/vercel-ai-gateway.png b/src/assets/images/vercel-ai-gateway.png new file mode 100644 index 0000000000..0c85df1fde Binary files /dev/null and b/src/assets/images/vercel-ai-gateway.png differ diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 97bcf0f584..ddbc5a992e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -575,6 +575,7 @@ export const webviewMessageHandler = async ( }, { key: "glama", options: { provider: "glama" } }, { key: "unbound", options: { provider: "unbound", apiKey: apiConfiguration.unboundApiKey } }, + { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, ] // Add IO Intelligence if API key is provided diff --git a/src/shared/api.ts b/src/shared/api.ts index 3dde992c6f..30dfd7393b 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -27,6 +27,7 @@ const routerNames = [ "ollama", "lmstudio", "io-intelligence", + "vercel-ai-gateway", ] as const export type RouterName = (typeof routerNames)[number] @@ -151,3 +152,4 @@ export type GetModelsOptions = | { provider: "ollama"; baseUrl?: string } | { provider: "lmstudio"; baseUrl?: string } | { provider: "io-intelligence"; apiKey: string } + | { provider: "vercel-ai-gateway" } diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 555b98aa82..a9bf7c7013 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -35,6 +35,7 @@ import { featherlessDefaultModelId, ioIntelligenceDefaultModelId, rooDefaultModelId, + vercelAiGatewayDefaultModelId, } from "@roo-code/types" import { vscode } from "@src/utils/vscode" @@ -91,6 +92,7 @@ import { ZAi, Fireworks, Featherless, + VercelAiGateway, } from "./providers" import { MODELS_BY_PROVIDER, PROVIDERS } from "./constants" @@ -335,6 +337,7 @@ const ApiOptions = ({ featherless: { field: "apiModelId", default: featherlessDefaultModelId }, "io-intelligence": { field: "ioIntelligenceModelId", default: ioIntelligenceDefaultModelId }, roo: { field: "apiModelId", default: rooDefaultModelId }, + "vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId }, openai: { field: "openAiModelId" }, ollama: { field: "ollamaModelId" }, lmstudio: { field: "lmStudioModelId" }, @@ -607,6 +610,16 @@ const ApiOptions = ({ /> )} + {selectedProvider === "vercel-ai-gateway" && ( + + )} + {selectedProvider === "human-relay" && ( <>
diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index 0753f9fc2b..e398a9f01f 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -37,6 +37,7 @@ type ModelIdKey = keyof Pick< | "openAiModelId" | "litellmModelId" | "ioIntelligenceModelId" + | "vercelAiGatewayModelId" > interface ModelPickerProps { diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 1a94c5c599..9aa02bbf53 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -79,4 +79,5 @@ export const PROVIDERS = [ { value: "featherless", label: "Featherless AI" }, { value: "io-intelligence", label: "IO Intelligence" }, { value: "roo", label: "Roo Code Cloud" }, + { value: "vercel-ai-gateway", label: "Vercel AI Gateway" }, ].sort((a, b) => a.label.localeCompare(b.label)) diff --git a/webview-ui/src/components/settings/providers/VercelAiGateway.tsx b/webview-ui/src/components/settings/providers/VercelAiGateway.tsx new file mode 100644 index 0000000000..edbcc83189 --- /dev/null +++ b/webview-ui/src/components/settings/providers/VercelAiGateway.tsx @@ -0,0 +1,68 @@ +import { useCallback } from "react" +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" + +import { type ProviderSettings, vercelAiGatewayDefaultModelId } from "@roo-code/types" + +import type { OrganizationAllowList } from "@roo/cloud" +import type { RouterModels } from "@roo/api" + +import { useAppTranslation } from "@src/i18n/TranslationContext" + +import { inputEventTransform } from "../transforms" +import { ModelPicker } from "../ModelPicker" + +type VercelAiGatewayProps = { + apiConfiguration: ProviderSettings + setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void + routerModels?: RouterModels + organizationAllowList: OrganizationAllowList + modelValidationError?: string +} + +export const VercelAiGateway = ({ + apiConfiguration, + setApiConfigurationField, + routerModels, + organizationAllowList, + modelValidationError, +}: VercelAiGatewayProps) => { + 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")} +
+ + + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 46fea622c9..eedbba0c29 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -28,3 +28,4 @@ export { ZAi } from "./ZAi" export { LiteLLM } from "./LiteLLM" export { Fireworks } from "./Fireworks" export { Featherless } from "./Featherless" +export { VercelAiGateway } from "./VercelAiGateway" diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index aeb90c3491..b41222585f 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -54,6 +54,7 @@ import { rooModels, qwenCodeDefaultModelId, qwenCodeModels, + vercelAiGatewayDefaultModelId, BEDROCK_CLAUDE_SONNET_4_MODEL_ID, } from "@roo-code/types" @@ -317,6 +318,11 @@ function getSelectedModel({ const info = qwenCodeModels[id as keyof typeof qwenCodeModels] return { id, info } } + case "vercel-ai-gateway": { + const id = apiConfiguration.vercelAiGatewayModelId ?? vercelAiGatewayDefaultModelId + const info = routerModels["vercel-ai-gateway"]?.[id] + return { id, info } + } // case "anthropic": // case "human-relay": // case "fake-ai": diff --git a/webview-ui/src/components/welcome/WelcomeView.tsx b/webview-ui/src/components/welcome/WelcomeView.tsx index 215a290d0d..40a1467e12 100644 --- a/webview-ui/src/components/welcome/WelcomeView.tsx +++ b/webview-ui/src/components/welcome/WelcomeView.tsx @@ -83,6 +83,14 @@ const WelcomeView = () => { description: t("welcome:routers.openrouter.description"), authUrl: getOpenRouterAuthUrl(uriScheme), }, + { + slug: "vercel-ai-gateway", + name: "Vercel AI Gateway", + description: t("welcome:routers.vercelAiGateway.description"), + incentive: t("welcome:routers.vercelAiGateway.incentive"), + authUrl: + "https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=AI+Gateway+API+Key", + }, ] // Shuffle providers based on machine ID (will be consistent for the same machine) diff --git a/webview-ui/src/i18n/locales/en/welcome.json b/webview-ui/src/i18n/locales/en/welcome.json index 2202f6fd61..56f3f2d6a0 100644 --- a/webview-ui/src/i18n/locales/en/welcome.json +++ b/webview-ui/src/i18n/locales/en/welcome.json @@ -10,6 +10,10 @@ }, "openrouter": { "description": "A unified interface for LLMs" + }, + "vercelAiGateway": { + "description": "The AI Gateway for Developers", + "incentive": "$5 free credits to use with any model" } }, "chooseProvider": "To do its magic, Roo needs an API key.", diff --git a/webview-ui/src/utils/__tests__/validate.test.ts b/webview-ui/src/utils/__tests__/validate.test.ts index f14fb89200..30ccfd4463 100644 --- a/webview-ui/src/utils/__tests__/validate.test.ts +++ b/webview-ui/src/utils/__tests__/validate.test.ts @@ -41,6 +41,7 @@ describe("Model Validation Functions", () => { ollama: {}, lmstudio: {}, "io-intelligence": {}, + "vercel-ai-gateway": {}, } const allowAllOrganization: OrganizationAllowList = { diff --git a/webview-ui/src/utils/validate.ts b/webview-ui/src/utils/validate.ts index 8f18e4411d..5613eb9eb8 100644 --- a/webview-ui/src/utils/validate.ts +++ b/webview-ui/src/utils/validate.ts @@ -136,6 +136,11 @@ function validateModelsAndKeysProvided(apiConfiguration: ProviderSettings): stri return i18next.t("settings:validation.qwenCodeOauthPath") } break + case "vercel-ai-gateway": + if (!apiConfiguration.vercelAiGatewayApiKey) { + return i18next.t("settings:validation.apiKey") + } + break } return undefined @@ -204,6 +209,8 @@ function getModelIdForProvider(apiConfiguration: ProviderSettings, provider: str return apiConfiguration.huggingFaceModelId case "io-intelligence": return apiConfiguration.ioIntelligenceModelId + case "vercel-ai-gateway": + return apiConfiguration.vercelAiGatewayModelId default: return apiConfiguration.apiModelId } @@ -277,6 +284,9 @@ export function validateModelId(apiConfiguration: ProviderSettings, routerModels case "io-intelligence": modelId = apiConfiguration.ioIntelligenceModelId break + case "vercel-ai-gateway": + modelId = apiConfiguration.vercelAiGatewayModelId + break } if (!modelId) {