feat: Add Vercel AI Gateway provider integration (#7396)

Co-authored-by: daniel-lxs <ricciodaniel98@gmail.com>
Co-authored-by: cte <cestreich@gmail.com>
This commit is contained in:
Josh 2025-08-26 13:41:02 -07:00 committed by GitHub
parent 7c91e4f259
commit 934bfd0a54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 1492 additions and 11 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@roo-code/types",
"version": "1.60.0",
"version": "1.61.0",
"description": "TypeScript type definitions for Roo Code.",
"publishConfig": {
"access": "public",

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 => {
@ -541,6 +550,7 @@ export const MODELS_BY_PROVIDER: Record<
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: [] },
}
export const dynamicProviders = [
@ -550,6 +560,7 @@ export const dynamicProviders = [
"openrouter",
"requesty",
"unbound",
"vercel-ai-gateway",
] as const satisfies readonly ProviderName[]
export type DynamicProvider = (typeof dynamicProviders)[number]

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,102 @@
import type { ModelInfo } from "../model.js"
// https://ai-gateway.vercel.sh/v1/
export const vercelAiGatewayDefaultModelId = "anthropic/claude-sonnet-4"
export const VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS = new Set([
"anthropic/claude-3-haiku",
"anthropic/claude-3-opus",
"anthropic/claude-3.5-haiku",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.7-sonnet",
"anthropic/claude-opus-4",
"anthropic/claude-opus-4.1",
"anthropic/claude-sonnet-4",
"openai/gpt-4.1",
"openai/gpt-4.1-mini",
"openai/gpt-4.1-nano",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/gpt-5",
"openai/gpt-5-mini",
"openai/gpt-5-nano",
"openai/o1",
"openai/o3",
"openai/o3-mini",
"openai/o4-mini",
])
export const VERCEL_AI_GATEWAY_VISION_ONLY_MODELS = new Set([
"alibaba/qwen-3-14b",
"alibaba/qwen-3-235b",
"alibaba/qwen-3-30b",
"alibaba/qwen-3-32b",
"alibaba/qwen3-coder",
"amazon/nova-pro",
"anthropic/claude-3.5-haiku",
"google/gemini-1.5-flash-8b",
"google/gemini-2.0-flash-thinking",
"google/gemma-3-27b",
"mistral/devstral-small",
"xai/grok-vision-beta",
])
export const VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS = new Set([
"amazon/nova-lite",
"anthropic/claude-3-haiku",
"anthropic/claude-3-opus",
"anthropic/claude-3-sonnet",
"anthropic/claude-3.5-sonnet",
"anthropic/claude-3.7-sonnet",
"anthropic/claude-opus-4",
"anthropic/claude-opus-4.1",
"anthropic/claude-sonnet-4",
"google/gemini-1.5-flash",
"google/gemini-1.5-pro",
"google/gemini-2.0-flash",
"google/gemini-2.0-flash-lite",
"google/gemini-2.0-pro",
"google/gemini-2.5-flash",
"google/gemini-2.5-flash-lite",
"google/gemini-2.5-pro",
"google/gemini-exp",
"meta/llama-3.2-11b",
"meta/llama-3.2-90b",
"meta/llama-3.3",
"meta/llama-4-maverick",
"meta/llama-4-scout",
"mistral/pixtral-12b",
"mistral/pixtral-large",
"moonshotai/kimi-k2",
"openai/gpt-4-turbo",
"openai/gpt-4.1",
"openai/gpt-4.1-mini",
"openai/gpt-4.1-nano",
"openai/gpt-4.5-preview",
"openai/gpt-4o",
"openai/gpt-4o-mini",
"openai/gpt-oss-120b",
"openai/gpt-oss-20b",
"openai/o3",
"openai/o3-pro",
"openai/o4-mini",
"vercel/v0-1.0-md",
"xai/grok-2-vision",
"zai/glm-4.5v",
])
export const vercelAiGatewayDefaultModelInfo: ModelInfo = {
maxTokens: 64000,
contextWindow: 200000,
supportsImages: true,
supportsComputerUse: 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.7

18
pnpm-lock.yaml generated
View file

@ -584,8 +584,8 @@ importers:
specifier: ^1.14.0
version: 1.14.0(typescript@5.8.3)
'@roo-code/cloud':
specifier: ^0.21.0
version: 0.21.0
specifier: ^0.22.0
version: 0.22.0
'@roo-code/ipc':
specifier: workspace:^
version: link:../packages/ipc
@ -3262,11 +3262,11 @@ packages:
cpu: [x64]
os: [win32]
'@roo-code/cloud@0.21.0':
resolution: {integrity: sha512-yNVybIjaS7Hy8GwDtGJc76N1WpCXGaCSlAEsW7VGjnojpxaIzV2GcJP1j1hg5q8HqLQnU4ixV0qXxOkxwhkEiA==}
'@roo-code/cloud@0.22.0':
resolution: {integrity: sha512-s1d4wcDYeDzcwr+YypMWDlNKL4f2osOZ3NoIlD36LCfFeMs+hnluZPS1oXX3WHtmPDC76vSzPMfwW2Ef41hEoA==}
'@roo-code/types@1.60.0':
resolution: {integrity: sha512-tQO6njPr/ZDNBoSHQg1/dpxfVEYeUzpKcernUxgJzmttn1zJbS0sc3CfUyPYOfYKB331z6O3KFUpaiqYFje1wA==}
'@roo-code/types@1.61.0':
resolution: {integrity: sha512-YJdFc6aYfaZ8EN08KbWaKLehRr1dcN3G3CzDjpppb08iehSEUZMycax/ryP5/G4vl34HTdtzyHNMboDen5ElUg==}
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@ -12563,9 +12563,9 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.40.2':
optional: true
'@roo-code/cloud@0.21.0':
'@roo-code/cloud@0.22.0':
dependencies:
'@roo-code/types': 1.60.0
'@roo-code/types': 1.61.0
ioredis: 5.6.1
p-wait-for: 5.0.2
socket.io-client: 4.8.1
@ -12575,7 +12575,7 @@ snapshots:
- supports-color
- utf-8-validate
'@roo-code/types@1.60.0':
'@roo-code/types@1.61.0':
dependencies:
zod: 3.25.76

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

@ -0,0 +1,383 @@
// npx vitest run src/api/providers/__tests__/vercel-ai-gateway.spec.ts
// Mock vscode first to avoid import errors
vitest.mock("vscode", () => ({}))
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { VercelAiGatewayHandler } from "../vercel-ai-gateway"
import { ApiHandlerOptions } from "../../../shared/api"
import { vercelAiGatewayDefaultModelId, VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types"
// Mock dependencies
vitest.mock("openai")
vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) }))
vitest.mock("../fetchers/modelCache", () => ({
getModels: vitest.fn().mockImplementation(() => {
return Promise.resolve({
"anthropic/claude-sonnet-4": {
maxTokens: 64000,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 3,
outputPrice: 15,
cacheWritesPrice: 3.75,
cacheReadsPrice: 0.3,
description: "Claude Sonnet 4",
supportsComputerUse: true,
},
"anthropic/claude-3.5-haiku": {
maxTokens: 32000,
contextWindow: 200000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1,
outputPrice: 5,
cacheWritesPrice: 1.25,
cacheReadsPrice: 0.1,
description: "Claude 3.5 Haiku",
supportsComputerUse: false,
},
"openai/gpt-4o": {
maxTokens: 16000,
contextWindow: 128000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2.5,
outputPrice: 10,
cacheWritesPrice: 3.125,
cacheReadsPrice: 0.25,
description: "GPT-4o",
supportsComputerUse: true,
},
})
}),
}))
vitest.mock("../../transform/caching/vercel-ai-gateway", () => ({
addCacheBreakpoints: vitest.fn(),
}))
const mockCreate = vitest.fn()
const mockConstructor = vitest.fn()
;(OpenAI as any).mockImplementation(() => ({
chat: {
completions: {
create: mockCreate,
},
},
}))
;(OpenAI as any).mockImplementation = mockConstructor.mockReturnValue({
chat: {
completions: {
create: mockCreate,
},
},
})
describe("VercelAiGatewayHandler", () => {
const mockOptions: ApiHandlerOptions = {
vercelAiGatewayApiKey: "test-key",
vercelAiGatewayModelId: "anthropic/claude-sonnet-4",
}
beforeEach(() => {
vitest.clearAllMocks()
mockCreate.mockClear()
mockConstructor.mockClear()
})
it("initializes with correct options", () => {
const handler = new VercelAiGatewayHandler(mockOptions)
expect(handler).toBeInstanceOf(VercelAiGatewayHandler)
expect(OpenAI).toHaveBeenCalledWith({
baseURL: "https://ai-gateway.vercel.sh/v1",
apiKey: mockOptions.vercelAiGatewayApiKey,
defaultHeaders: expect.objectContaining({
"HTTP-Referer": "https://github.com/RooVetGit/Roo-Cline",
"X-Title": "Roo Code",
"User-Agent": expect.stringContaining("RooCode/"),
}),
})
})
describe("fetchModel", () => {
it("returns correct model info when options are provided", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const result = await handler.fetchModel()
expect(result.id).toBe(mockOptions.vercelAiGatewayModelId)
expect(result.info.maxTokens).toBe(64000)
expect(result.info.contextWindow).toBe(200000)
expect(result.info.supportsImages).toBe(true)
expect(result.info.supportsPromptCache).toBe(true)
expect(result.info.supportsComputerUse).toBe(true)
})
it("returns default model info when options are not provided", async () => {
const handler = new VercelAiGatewayHandler({})
const result = await handler.fetchModel()
expect(result.id).toBe(vercelAiGatewayDefaultModelId)
expect(result.info.supportsPromptCache).toBe(true)
})
it("uses vercel ai gateway default model when no model specified", async () => {
const handler = new VercelAiGatewayHandler({ vercelAiGatewayApiKey: "test-key" })
const result = await handler.fetchModel()
expect(result.id).toBe("anthropic/claude-sonnet-4")
})
})
describe("createMessage", () => {
beforeEach(() => {
mockCreate.mockImplementation(async () => ({
[Symbol.asyncIterator]: async function* () {
yield {
choices: [
{
delta: { content: "Test response" },
index: 0,
},
],
usage: null,
}
yield {
choices: [
{
delta: {},
index: 0,
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 5,
total_tokens: 15,
cache_creation_input_tokens: 2,
prompt_tokens_details: {
cached_tokens: 3,
},
cost: 0.005,
},
}
},
}))
})
it("streams text content correctly", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const stream = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
expect(chunks).toHaveLength(2)
expect(chunks[0]).toEqual({
type: "text",
text: "Test response",
})
expect(chunks[1]).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 5,
cacheWriteTokens: 2,
cacheReadTokens: 3,
totalCost: 0.005,
})
})
it("uses correct temperature from options", async () => {
const customTemp = 0.5
const handler = new VercelAiGatewayHandler({
...mockOptions,
modelTemperature: customTemp,
})
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
await handler.createMessage(systemPrompt, messages).next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: customTemp,
}),
)
})
it("uses default temperature when none provided", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
await handler.createMessage(systemPrompt, messages).next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE,
}),
)
})
it("adds cache breakpoints for supported models", async () => {
const { addCacheBreakpoints } = await import("../../transform/caching/vercel-ai-gateway")
const handler = new VercelAiGatewayHandler({
...mockOptions,
vercelAiGatewayModelId: "anthropic/claude-3.5-haiku",
})
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
await handler.createMessage(systemPrompt, messages).next()
expect(addCacheBreakpoints).toHaveBeenCalled()
})
it("sets correct max_completion_tokens", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
await handler.createMessage(systemPrompt, messages).next()
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
max_completion_tokens: 64000, // max tokens for sonnet 4
}),
)
})
it("handles usage info correctly with all Vercel AI Gateway specific fields", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const systemPrompt = "You are a helpful assistant."
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }]
const stream = handler.createMessage(systemPrompt, messages)
const chunks = []
for await (const chunk of stream) {
chunks.push(chunk)
}
const usageChunk = chunks.find((chunk) => chunk.type === "usage")
expect(usageChunk).toEqual({
type: "usage",
inputTokens: 10,
outputTokens: 5,
cacheWriteTokens: 2,
cacheReadTokens: 3,
totalCost: 0.005,
})
})
})
describe("completePrompt", () => {
beforeEach(() => {
mockCreate.mockImplementation(async () => ({
choices: [
{
message: { role: "assistant", content: "Test completion response" },
finish_reason: "stop",
index: 0,
},
],
usage: {
prompt_tokens: 8,
completion_tokens: 4,
total_tokens: 12,
},
}))
})
it("completes prompt correctly", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const prompt = "Complete this: Hello"
const result = await handler.completePrompt(prompt)
expect(result).toBe("Test completion response")
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
model: "anthropic/claude-sonnet-4",
messages: [{ role: "user", content: prompt }],
stream: false,
temperature: VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE,
max_completion_tokens: 64000,
}),
)
})
it("uses custom temperature for completion", async () => {
const customTemp = 0.8
const handler = new VercelAiGatewayHandler({
...mockOptions,
modelTemperature: customTemp,
})
await handler.completePrompt("Test prompt")
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: customTemp,
}),
)
})
it("handles completion errors correctly", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
const errorMessage = "API error"
mockCreate.mockImplementation(() => {
throw new Error(errorMessage)
})
await expect(handler.completePrompt("Test")).rejects.toThrow(
`Vercel AI Gateway completion error: ${errorMessage}`,
)
})
it("returns empty string when no content in response", async () => {
const handler = new VercelAiGatewayHandler(mockOptions)
mockCreate.mockImplementation(async () => ({
choices: [
{
message: { role: "assistant", content: null },
finish_reason: "stop",
index: 0,
},
],
}))
const result = await handler.completePrompt("Test")
expect(result).toBe("")
})
})
describe("temperature support", () => {
it("applies temperature for supported models", async () => {
const handler = new VercelAiGatewayHandler({
...mockOptions,
vercelAiGatewayModelId: "anthropic/claude-sonnet-4",
modelTemperature: 0.9,
})
await handler.completePrompt("Test")
expect(mockCreate).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.9,
}),
)
})
})
})

