feat(vercel-ai-gateway): support embeddings, images and credit usage

Extend Vercel AI Gateway beyond chat: add OpenAI-compatible embeddings
and image generation endpoints, credit balance fetch on the usage
dashboard, retry on 429, and models catalog fetcher.

Thinking/reasoning mapping is omitted pending a project-wide refactor.

Co-authored-by: Ngô Tấn Tài <tantai@newnol.io.vn>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ngô Tấn Tài
2026-06-13 10:54:51 +07:00
committed by decolua
parent d9b030011f
commit b33cbb0280
10 changed files with 164 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
];

View File

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

View File

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