feat: Add Vercel AI Gateway provider integration

This commit is contained in:
joshualipman123 2025-08-25 11:33:35 -07:00 committed by daniel-lxs
parent a79c3d04a6
commit 3169f25a5c
No known key found for this signature in database
GPG key ID: 21C74479048B3AA6
22 changed files with 384 additions and 0 deletions

View file

@ -198,6 +198,7 @@ export const SECRET_STATE_KEYS = [
"fireworksApiKey",
"featherlessApiKey",
"ioIntelligenceApiKey",
"vercelAiGatewayApiKey",
] as const satisfies readonly (keyof ProviderSettings)[]
export type SecretState = Pick<ProviderSettings, (typeof SECRET_STATE_KEYS)[number]>

View file

@ -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<keyof ProviderSettings>[] = [
"litellmModelId",
"huggingFaceModelId",
"ioIntelligenceModelId",
"vercelAiGatewayModelId",
]
export const getModelId = (settings: ProviderSettings): string | undefined => {

View file

@ -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"

View file

@ -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

View file

@ -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)

View file

@ -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<ModelRecord>
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

View file

@ -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<typeof vercelAiGatewayModelSchema>
/**
* VercelAiGatewayModelsResponse
*/
const vercelAiGatewayModelsResponseSchema = z.object({
object: z.string(),
data: z.array(vercelAiGatewayModelSchema),
})
type VercelAiGatewayModelsResponse = z.infer<typeof vercelAiGatewayModelsResponseSchema>
/**
* getVercelAiGatewayModels
*/
export async function getVercelAiGatewayModels(options?: ApiHandlerOptions): Promise<Record<string, ModelInfo>> {
const models: Record<string, ModelInfo> = {}
const baseURL = "https://ai-gateway.vercel.sh/v1"
try {
const response = await axios.get<VercelAiGatewayModelsResponse>(`${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
}

View file

@ -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"

View file

@ -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<string> {
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
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 875 B

View file

@ -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

View file

@ -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" }

View file

@ -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" && (
<VercelAiGateway
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
routerModels={routerModels}
organizationAllowList={organizationAllowList}
modelValidationError={modelValidationError}
/>
)}
{selectedProvider === "human-relay" && (
<>
<div className="text-sm text-vscode-descriptionForeground">

View file

@ -37,6 +37,7 @@ type ModelIdKey = keyof Pick<
| "openAiModelId"
| "litellmModelId"
| "ioIntelligenceModelId"
| "vercelAiGatewayModelId"
>
interface ModelPickerProps {

View file

@ -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))

View file

@ -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(
<K extends keyof ProviderSettings, E>(
field: K,
transform: (event: E) => ProviderSettings[K] = inputEventTransform,
) =>
(event: E | Event) => {
setApiConfigurationField(field, transform(event as E))
},
[setApiConfigurationField],
)
return (
<>
<VSCodeTextField
value={apiConfiguration?.vercelAiGatewayApiKey || ""}
type="password"
onInput={handleInputChange("vercelAiGatewayApiKey")}
placeholder={t("settings:placeholders.apiKey")}
className="w-full">
<label className="block font-medium mb-1">{t("settings:providers.vercelAiGatewayApiKey")}</label>
</VSCodeTextField>
<div className="text-sm text-vscode-descriptionForeground -mt-2">
{t("settings:providers.apiKeyStorageNotice")}
</div>
<ModelPicker
apiConfiguration={apiConfiguration}
setApiConfigurationField={setApiConfigurationField}
defaultModelId={vercelAiGatewayDefaultModelId}
models={routerModels?.["vercel-ai-gateway"] ?? {}}
modelIdKey="vercelAiGatewayModelId"
serviceName="Vercel AI Gateway"
serviceUrl="https://vercel.com/ai-gateway/models"
organizationAllowList={organizationAllowList}
errorMessage={modelValidationError}
/>
</>
)
}

View file

@ -28,3 +28,4 @@ export { ZAi } from "./ZAi"
export { LiteLLM } from "./LiteLLM"
export { Fireworks } from "./Fireworks"
export { Featherless } from "./Featherless"
export { VercelAiGateway } from "./VercelAiGateway"

View file

@ -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":

View file

@ -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)

View file

@ -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.",

View file

@ -41,6 +41,7 @@ describe("Model Validation Functions", () => {
ollama: {},
lmstudio: {},
"io-intelligence": {},
"vercel-ai-gateway": {},
}
const allowAllOrganization: OrganizationAllowList = {

View file

@ -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) {