View file

@ -0,0 +1,317 @@
// npx vitest run src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts
import axios from "axios"
import { VERCEL_AI_GATEWAY_VISION_ONLY_MODELS, VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS } from "@roo-code/types"
import { getVercelAiGatewayModels, parseVercelAiGatewayModel } from "../vercel-ai-gateway"
vitest.mock("axios")
const mockedAxios = axios as any
describe("Vercel AI Gateway Fetchers", () => {
beforeEach(() => {
vitest.clearAllMocks()
})
describe("getVercelAiGatewayModels", () => {
const mockResponse = {
data: {
object: "list",
data: [
{
id: "anthropic/claude-sonnet-4",
object: "model",
created: 1640995200,
owned_by: "anthropic",
name: "Claude Sonnet 4",
description:
"Claude Sonnet 4 significantly improves on Sonnet 3.7's industry-leading capabilities",
context_window: 200000,
max_tokens: 64000,
type: "language",
pricing: {
input: "3.00",
output: "15.00",
input_cache_write: "3.75",
input_cache_read: "0.30",
},
},
{
id: "anthropic/claude-3.5-haiku",
object: "model",
created: 1640995200,
owned_by: "anthropic",
name: "Claude 3.5 Haiku",
description: "Claude 3.5 Haiku is fast and lightweight",
context_window: 200000,
max_tokens: 32000,
type: "language",
pricing: {
input: "1.00",
output: "5.00",
input_cache_write: "1.25",
input_cache_read: "0.10",
},
},
{
id: "dall-e-3",
object: "model",
created: 1640995200,
owned_by: "openai",
name: "DALL-E 3",
description: "DALL-E 3 image generation model",
context_window: 4000,
max_tokens: 1000,
type: "image",
pricing: {
input: "40.00",
output: "0.00",
},
},
],
},
}
it("fetches and parses models correctly", async () => {
mockedAxios.get.mockResolvedValueOnce(mockResponse)
const models = await getVercelAiGatewayModels()
expect(mockedAxios.get).toHaveBeenCalledWith("https://ai-gateway.vercel.sh/v1/models")
expect(Object.keys(models)).toHaveLength(2) // Only language models
expect(models["anthropic/claude-sonnet-4"]).toBeDefined()
expect(models["anthropic/claude-3.5-haiku"]).toBeDefined()
})
it("handles API errors gracefully", async () => {
const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {})
mockedAxios.get.mockRejectedValueOnce(new Error("Network error"))
const models = await getVercelAiGatewayModels()
expect(models).toEqual({})
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining("Error fetching Vercel AI Gateway models"),
)
consoleErrorSpy.mockRestore()
})
it("handles invalid response schema gracefully", async () => {
const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {})
mockedAxios.get.mockResolvedValueOnce({
data: {
invalid: "response",
data: "not an array",
},
})
const models = await getVercelAiGatewayModels()
expect(models).toEqual({})
expect(consoleErrorSpy).toHaveBeenCalledWith(
"Vercel AI Gateway models response is invalid",
expect.any(Object),
)
consoleErrorSpy.mockRestore()
})
it("continues processing with partially valid schema", async () => {
const consoleErrorSpy = vitest.spyOn(console, "error").mockImplementation(() => {})
const invalidResponse = {
data: {
invalid_root: "response",
data: [
{
id: "anthropic/claude-sonnet-4",
object: "model",
created: 1640995200,
owned_by: "anthropic",
name: "Claude Sonnet 4",
description: "Claude Sonnet 4",
context_window: 200000,
max_tokens: 64000,
type: "language",
pricing: {
input: "3.00",
output: "15.00",
},
},
],
},
}
mockedAxios.get.mockResolvedValueOnce(invalidResponse)
const models = await getVercelAiGatewayModels()
expect(consoleErrorSpy).toHaveBeenCalled()
expect(models["anthropic/claude-sonnet-4"]).toBeDefined()
consoleErrorSpy.mockRestore()
})
})
describe("parseVercelAiGatewayModel", () => {
const baseModel = {
id: "test/model",
object: "model",
created: 1640995200,
owned_by: "test",
name: "Test Model",
description: "A test model",
context_window: 100000,
max_tokens: 8000,
type: "language",
pricing: {
input: "2.50",
output: "10.00",
},
}
it("parses basic model info correctly", () => {
const result = parseVercelAiGatewayModel({
id: "test/model",
model: baseModel,
})
expect(result).toEqual({
maxTokens: 8000,
contextWindow: 100000,
supportsImages: false,
supportsComputerUse: false,
supportsPromptCache: false,
inputPrice: 2500000,
outputPrice: 10000000,
cacheWritesPrice: undefined,
cacheReadsPrice: undefined,
description: "A test model",
})
})
it("parses cache pricing when available", () => {
const modelWithCache = {
...baseModel,
pricing: {
input: "3.00",
output: "15.00",
input_cache_write: "3.75",
input_cache_read: "0.30",
},
}
const result = parseVercelAiGatewayModel({
id: "anthropic/claude-sonnet-4",
model: modelWithCache,
})
expect(result).toMatchObject({
supportsPromptCache: true,
cacheWritesPrice: 3750000,
cacheReadsPrice: 300000,
})
})
it("detects vision-only models", () => {
// claude 3.5 haiku in VERCEL_AI_GATEWAY_VISION_ONLY_MODELS
const visionModel = {
...baseModel,
id: "anthropic/claude-3.5-haiku",
}
const result = parseVercelAiGatewayModel({
id: "anthropic/claude-3.5-haiku",
model: visionModel,
})
expect(result.supportsImages).toBe(VERCEL_AI_GATEWAY_VISION_ONLY_MODELS.has("anthropic/claude-3.5-haiku"))
expect(result.supportsComputerUse).toBe(false)
})
it("detects vision and tools models", () => {
// 4 sonnet in VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS
const visionToolsModel = {
...baseModel,
id: "anthropic/claude-sonnet-4",
}
const result = parseVercelAiGatewayModel({
id: "anthropic/claude-sonnet-4",
model: visionToolsModel,
})
expect(result.supportsImages).toBe(
VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS.has("anthropic/claude-sonnet-4"),
)
expect(result.supportsComputerUse).toBe(
VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS.has("anthropic/claude-sonnet-4"),
)
})
it("handles missing cache pricing", () => {
const modelNoCachePricing = {
...baseModel,
pricing: {
input: "2.50",
output: "10.00",
// No cache pricing
},
}
const result = parseVercelAiGatewayModel({
id: "test/model",
model: modelNoCachePricing,
})
expect(result.supportsPromptCache).toBe(false)
expect(result.cacheWritesPrice).toBeUndefined()
expect(result.cacheReadsPrice).toBeUndefined()
})
it("handles partial cache pricing", () => {
const modelPartialCachePricing = {
...baseModel,
pricing: {
input: "2.50",
output: "10.00",
input_cache_write: "3.00",
// Missing input_cache_read
},
}
const result = parseVercelAiGatewayModel({
id: "test/model",
model: modelPartialCachePricing,
})
expect(result.supportsPromptCache).toBe(false)
expect(result.cacheWritesPrice).toBe(3000000)
expect(result.cacheReadsPrice).toBeUndefined()
})
it("validates all vision model categories", () => {
// Test a few models from each category
const visionOnlyModels = ["anthropic/claude-3.5-haiku", "google/gemini-1.5-flash-8b"]
const visionAndToolsModels = ["anthropic/claude-sonnet-4", "openai/gpt-4o"]
visionOnlyModels.forEach((modelId) => {
if (VERCEL_AI_GATEWAY_VISION_ONLY_MODELS.has(modelId)) {
const result = parseVercelAiGatewayModel({
id: modelId,
model: { ...baseModel, id: modelId },
})
expect(result.supportsImages).toBe(true)
expect(result.supportsComputerUse).toBe(false)
}
})
visionAndToolsModels.forEach((modelId) => {
if (VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS.has(modelId)) {
const result = parseVercelAiGatewayModel({
id: modelId,
model: { ...baseModel, id: modelId },
})
expect(result.supportsImages).toBe(true)
expect(result.supportsComputerUse).toBe(true)
}
})
})
})
})

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,120 @@
import axios from "axios"
import { z } from "zod"
import type { ModelInfo } from "@roo-code/types"
import { VERCEL_AI_GATEWAY_VISION_ONLY_MODELS, VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS } 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
// Only include language models
if (model.type !== "language") {
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 supportsImages =
VERCEL_AI_GATEWAY_VISION_ONLY_MODELS.has(id) || VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS.has(id)
const supportsComputerUse = VERCEL_AI_GATEWAY_VISION_AND_TOOLS_MODELS.has(id)
const modelInfo: ModelInfo = {
maxTokens: model.max_tokens,
contextWindow: model.context_window,
supportsImages,
supportsComputerUse,
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,
VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS,
} 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/vercel-ai-gateway"
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
cost?: 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 (VERCEL_AI_GATEWAY_PROMPT_CACHING_MODELS.has(modelId) && info.supportsPromptCache) {
addCacheBreakpoints(systemPrompt, openAiMessages)
}
const body: OpenAI.Chat.ChatCompletionCreateParams = {
model: modelId,
messages: openAiMessages,
temperature: this.supportsTemperature(modelId)
? (this.options.modelTemperature ?? VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE)
: undefined,
max_completion_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,
totalCost: usage.cost ?? 0,
}
}
}
}
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_completion_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
}
}
}

