diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 50b9f36a..4de90330 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -129,7 +129,8 @@ export const PROVIDERS = { }, "vercel-ai-gateway": { baseUrl: "https://ai-gateway.vercel.sh/v1/chat/completions", - format: "openai" + format: "openai", + retry: { 429: 2 } }, glm: { baseUrl: "https://api.z.ai/api/anthropic/v1/messages", diff --git a/open-sse/handlers/embeddingProviders/index.js b/open-sse/handlers/embeddingProviders/index.js index bbc7c6f5..62e52778 100644 --- a/open-sse/handlers/embeddingProviders/index.js +++ b/open-sse/handlers/embeddingProviders/index.js @@ -6,6 +6,7 @@ import openaiCompatNode from "./openaiCompatNode.js"; const OPENAI_COMPAT_PROVIDERS = [ "openai", "openrouter", "mistral", "voyage-ai", "fireworks", "together", "nebius", "github", "nvidia", "jina-ai", + "vercel-ai-gateway", ]; const ADAPTERS = { diff --git a/open-sse/handlers/embeddingProviders/openai.js b/open-sse/handlers/embeddingProviders/openai.js index c89cd9d5..877890d2 100644 --- a/open-sse/handlers/embeddingProviders/openai.js +++ b/open-sse/handlers/embeddingProviders/openai.js @@ -12,6 +12,7 @@ const ENDPOINTS = { github: "https://models.github.ai/inference/embeddings", nvidia: "https://integrate.api.nvidia.com/v1/embeddings", "jina-ai": "https://api.jina.ai/v1/embeddings", + "vercel-ai-gateway": "https://ai-gateway.vercel.sh/v1/embeddings", }; export default function createOpenAIEmbeddingAdapter(providerId) { diff --git a/open-sse/handlers/imageProviders/index.js b/open-sse/handlers/imageProviders/index.js index 7094a8ea..95d8e005 100644 --- a/open-sse/handlers/imageProviders/index.js +++ b/open-sse/handlers/imageProviders/index.js @@ -17,6 +17,7 @@ const ADAPTERS = { minimax: createOpenAIAdapter("minimax"), openrouter: createOpenAIAdapter("openrouter"), recraft: createOpenAIAdapter("recraft"), + "vercel-ai-gateway": createOpenAIAdapter("vercel-ai-gateway"), xai: createOpenAIAdapter("xai"), gemini, codex, diff --git a/open-sse/handlers/imageProviders/openai.js b/open-sse/handlers/imageProviders/openai.js index 7c28a3a6..d5032e93 100644 --- a/open-sse/handlers/imageProviders/openai.js +++ b/open-sse/handlers/imageProviders/openai.js @@ -5,6 +5,7 @@ const ENDPOINTS = { minimax: "https://api.minimaxi.com/v1/images/generations", openrouter: "https://openrouter.ai/api/v1/images/generations", recraft: "https://external.api.recraft.ai/v1/images/generations", + "vercel-ai-gateway": "https://ai-gateway.vercel.sh/v1/images/generations", xai: "https://api.x.ai/v1/images/generations", }; diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js index 0ca6b929..95c4be7c 100644 --- a/open-sse/services/usage.js +++ b/open-sse/services/usage.js @@ -30,6 +30,11 @@ const MINIMAX_USAGE_URLS = { ], }; +// Vercel AI Gateway credits endpoint +// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings). +// Docs: https://vercel.com/docs/ai-gateway/usage +const VERCEL_AI_GATEWAY_CREDITS_URL = "https://ai-gateway.vercel.sh/v1/credits"; + // Antigravity API config (from Quotio) const ANTIGRAVITY_CONFIG = { quotaApiUrl: "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels", @@ -92,6 +97,8 @@ export async function getUsageForProvider(connection, proxyOptions = null) { case "minimax": case "minimax-cn": return await getMiniMaxUsage(apiKey, provider, proxyOptions); + case "vercel-ai-gateway": + return await getVercelAiGatewayUsage(apiKey, proxyOptions); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -1203,6 +1210,89 @@ async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) { return { message: lastErrorMessage ? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}` : "MiniMax connected. Unable to fetch usage." }; } + +/** + * Vercel AI Gateway usage — credit balance for the API key + * + * Calls GET /v1/credits which returns: + * { "balance": "95.50", "total_used": "4.50" } (USD as decimal strings) + * + * We surface this as a single "Balance ($)" quota row so the existing + * QuotaTable / progress-bar UI can render it. used = total_used, + * total = balance + total_used (the original credit allotment), so the + * remaining percentage equals balance / total. + * + * Docs: https://vercel.com/docs/ai-gateway/usage + */ +async function getVercelAiGatewayUsage(apiKey, proxyOptions = null) { + if (!apiKey) { + return { message: "Vercel AI Gateway API key not available." }; + } + + try { + const response = await proxyAwareFetch(VERCEL_AI_GATEWAY_CREDITS_URL, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + }, proxyOptions); + + if (response.status === 401 || response.status === 403) { + return { message: "Vercel AI Gateway API key invalid or expired." }; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => ""); + const trimmed = errorText ? `: ${errorText.slice(0, 200)}` : ""; + return { message: `Vercel AI Gateway credits API error (${response.status})${trimmed}` }; + } + + const data = await response.json(); + + // Vercel returns numeric strings; coerce safely. + const balance = Number(data?.balance) || 0; + const totalUsed = Number(data?.total_used) || 0; + + // Vercel gives $5/month free credit. The API doesn't return the + // monthly allocation so we use the known constant as the denominator. + const MONTHLY_CREDIT = 5; + const remainingPercentage = (balance / MONTHLY_CREDIT) * 100; + + if (balance <= 0 && totalUsed <= 0) { + return { + plan: "Pay-as-you-go", + message: "Vercel AI Gateway connected. No credit allocation found (BYOK or unfunded account).", + quotas: {}, + }; + } + + // "Used (USD)": how much has been spent this month (no fixed cap → unlimited). + // "Remaining (USD)": balance remaining out of the $5 monthly allocation. + return { + plan: "Pay-as-you-go", + quotas: { + "Used (USD)": { + used: totalUsed, + total: 0, + remaining: 0, + remainingPercentage: 100, + unlimited: true, + }, + "Remaining (USD)": { + used: balance, + total: MONTHLY_CREDIT, + remaining: balance, + remainingPercentage, + unlimited: false, + }, + }, + }; + } catch (error) { + return { message: `Vercel AI Gateway error: ${error.message}` }; + } +} + async function getQoderUsage(accessToken, proxyOptions = null) { if (!accessToken) { return { message: "Qoder usage unavailable: no access token" }; diff --git a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js index 24e9f875..0f2c1ce1 100644 --- a/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js +++ b/src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.js @@ -207,6 +207,24 @@ export function parseQuotaData(provider, data) { } break; + case "vercel-ai-gateway": + // Vercel returns currency credit balance, not request quotas. + // The 'Remaining (USD)' row needs explicit remainingPercentage because + // its used/total values would otherwise compute the wrong direction + // (e.g. used=95.5 / total=100 → 4% instead of 96%). + if (data.quotas) { + Object.entries(data.quotas).forEach(([name, quota]) => { + normalizedQuotas.push({ + name, + used: quota.used || 0, + total: quota.total || 0, + resetAt: quota.resetAt || null, + remainingPercentage: quota.remainingPercentage, + }); + }); + } + break; + default: // Generic fallback for unknown providers if (data.quotas) { diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js index c840eb53..becd5473 100644 --- a/src/shared/constants/providers.js +++ b/src/shared/constants/providers.js @@ -80,7 +80,7 @@ export const APIKEY_PROVIDERS = { "xiaomi-tokenplan": { id: "xiaomi-tokenplan", alias: "xmtp", name: "Xiaomi MiMo (Token Plan)", icon: "smart_toy", color: "#FF6700", textIcon: "XT", website: "https://mimo.xiaomi.com", notice: { text: "Xiaomi MiMo Token Plan subscription (API key starts with tp-). Token Plan keys are cluster-specific — select the region matching your subscription.", apiKeyUrl: "https://mimo.xiaomi.com" }, hasProviderSpecificData: true, regions: [{ id: "sgp", label: "Singapore", baseUrl: "https://token-plan-sgp.xiaomimimo.com/v1" }, { id: "cn", label: "China", baseUrl: "https://token-plan-cn.xiaomimimo.com/v1" }, { id: "ams", label: "Europe", baseUrl: "https://token-plan-ams.xiaomimimo.com/v1" }], defaultRegion: "sgp" }, "volcengine-ark": { id: "volcengine-ark", alias: "ark", name: "Volcengine Ark", icon: "cloud", color: "#1677FF", textIcon: "ARK", website: "https://ark.cn-beijing.volces.com", notice: { apiKeyUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey" } }, openai: { id: "openai", alias: "openai", name: "OpenAI", icon: "auto_awesome", color: "#10A37F", textIcon: "OA", website: "https://platform.openai.com", notice: { apiKeyUrl: "https://platform.openai.com/api-keys" }, serviceKinds: ["llm", "embedding", "tts", "stt", "image", "imageToText", "webSearch"], thinkingConfig: THINKING_CONFIG.effort, searchViaChat: { defaultModel: "gpt-4o-mini", pricingUrl: "https://openai.com/api/pricing" }, ttsConfig: { baseUrl: "https://api.openai.com/v1/audio/speech", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "tts-1", name: "TTS-1" }, { id: "tts-1-hd", name: "TTS-1 HD" }, { id: "gpt-4o-mini-tts", name: "GPT-4o Mini TTS" }] }, sttConfig: { baseUrl: "https://api.openai.com/v1/audio/transcriptions", authType: "apikey", authHeader: "bearer", format: "openai", models: [{ id: "whisper-1", name: "Whisper 1" }, { id: "gpt-4o-transcribe", name: "GPT-4o Transcribe" }, { id: "gpt-4o-mini-transcribe", name: "GPT-4o Mini Transcribe" }] }, embeddingConfig: { baseUrl: "https://api.openai.com/v1/embeddings", authType: "apikey", authHeader: "bearer", models: [{ id: "text-embedding-3-small", name: "Text Embedding 3 Small", dimensions: 1536 }, { id: "text-embedding-3-large", name: "Text Embedding 3 Large", dimensions: 3072 }, { id: "text-embedding-ada-002", name: "Text Embedding Ada 002", dimensions: 1536 }] } }, - "vercel-ai-gateway": { id: "vercel-ai-gateway", alias: "vercel", name: "Vercel AI Gateway", icon: "deployed_code", color: "#111827", textIcon: "VG", website: "https://vercel.com/ai-gateway", notice: { text: "Unified OpenAI-compatible endpoint from Vercel. Use your AI Gateway API key, then pick models with provider/model IDs like anthropic/claude-sonnet-4.6 or openai/gpt-5.4.", apiKeyUrl: "https://vercel.com/dashboard/~/ai-gateway" }, passthroughModels: true, serviceKinds: ["llm"] }, + "vercel-ai-gateway": { id: "vercel-ai-gateway", alias: "vercel", name: "Vercel AI Gateway", icon: "deployed_code", color: "#111827", textIcon: "VG", website: "https://vercel.com/ai-gateway", notice: { text: "Unified OpenAI-compatible endpoint from Vercel. Use your AI Gateway API key, then pick models with provider/model IDs like anthropic/claude-sonnet-4.6 or openai/gpt-5.4.", apiKeyUrl: "https://vercel.com/dashboard/~/ai-gateway" }, passthroughModels: true, serviceKinds: ["llm", "embedding", "image", "imageToText", "webSearch"], searchViaChat: { defaultModel: "openai/gpt-4o-mini", pricingUrl: "https://vercel.com/docs/ai-gateway/pricing" }, modelsFetcher: { url: "https://ai-gateway.vercel.sh/v1/models", type: "openai" } }, anthropic: { id: "anthropic", alias: "anthropic", name: "Anthropic", icon: "smart_toy", color: "#D97757", textIcon: "AN", website: "https://console.anthropic.com", notice: { apiKeyUrl: "https://console.anthropic.com/settings/keys" }, serviceKinds: ["llm", "imageToText"] }, "opencode-go": { id: "opencode-go", alias: "ocg", name: "OpenCode Go", icon: "terminal", color: "#E87040", textIcon: "OC", website: "https://opencode.ai/auth", notice: { text: "OpenCode Go subscription: $5/mo (then $10/mo). Access to Kimi, GLM, Qwen, MiMo, MiniMax models.", apiKeyUrl: "https://opencode.ai/auth" } }, azure: { id: "azure", alias: "azure", name: "Azure OpenAI", icon: "cloud", color: "#0078D4", textIcon: "AZ", website: "https://azure.microsoft.com/en-us/products/ai-services/openai-service", notice: { apiKeyUrl: "https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI" }, hasProviderSpecificData: true }, @@ -278,6 +278,7 @@ export const USAGE_SUPPORTED_PROVIDERS = [ "glm-cn", "minimax", "minimax-cn", + "vercel-ai-gateway", ]; // Subset that uses apikey auth (still surfaced on quota page) @@ -286,4 +287,5 @@ export const USAGE_APIKEY_PROVIDERS = [ "glm-cn", "minimax", "minimax-cn", + "vercel-ai-gateway", ]; diff --git a/tests/unit/embeddingsCore.test.js b/tests/unit/embeddingsCore.test.js index 6450e4bb..62239ed5 100644 --- a/tests/unit/embeddingsCore.test.js +++ b/tests/unit/embeddingsCore.test.js @@ -222,6 +222,21 @@ describe("buildEmbeddingsUrl", () => { expect(url).toBe("https://openrouter.ai/api/v1/embeddings"); }); + it("vercel-ai-gateway → https://ai-gateway.vercel.sh/v1/embeddings", async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeProviderResponse(VALID_EMBEDDING_RESPONSE)); + + await handleEmbeddingsCore(makeOptions({ + modelInfo: { provider: "vercel-ai-gateway", model: "openai/text-embedding-3-small" }, + credentials: { apiKey: "vag-test-key" }, + })); + + const [url, init] = vi.mocked(fetch).mock.calls[0]; + const sent = JSON.parse(init.body); + expect(url).toBe("https://ai-gateway.vercel.sh/v1/embeddings"); + expect(init.headers.Authorization).toBe("Bearer vag-test-key"); + expect(sent.model).toBe("openai/text-embedding-3-small"); + }); + it("openai-compatible-* → uses baseUrl from providerSpecificData", async () => { vi.mocked(fetch).mockResolvedValueOnce(makeProviderResponse(VALID_EMBEDDING_RESPONSE)); diff --git a/tests/unit/image-generation.test.js b/tests/unit/image-generation.test.js index d74fea97..e5504aec 100644 --- a/tests/unit/image-generation.test.js +++ b/tests/unit/image-generation.test.js @@ -263,6 +263,38 @@ describe("handleImageGenerationCore", () => { ); }); + it("handles Vercel AI Gateway image generation as OpenAI-compatible", async () => { + global.fetch.mockResolvedValueOnce( + new Response( + JSON.stringify({ + created: 1234567890, + data: [{ url: "https://example.com/vercel-image.png" }], + }), + { status: 200, headers: { "Content-Type": "application/json" } } + ) + ); + + const result = await handleImageGenerationCore({ + body: { prompt: "A watercolor castle", n: 1, size: "1024x1024" }, + modelInfo: { provider: "vercel-ai-gateway", model: "openai/gpt-image-1" }, + credentials: { apiKey: "vag-test-key" }, + log: null, + }); + + expect(result.success).toBe(true); + expect(global.fetch).toHaveBeenCalledWith( + "https://ai-gateway.vercel.sh/v1/images/generations", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer vag-test-key", + }), + body: expect.stringContaining('"model":"openai/gpt-image-1"'), + }) + ); + }); + it("handles HuggingFace binary response", async () => { const imageBuffer = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); // PNG header global.fetch.mockResolvedValueOnce(