diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 2b1aa701e251..374f4e62bfc4 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -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", diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index dd72d72fe959..3b80bba3b2de 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 4f5ca9c3caf3..8941e4cdd836 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 => { @@ -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] diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index 27951f0f1a48..97fa10ca8248 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 000000000000..70cf49b41976 --- /dev/null +++ b/packages/types/src/providers/vercel-ai-gateway.ts @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b683e48b6449..00788ad28e71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/api/index.ts b/src/api/index.ts index 188cb3930faf..59ca681f7967 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/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts new file mode 100644 index 000000000000..a3e7c9e7d5b7 --- /dev/null +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -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, + }), + ) + }) + }) +}) diff --git a/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts new file mode 100644 index 000000000000..657d335b61b1 --- /dev/null +++ b/src/api/providers/fetchers/__tests__/vercel-ai-gateway.spec.ts @@ -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) + } + }) + }) + }) +}) diff --git a/src/api/providers/fetchers/modelCache.ts b/src/api/providers/fetchers/modelCache.ts index f4c240a61cf7..0005e8205f63 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 000000000000..91456819a61c --- /dev/null +++ b/src/api/providers/fetchers/vercel-ai-gateway.ts @@ -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 + +/** + * 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 + + // 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 +} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index e2b9047dfc96..c3786c5f56d7 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 000000000000..be77d35986b4 --- /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, + 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 { + 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 + } + } +} diff --git a/src/api/transform/caching/__tests__/vercel-ai-gateway.spec.ts b/src/api/transform/caching/__tests__/vercel-ai-gateway.spec.ts new file mode 100644 index 000000000000..86dc593f4f3f --- /dev/null +++ b/src/api/transform/caching/__tests__/vercel-ai-gateway.spec.ts @@ -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" }, + }) + } + }) + }) +}) diff --git a/src/api/transform/caching/vercel-ai-gateway.ts b/src/api/transform/caching/vercel-ai-gateway.ts new file mode 100644 index 000000000000..82eff0cd7bf3 --- /dev/null +++ b/src/api/transform/caching/vercel-ai-gateway.ts @@ -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" } + } + } + }) +} diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 80c2f537a214..0d51890d8144 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -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, }, }) }) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index 6f76974d89d3..06dbc0350251 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -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, { diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 97bcf0f584e6..ddbc5a992e24 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/package.json b/src/package.json index 7b84e9759a63..1f5154968451 100644 --- a/src/package.json +++ b/src/package.json @@ -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:^", diff --git a/src/shared/api.ts b/src/shared/api.ts index 3dde992c6f5f..30dfd7393bed 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 555b98aa8237..a9bf7c70133b 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 0753f9fc2bde..e398a9f01fba 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 1a94c5c59945..9aa02bbf53ca 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 000000000000..8050469efcb8 --- /dev/null +++ b/webview-ui/src/components/settings/providers/VercelAiGateway.tsx @@ -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( + ( + field: K, + transform: (event: E) => ProviderSettings[K] = inputEventTransform, + ) => + (event: E | Event) => { + setApiConfigurationField(field, transform(event as E)) + }, + [setApiConfigurationField], + ) + + return ( + <> + + + +
+ {t("settings:providers.apiKeyStorageNotice")} +
+ {!apiConfiguration?.vercelAiGatewayApiKey && ( + + {t("settings:providers.getVercelAiGatewayApiKey")} + + )} + + + ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 46fea622c91a..eedbba0c2903 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 6455e2e31394..e9470e090262 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" @@ -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": diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 977ccaeec992..40209cc68ce3 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 71006a9d4c1a..f7ffc547168a 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 6d761e85ffc2..c8cad691a8f7 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index a3dbfa410c07..da5a5c367d43 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index a2c2d0ca0686..6c73a7adfd4e 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 000b5f89b367..769091541204 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -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 कुंजी प्राप्त करें", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 0af73ecda9e4..83f3dd39be8f 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 0cc2a590193c..e0cf5574d00a 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index 27b51854dd54..76661ec09193 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -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キーを取得", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index e6125f0ca839..463cad8d2634 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -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 키 받기", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index ca2024522e6f..816ea37b9662 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 25b04078bbca..2da116c79ede 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 7039497b9412..04655ba604e5 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 05d73cd46f0c..c3b351b69be2 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -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-ключ", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 1bcaa7def571..3813b3db17eb 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index b8cbb8c57179..3e1cfd7422f6 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -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", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index a7d30776846b..cb8dde46afa7 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -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 密钥", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index d8b71601dcba..adf5d5fd95f6 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -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 金鑰", diff --git a/webview-ui/src/utils/__tests__/validate.test.ts b/webview-ui/src/utils/__tests__/validate.test.ts index f14fb8920048..30ccfd4463fd 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 8f18e4411dfa..5613eb9eb8ee 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) {