View file

@ -0,0 +1,233 @@
// npx vitest run src/api/transform/caching/__tests__/vercel-ai-gateway.spec.ts
import OpenAI from "openai"
import { addCacheBreakpoints } from "../vercel-ai-gateway"
describe("Vercel AI Gateway Caching", () => {
describe("addCacheBreakpoints", () => {
it("adds cache control to system message", () => {
const systemPrompt = "You are a helpful assistant."
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: "Hello" },
]
addCacheBreakpoints(systemPrompt, messages)
expect(messages[0]).toEqual({
role: "system",
content: systemPrompt,
cache_control: { type: "ephemeral" },
})
})
it("adds cache control to last two user messages with string content", () => {
const systemPrompt = "You are a helpful assistant."
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: "First message" },
{ role: "assistant", content: "First response" },
{ role: "user", content: "Second message" },
{ role: "assistant", content: "Second response" },
{ role: "user", content: "Third message" },
{ role: "assistant", content: "Third response" },
{ role: "user", content: "Fourth message" },
]
addCacheBreakpoints(systemPrompt, messages)
const lastUserMessage = messages[7]
expect(Array.isArray(lastUserMessage.content)).toBe(true)
if (Array.isArray(lastUserMessage.content)) {
const textPart = lastUserMessage.content.find((part) => part.type === "text")
expect(textPart).toEqual({
type: "text",
text: "Fourth message",
cache_control: { type: "ephemeral" },
})
}
const secondLastUserMessage = messages[5]
expect(Array.isArray(secondLastUserMessage.content)).toBe(true)
if (Array.isArray(secondLastUserMessage.content)) {
const textPart = secondLastUserMessage.content.find((part) => part.type === "text")
expect(textPart).toEqual({
type: "text",
text: "Third message",
cache_control: { type: "ephemeral" },
})
}
})
it("handles messages with existing array content", () => {
const systemPrompt = "You are a helpful assistant."
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{
role: "user",
content: [
{ type: "text", text: "Hello with image" },
{ type: "image_url", image_url: { url: "data:image/png;base64,..." } },
],
},
]
addCacheBreakpoints(systemPrompt, messages)
const userMessage = messages[1]
expect(Array.isArray(userMessage.content)).toBe(true)
if (Array.isArray(userMessage.content)) {
const textPart = userMessage.content.find((part) => part.type === "text")
expect(textPart).toEqual({
type: "text",
text: "Hello with image",
cache_control: { type: "ephemeral" },
})
const imagePart = userMessage.content.find((part) => part.type === "image_url")
expect(imagePart).toEqual({
type: "image_url",
image_url: { url: "data:image/png;base64,..." },
})
}
})
it("handles empty string content gracefully", () => {
const systemPrompt = "You are a helpful assistant."
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: "" },
]
addCacheBreakpoints(systemPrompt, messages)
const userMessage = messages[1]
expect(userMessage.content).toBe("")
})
it("handles messages with no text parts", () => {
const systemPrompt = "You are a helpful assistant."
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{
role: "user",
content: [{ type: "image_url", image_url: { url: "data:image/png;base64,..." } }],
},
]
addCacheBreakpoints(systemPrompt, messages)
const userMessage = messages[1]
expect(Array.isArray(userMessage.content)).toBe(true)
if (Array.isArray(userMessage.content)) {
const textPart = userMessage.content.find((part) => part.type === "text")
expect(textPart).toBeUndefined()
const imagePart = userMessage.content.find((part) => part.type === "image_url")
expect(imagePart).toEqual({
type: "image_url",
image_url: { url: "data:image/png;base64,..." },
})
}
})
it("processes only user messages for conversation caching", () => {
const systemPrompt = "You are a helpful assistant."
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: "First user" },
{ role: "assistant", content: "Assistant response" },
{ role: "user", content: "Second user" },
]
addCacheBreakpoints(systemPrompt, messages)
expect(messages[2]).toEqual({
role: "assistant",
content: "Assistant response",
})
const firstUser = messages[1]
const secondUser = messages[3]
expect(Array.isArray(firstUser.content)).toBe(true)
expect(Array.isArray(secondUser.content)).toBe(true)
})
it("handles case with only one user message", () => {
const systemPrompt = "You are a helpful assistant."
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: "Only message" },
]
addCacheBreakpoints(systemPrompt, messages)
const userMessage = messages[1]
expect(Array.isArray(userMessage.content)).toBe(true)
if (Array.isArray(userMessage.content)) {
const textPart = userMessage.content.find((part) => part.type === "text")
expect(textPart).toEqual({
type: "text",
text: "Only message",
cache_control: { type: "ephemeral" },
})
}
})
it("handles case with no user messages", () => {
const systemPrompt = "You are a helpful assistant."
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{ role: "assistant", content: "Assistant only" },
]
addCacheBreakpoints(systemPrompt, messages)
expect(messages[0]).toEqual({
role: "system",
content: systemPrompt,
cache_control: { type: "ephemeral" },
})
expect(messages[1]).toEqual({
role: "assistant",
content: "Assistant only",
})
})
it("handles messages with multiple text parts", () => {
const systemPrompt = "You are a helpful assistant."
const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
{
role: "user",
content: [
{ type: "text", text: "First part" },
{ type: "image_url", image_url: { url: "data:image/png;base64,..." } },
{ type: "text", text: "Second part" },
],
},
]
addCacheBreakpoints(systemPrompt, messages)
const userMessage = messages[1]
if (Array.isArray(userMessage.content)) {
const textParts = userMessage.content.filter((part) => part.type === "text")
expect(textParts).toHaveLength(2)
expect(textParts[0]).toEqual({
type: "text",
text: "First part",
})
expect(textParts[1]).toEqual({
type: "text",
text: "Second part",
cache_control: { type: "ephemeral" },
})
}
})
})
})

View file

@ -0,0 +1,30 @@
import OpenAI from "openai"
export function addCacheBreakpoints(systemPrompt: string, messages: OpenAI.Chat.ChatCompletionMessageParam[]) {
// Apply cache_control to system message at the message level
messages[0] = {
role: "system",
content: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
}
// Add cache_control to the last two user messages for conversation context caching
const lastTwoUserMessages = messages.filter((msg) => msg.role === "user").slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string" && msg.content.length > 0) {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// Find the last text part in the message content
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
if (lastTextPart && lastTextPart.text && lastTextPart.text.length > 0) {
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
}
})
}

View file

@ -2669,6 +2669,7 @@ describe("ClineProvider - Router Models", () => {
expect(getModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" })
expect(getModels).toHaveBeenCalledWith({ provider: "glama" })
expect(getModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" })
expect(getModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" })
expect(getModels).toHaveBeenCalledWith({
provider: "litellm",
apiKey: "litellm-key",
@ -2686,6 +2687,7 @@ describe("ClineProvider - Router Models", () => {
litellm: mockModels,
ollama: {},
lmstudio: {},
"vercel-ai-gateway": mockModels,
},
})
})
@ -2716,6 +2718,7 @@ describe("ClineProvider - Router Models", () => {
.mockRejectedValueOnce(new Error("Requesty API error")) // requesty fail
.mockResolvedValueOnce(mockModels) // glama success
.mockRejectedValueOnce(new Error("Unbound API error")) // unbound fail
.mockResolvedValueOnce(mockModels) // vercel-ai-gateway success
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail
await messageHandler({ type: "requestRouterModels" })
@ -2731,6 +2734,7 @@ describe("ClineProvider - Router Models", () => {
ollama: {},
lmstudio: {},
litellm: {},
"vercel-ai-gateway": mockModels,
},
})
@ -2841,6 +2845,7 @@ describe("ClineProvider - Router Models", () => {
litellm: {},
ollama: {},
lmstudio: {},
"vercel-ai-gateway": mockModels,
},
})
})

View file

@ -178,6 +178,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" })
expect(mockGetModels).toHaveBeenCalledWith({ provider: "glama" })
expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" })
expect(mockGetModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" })
expect(mockGetModels).toHaveBeenCalledWith({
provider: "litellm",
apiKey: "litellm-key",
@ -195,6 +196,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
litellm: mockModels,
ollama: {},
lmstudio: {},
"vercel-ai-gateway": mockModels,
},
})
})
@ -282,6 +284,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
litellm: {},
ollama: {},
lmstudio: {},
"vercel-ai-gateway": mockModels,
},
})
})
@ -302,6 +305,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
.mockRejectedValueOnce(new Error("Requesty API error")) // requesty
.mockResolvedValueOnce(mockModels) // glama
.mockRejectedValueOnce(new Error("Unbound API error")) // unbound
.mockResolvedValueOnce(mockModels) // vercel-ai-gateway
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
await webviewMessageHandler(mockClineProvider, {
@ -319,6 +323,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
litellm: {},
ollama: {},
lmstudio: {},
"vercel-ai-gateway": mockModels,
},
})
@ -352,6 +357,7 @@ describe("webviewMessageHandler - requestRouterModels", () => {
.mockRejectedValueOnce(new Error("Requesty API error")) // requesty
.mockRejectedValueOnce(new Error("Glama API error")) // glama
.mockRejectedValueOnce(new Error("Unbound API error")) // unbound
.mockRejectedValueOnce(new Error("Vercel AI Gateway error")) // vercel-ai-gateway
.mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm
await webviewMessageHandler(mockClineProvider, {

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

@ -429,7 +429,7 @@
"@mistralai/mistralai": "^1.9.18",
"@modelcontextprotocol/sdk": "^1.9.0",
"@qdrant/js-client-rest": "^1.14.0",
"@roo-code/cloud": "^0.21.0",
"@roo-code/cloud": "^0.22.0",
"@roo-code/ipc": "workspace:^",
"@roo-code/telemetry": "workspace:^",
"@roo-code/types": "workspace:^",

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,77 @@
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 { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
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>
{!apiConfiguration?.vercelAiGatewayApiKey && (
<VSCodeButtonLink
href="https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%2Fapi-keys&title=AI+Gateway+API+Key"
appearance="primary"
style={{ width: "100%" }}>
{t("settings:providers.getVercelAiGatewayApiKey")}
</VSCodeButtonLink>
)}
<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"
@ -329,6 +330,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

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Assegureu-vos que la regió a l'ARN coincideix amb la regió d'AWS seleccionada anteriorment.",
"openRouterApiKey": "Clau API d'OpenRouter",
"getOpenRouterApiKey": "Obtenir clau API d'OpenRouter",
"vercelAiGatewayApiKey": "Clau API de Vercel AI Gateway",
"getVercelAiGatewayApiKey": "Obtenir clau API de Vercel AI Gateway",
"apiKeyStorageNotice": "Les claus API s'emmagatzemen de forma segura a l'Emmagatzematge Secret de VSCode",
"glamaApiKey": "Clau API de Glama",
"getGlamaApiKey": "Obtenir clau API de Glama",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Stellen Sie sicher, dass die Region in der ARN mit Ihrer oben ausgewählten AWS-Region übereinstimmt.",
"openRouterApiKey": "OpenRouter API-Schlüssel",
"getOpenRouterApiKey": "OpenRouter API-Schlüssel erhalten",
"vercelAiGatewayApiKey": "Vercel AI Gateway API-Schlüssel",
"getVercelAiGatewayApiKey": "Vercel AI Gateway API-Schlüssel erhalten",
"doubaoApiKey": "Doubao API-Schlüssel",
"getDoubaoApiKey": "Doubao API-Schlüssel erhalten",
"apiKeyStorageNotice": "API-Schlüssel werden sicher im VSCode Secret Storage gespeichert",

View file

@ -232,6 +232,8 @@
"awsCustomArnDesc": "Make sure the region in the ARN matches your selected AWS Region above.",
"openRouterApiKey": "OpenRouter API Key",
"getOpenRouterApiKey": "Get OpenRouter API Key",
"vercelAiGatewayApiKey": "Vercel AI Gateway API Key",
"getVercelAiGatewayApiKey": "Get Vercel AI Gateway API Key",
"apiKeyStorageNotice": "API keys are stored securely in VSCode's Secret Storage",
"glamaApiKey": "Glama API Key",
"getGlamaApiKey": "Get Glama API Key",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Asegúrese de que la región en el ARN coincida con la región de AWS seleccionada anteriormente.",
"openRouterApiKey": "Clave API de OpenRouter",
"getOpenRouterApiKey": "Obtener clave API de OpenRouter",
"vercelAiGatewayApiKey": "Clave API de Vercel AI Gateway",
"getVercelAiGatewayApiKey": "Obtener clave API de Vercel AI Gateway",
"apiKeyStorageNotice": "Las claves API se almacenan de forma segura en el Almacenamiento Secreto de VSCode",
"glamaApiKey": "Clave API de Glama",
"getGlamaApiKey": "Obtener clave API de Glama",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Assurez-vous que la région dans l'ARN correspond à la région AWS sélectionnée ci-dessus.",
"openRouterApiKey": "Clé API OpenRouter",
"getOpenRouterApiKey": "Obtenir la clé API OpenRouter",
"vercelAiGatewayApiKey": "Clé API Vercel AI Gateway",
"getVercelAiGatewayApiKey": "Obtenir la clé API Vercel AI Gateway",
"apiKeyStorageNotice": "Les clés API sont stockées en toute sécurité dans le stockage sécurisé de VSCode",
"glamaApiKey": "Clé API Glama",
"getGlamaApiKey": "Obtenir la clé API Glama",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "सुनिश्चित करें कि ARN में क्षेत्र ऊपर चयनित AWS क्षेत्र से मेल खाता है।",
"openRouterApiKey": "OpenRouter API कुंजी",
"getOpenRouterApiKey": "OpenRouter API कुंजी प्राप्त करें",
"vercelAiGatewayApiKey": "Vercel AI Gateway API कुंजी",
"getVercelAiGatewayApiKey": "Vercel AI Gateway API कुंजी प्राप्त करें",
"apiKeyStorageNotice": "API कुंजियाँ VSCode के सुरक्षित स्टोरेज में सुरक्षित रूप से संग्रहीत हैं",
"glamaApiKey": "Glama API कुंजी",
"getGlamaApiKey": "Glama API कुंजी प्राप्त करें",

View file

@ -237,6 +237,8 @@
"awsCustomArnDesc": "Pastikan region di ARN cocok dengan AWS Region yang kamu pilih di atas.",
"openRouterApiKey": "OpenRouter API Key",
"getOpenRouterApiKey": "Dapatkan OpenRouter API Key",
"vercelAiGatewayApiKey": "Vercel AI Gateway API Key",
"getVercelAiGatewayApiKey": "Dapatkan Vercel AI Gateway API Key",
"apiKeyStorageNotice": "API key disimpan dengan aman di Secret Storage VSCode",
"glamaApiKey": "Glama API Key",
"getGlamaApiKey": "Dapatkan Glama API Key",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Assicurati che la regione nell'ARN corrisponda alla regione AWS selezionata sopra.",
"openRouterApiKey": "Chiave API OpenRouter",
"getOpenRouterApiKey": "Ottieni chiave API OpenRouter",
"vercelAiGatewayApiKey": "Chiave API Vercel AI Gateway",
"getVercelAiGatewayApiKey": "Ottieni chiave API Vercel AI Gateway",
"apiKeyStorageNotice": "Le chiavi API sono memorizzate in modo sicuro nell'Archivio Segreto di VSCode",
"glamaApiKey": "Chiave API Glama",
"getGlamaApiKey": "Ottieni chiave API Glama",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "ARN内のリージョンが上で選択したAWSリージョンと一致していることを確認してください。",
"openRouterApiKey": "OpenRouter APIキー",
"getOpenRouterApiKey": "OpenRouter APIキーを取得",
"vercelAiGatewayApiKey": "Vercel AI Gateway APIキー",
"getVercelAiGatewayApiKey": "Vercel AI Gateway APIキーを取得",
"apiKeyStorageNotice": "APIキーはVSCodeのシークレットストレージに安全に保存されます",
"glamaApiKey": "Glama APIキー",
"getGlamaApiKey": "Glama APIキーを取得",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "ARN의 리전이 위에서 선택한 AWS 리전과 일치하는지 확인하세요.",
"openRouterApiKey": "OpenRouter API 키",
"getOpenRouterApiKey": "OpenRouter API 키 받기",
"vercelAiGatewayApiKey": "Vercel AI Gateway API 키",
"getVercelAiGatewayApiKey": "Vercel AI Gateway API 키 받기",
"apiKeyStorageNotice": "API 키는 VSCode의 보안 저장소에 안전하게 저장됩니다",
"glamaApiKey": "Glama API 키",
"getGlamaApiKey": "Glama API 키 받기",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Zorg ervoor dat de regio in de ARN overeenkomt met je geselecteerde AWS-regio hierboven.",
"openRouterApiKey": "OpenRouter API-sleutel",
"getOpenRouterApiKey": "OpenRouter API-sleutel ophalen",
"vercelAiGatewayApiKey": "Vercel AI Gateway API-sleutel",
"getVercelAiGatewayApiKey": "Vercel AI Gateway API-sleutel ophalen",
"apiKeyStorageNotice": "API-sleutels worden veilig opgeslagen in de geheime opslag van VSCode",
"glamaApiKey": "Glama API-sleutel",
"getGlamaApiKey": "Glama API-sleutel ophalen",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Upewnij się, że region w ARN odpowiada wybranemu powyżej regionowi AWS.",
"openRouterApiKey": "Klucz API OpenRouter",
"getOpenRouterApiKey": "Uzyskaj klucz API OpenRouter",
"vercelAiGatewayApiKey": "Klucz API Vercel AI Gateway",
"getVercelAiGatewayApiKey": "Uzyskaj klucz API Vercel AI Gateway",
"apiKeyStorageNotice": "Klucze API są bezpiecznie przechowywane w Tajnym Magazynie VSCode",
"glamaApiKey": "Klucz API Glama",
"getGlamaApiKey": "Uzyskaj klucz API Glama",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Certifique-se de que a região no ARN corresponde à região AWS selecionada acima.",
"openRouterApiKey": "Chave de API OpenRouter",
"getOpenRouterApiKey": "Obter chave de API OpenRouter",
"vercelAiGatewayApiKey": "Chave API do Vercel AI Gateway",
"getVercelAiGatewayApiKey": "Obter chave API do Vercel AI Gateway",
"apiKeyStorageNotice": "As chaves de API são armazenadas com segurança no Armazenamento Secreto do VSCode",
"glamaApiKey": "Chave de API Glama",
"getGlamaApiKey": "Obter chave de API Glama",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Убедитесь, что регион в ARN совпадает с выбранным выше регионом AWS.",
"openRouterApiKey": "OpenRouter API-ключ",
"getOpenRouterApiKey": "Получить OpenRouter API-ключ",
"vercelAiGatewayApiKey": "Ключ API Vercel AI Gateway",
"getVercelAiGatewayApiKey": "Получить ключ API Vercel AI Gateway",
"apiKeyStorageNotice": "API-ключи хранятся безопасно в Secret Storage VSCode",
"glamaApiKey": "Glama API-ключ",
"getGlamaApiKey": "Получить Glama API-ключ",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "ARN içindeki bölgenin yukarıda seçilen AWS Bölgesiyle eşleştiğinden emin olun.",
"openRouterApiKey": "OpenRouter API Anahtarı",
"getOpenRouterApiKey": "OpenRouter API Anahtarı Al",
"vercelAiGatewayApiKey": "Vercel AI Gateway API Anahtarı",
"getVercelAiGatewayApiKey": "Vercel AI Gateway API Anahtarı Al",
"apiKeyStorageNotice": "API anahtarları VSCode'un Gizli Depolamasında güvenli bir şekilde saklanır",
"glamaApiKey": "Glama API Anahtarı",
"getGlamaApiKey": "Glama API Anahtarı Al",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "Đảm bảo rằng vùng trong ARN khớp với vùng AWS đã chọn ở trên.",
"openRouterApiKey": "Khóa API OpenRouter",
"getOpenRouterApiKey": "Lấy khóa API OpenRouter",
"vercelAiGatewayApiKey": "Khóa API Vercel AI Gateway",
"getVercelAiGatewayApiKey": "Lấy khóa API Vercel AI Gateway",
"apiKeyStorageNotice": "Khóa API được lưu trữ an toàn trong Bộ lưu trữ bí mật của VSCode",
"glamaApiKey": "Khóa API Glama",
"getGlamaApiKey": "Lấy khóa API Glama",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "请确保ARN中的区域与上方选择的AWS区域一致。",
"openRouterApiKey": "OpenRouter API 密钥",
"getOpenRouterApiKey": "获取 OpenRouter API 密钥",
"vercelAiGatewayApiKey": "Vercel AI Gateway API 密钥",
"getVercelAiGatewayApiKey": "获取 Vercel AI Gateway API 密钥",
"apiKeyStorageNotice": "API 密钥安全存储在 VSCode 的密钥存储中",
"glamaApiKey": "Glama API 密钥",
"getGlamaApiKey": "获取 Glama API 密钥",

View file

@ -233,6 +233,8 @@
"awsCustomArnDesc": "確保 ARN 中的區域與您上面選擇的 AWS 區域相符。",
"openRouterApiKey": "OpenRouter API 金鑰",
"getOpenRouterApiKey": "取得 OpenRouter API 金鑰",
"vercelAiGatewayApiKey": "Vercel AI Gateway API 金鑰",
"getVercelAiGatewayApiKey": "取得 Vercel AI Gateway API 金鑰",
"apiKeyStorageNotice": "API 金鑰安全儲存於 VSCode 金鑰儲存中",
"glamaApiKey": "Glama API 金鑰",
"getGlamaApiKey": "取得 Glama API 金鑰",

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