Install
openclaw skills install @byungkyu/vercel-ai-gatewayVercel AI Gateway API integration with managed authentication. Browse the model catalog across 30+ providers, inspect per-provider endpoints, pricing, and uptime, check credit balance, look up generation usage, and run OpenAI/Anthropic-compatible inferences (chat completions, responses, embeddings).
openclaw skills install @byungkyu/vercel-ai-gatewayAccess the Vercel AI Gateway API with managed authentication. Vercel AI Gateway is a unified inference proxy that exposes models from many providers behind a single OpenAI-compatible API, with observability, credits, and automatic provider failover.
This is not the Vercel platform API. AI Gateway proxies ai-gateway.vercel.sh and deals with models and inference. For projects, deployments, domains, and environment variables, use the separate vercel skill (api.vercel.com).
# List every available model
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://api.maton.ai/vercel-ai-gateway/v1/models')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
data = json.load(urllib.request.urlopen(req))
print(f'{len(data["data"])} models')
for m in data['data'][:5]:
print(f' {m["id"]:45} {m["type"]:10} ctx={m["context_window"]}')
EOF
https://api.maton.ai/vercel-ai-gateway/{native-api-path}
Maton proxies requests to ai-gateway.vercel.sh and automatically injects your AI Gateway credential.
The native API version prefix is /v1, so a full path looks like https://api.maton.ai/vercel-ai-gateway/v1/models. The /v1 prefix is required — requests without it return 404 with an HTML body (content-type: text/html) rather than the usual JSON error, which will fail JSON parsing before you ever see the status code.
All requests require the Maton API key in the Authorization header:
Authorization: Bearer $MATON_API_KEY
Environment Variable: Set your API key as MATON_API_KEY:
export MATON_API_KEY="YOUR_API_KEY"
Natively, AI Gateway accepts either an AI Gateway API key (Authorization: Bearer <AI_GATEWAY_API_KEY>) or a Vercel OIDC token. Through Maton you never handle either one — the gateway injects the credential stored on the connection. The connection method for this app is API_KEY, so there is no OAuth browser step.
Manage your Vercel AI Gateway connections at https://api.maton.ai.
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://api.maton.ai/connections?app=vercel-ai-gateway&status=ACTIVE')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
python <<'EOF'
import urllib.request, os, json
data = json.dumps({'app': 'vercel-ai-gateway'}).encode()
req = urllib.request.Request('https://api.maton.ai/connections', data=data, method='POST')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
req.add_header('Content-Type', 'application/json')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://api.maton.ai/connections/{connection_id}')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
Response:
{
"connection": {
"connection_id": "{connection_id}",
"status": "ACTIVE",
"creation_time": "2026-08-04T07:20:53.488460Z",
"last_updated_time": "2026-08-04T20:03:32.593153Z",
"url": "https://connect.maton.ai/?session_token=...",
"app": "vercel-ai-gateway",
"method": "API_KEY",
"metadata": {}
}
}
Open the returned url in a browser to supply your AI Gateway API key.
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://api.maton.ai/connections/{connection_id}', method='DELETE')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
If you have multiple Vercel AI Gateway connections, specify which one to use with the Maton-Connection header:
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://api.maton.ai/vercel-ai-gateway/v1/credits')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
req.add_header('Maton-Connection', '{connection_id}')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
If you have multiple connections, always include this header to ensure requests go to the intended account. An unknown connection ID returns 404 with a Maton-shaped error ({"message": "Connection \...` not found...", "type": "Not Found", "code": 404}`), not an AI Gateway error.
/v1/chat/completions, /v1/responses, /v1/messages, and /v1/embeddings draws down credits or bills the card on file. Confirm the model and approximate request size with the user before running inference in a loop, over a large batch, or with a high max_tokens./v1/models/{creator}/{model} to check pricing before sending traffic to an unfamiliar model. Prices vary by more than 1000x across the catalog.The API splits into two families: catalog and observability (read-only, free) and inference (billed).
GET /vercel-ai-gateway/v1/models
Response (315 models across 34 providers at time of testing):
{
"object": "list",
"data": [
{
"id": "alibaba/qwen-3-14b",
"object": "model",
"created": 1755815280,
"released": 1745798400,
"owned_by": "alibaba",
"name": "Qwen3-14B",
"description": "Qwen3 is the latest generation of large language models in Qwen series...",
"context_window": 40960,
"max_tokens": 16384,
"type": "language",
"tags": ["reasoning", "tool-use"],
"supported_specifications": ["v2", "v3", "v4"],
"modalities": { "input": ["text"], "output": ["text"] },
"supported_parameters": ["max_tokens", "temperature", "stop", "tools", "tool_choice", "reasoning", "include_reasoning"],
"temperature": true,
"reasoning_options": [{ "type": "toggle" }],
"knowledge": "2025-04",
"pricing": { "input": "0.00000012", "output": "0.00000024" }
}
]
}
This endpoint takes no working parameters. ?limit= and ?type= are accepted without error but silently ignored — the full catalog comes back every time. Filter client-side:
python <<'EOF'
import urllib.request, os, json, collections
req = urllib.request.Request('https://api.maton.ai/vercel-ai-gateway/v1/models')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
models = json.load(urllib.request.urlopen(req))['data']
print(collections.Counter(m['type'] for m in models).most_common())
# Cheapest language models that support tool use.
# Guard on pricing.input — it is absent for some models (see Pricing Shapes below).
lang = [m for m in models
if m['type'] == 'language'
and 'tool-use' in m.get('tags', [])
and 'input' in m['pricing']]
for m in sorted(lang, key=lambda m: float(m['pricing']['input']))[:5]:
print(f' {m["id"]:45} ${float(m["pricing"]["input"]) * 1e6:.3f}/M in')
EOF
pricing is always present but its keys depend on the model type, and per-token input/output are not universal. Code that reads m['pricing']['input'] unconditionally will raise KeyError on 65 of the 315 models. Observed coverage:
| Type | Typical keys | Notes |
|---|---|---|
language (208) | input, output | 205/208 have both; input_cache_read 139, web_search 70, regional 42, input_tiers/output_tiers 35 |
embedding (26) | input | all 26 |
image (32) | image (21) or image_dimension_quality_pricing (9) | only 5 have input |
video (30) | video_duration_pricing (28) or video_token_pricing (2) | none have input/output |
transcription (5) | input, transcription_duration_cost_per_second | |
speech (3) | input, speech_input_character_cost | |
realtime (6) | input/output (4), audio_input_token_cost, realtime_session_duration_cost_per_second | |
reranking (5) | input (2 of 5) | 3 have an empty pricing object |
The three perplexity/sonar* language models have "pricing": {} — an empty object, since Perplexity bills per request tier rather than per token. Always check membership before arithmetic:
def input_price_per_million(model):
"""USD per 1M input tokens, or None when the model is not priced per token."""
price = model['pricing'].get('input')
return float(price) * 1e6 if price is not None else None
Tiered pricing (input_tiers) replaces the flat rate with context-dependent brackets:
"input_tiers": [
{ "cost": "0.0000013", "min": 0, "max": 128000 },
{ "cost": "0.000002", "min": 128000 }
]
GET /vercel-ai-gateway/v1/models/{creator}/{model}
Returns a single model object with the same shape as a list entry. Example:
GET /vercel-ai-gateway/v1/models/anthropic/claude-haiku-4.5
{
"id": "anthropic/claude-haiku-4.5",
"object": "model",
"owned_by": "anthropic",
"name": "Claude Haiku 4.5",
"context_window": 200000,
"max_tokens": 64000,
"type": "language",
"tags": ["explicit-caching", "file-input", "reasoning", "tool-use", "vision", "web-search"],
"regions": ["eu", "us"],
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"reasoning_options": [{ "type": "toggle" }, { "type": "budget_tokens", "min": 1024 }],
"knowledge": "2025-02-28",
"interleaved": true,
"pricing": {
"input": "0.000001",
"output": "0.000005",
"input_cache_read": "0.0000001",
"input_cache_write": "0.00000125",
"regional": {
"us": { "input": "0.0000011", "output": "0.0000055", "input_cache_read": "0.00000011", "input_cache_write": "0.000001375" },
"eu": { "input": "0.0000011", "output": "0.0000055", "input_cache_read": "0.00000011", "input_cache_write": "0.000001375" }
}
}
}
This single-model route is not in the published REST reference but works.
Only these fields are present on every model (verified across all 315): id, object, created, released, owned_by, name, description, type, supported_specifications, modalities, pricing. Everything else is conditional — read it with .get():
| Field | Coverage | Absent when |
|---|---|---|
context_window, max_tokens | 307/315 | transcription and speech models |
tags | 248/315 | model has no feature tags |
supported_parameters, temperature | 214/315 | non-text model types |
knowledge | 149/315 | training cutoff not published |
reasoning_options | 110/315 | model has no reasoning mode |
regions | 45/315 | no regional routing |
video_capabilities | 30/315 | not a video model |
interleaved | 13/315 | no interleaved thinking |
deprecated_at | 1/315 | model is not scheduled for removal |
deprecated_at is a millisecond epoch. Check for it before pinning a model in production code — exactly one model in the catalog carried it during testing (openai/gpt-5.3-chat).
video models carry a video_capabilities object describing supported operations, resolutions, aspect ratios, durations, fps, and input limits:
"video_capabilities": {
"supported_operations": ["text-to-video"],
"supported_resolutions": ["480p", "720p", "1080p"],
"supported_aspect_ratios": ["16:9", "9:16", "1:1", "4:3", "3:4"],
"supported_durations_seconds": [5, 10],
"generate_audio": true,
"supported_fps": [24],
"input_limits": { "text": { "max_chars": 1500 } }
}
GET /vercel-ai-gateway/v1/models/{creator}/{model}/endpoints
Shows every upstream provider that can serve a model, with per-provider pricing, limits, uptime, and latency — this is how you tell why two providers of the "same" model behave differently.
{
"data": {
"id": "anthropic/claude-haiku-4.5",
"name": "Claude Haiku 4.5",
"architecture": {
"tokenizer": null,
"instruct_type": null,
"modality": "text+image+file→text",
"input_modalities": ["text", "image", "file"],
"output_modalities": ["text"]
},
"reasoning": { "mandatory": false, "supports_max_tokens": true },
"endpoints": [
{
"name": "anthropic | anthropic/claude-haiku-4.5",
"model_name": "Claude Haiku 4.5",
"provider_name": "anthropic",
"context_length": 200000,
"max_completion_tokens": 64000,
"max_prompt_tokens": null,
"quantization": null,
"supported_parameters": ["max_tokens", "temperature", "stop", "tools", "tool_choice", "reasoning", "include_reasoning"],
"tags": ["explicit-caching", "file-input", "reasoning", "tool-use", "vision", "web-search"],
"pricing": {
"prompt": "0.000001",
"completion": "0.000005",
"request": "0",
"image": "0",
"image_output": "0",
"web_search": "0",
"internal_reasoning": "0",
"input_cache_read": "0.0000001",
"input_cache_write": "0.00000125",
"discount": 0
},
"status": 0,
"supports_implicit_caching": false,
"uptime_last_15m": 99.8486,
"uptime_last_1h": 99.8806,
"uptime_last_1d": 99.9745,
"latency_last_1h": { "p50": 817, "p95": 2305.85 },
"throughput_last_1h": { "p50": 93.5, "p95": 105.05 }
}
]
}
}
Shape differences from /v1/models to watch for:
data is an object here, not an array.prompt/completion; model pricing uses input/output for the same values.capabilities, not video_capabilities.reasoning is present only for models with a reasoning mode ({"mandatory": bool, "supports_max_tokens": bool} — supports_max_tokens itself is conditional). It is absent for models like anthropic/claude-opus-5 and openai/gpt-4o-mini.context_length, tags, max_completion_tokens, and inference_regions are conditional; the rest of the endpoint object is always present.latency_last_1h (ms) and throughput_last_1h (tokens/sec) each carry p50 and p95. Together with the uptime fields these are live operational metrics — good for provider selection, but they change between calls, so do not snapshot them as facts.
This route works for every model type, not just language — embedding, image, video, speech, transcription, reranking, and realtime models all return endpoint lists.
Provider counts vary widely, which is the point of the route: anthropic/claude-opus-5 is served by 4 (anthropic, bedrock, claudeaws, vertexAnthropic), openai/gpt-4o-mini by 2 (azure, openai), and alibaba/qwen-3-14b by 1 (deepinfra).
An unknown model returns 404 with model_not_found:
GET /vercel-ai-gateway/v1/models/nosuch/nomodel/endpoints
# 404 {"error": {"message": "...", "type": "model_not_found"}}
GET /vercel-ai-gateway/v1/credits
{ "balance": "4.99999992", "total_used": "0.00000008" }
Both values are decimal strings in USD, not numbers — parse before comparing (float(r["balance"])). They carry full sub-cent precision (8 decimal places observed), so never round them for accounting.
balance is "0" on an account with no card on file, and jumps to the free-credit grant ("5") once one is added. A "0" balance alongside a 403 on inference is the signature of the card gate, not of exhausted credits.
GET /vercel-ai-gateway/v1/generation?id={generation_id}
Looks up cost and token usage for one completed request. Generation IDs have the form gen_<ulid> and come from:
id field on a /v1/chat/completions, /v1/responses, or /v1/messages responsegenerationId on the same responsesid on every chunk of a streaming responseprovider_metadata.gateway.generationId (or providerMetadata... on /v1/embeddings and /v1/responses)Response:
{
"data": {
"id": "gen_01KZ7DPJ3VGJXG6NF1WEJWE03M",
"created_at": "2026-08-04T22:18:25.000Z",
"model": "inclusionai/ling-3.0-flash-free",
"provider_name": "novita",
"streamed": false,
"finish_reason": "length",
"total_cost": 0,
"upstream_inference_cost": 0,
"usage": 0,
"is_byok": false,
"latency": 963,
"generation_time": 963,
"tokens_prompt": 21,
"tokens_completion": 10,
"native_tokens_prompt": 21,
"native_tokens_completion": 10,
"native_tokens_reasoning": 0,
"native_tokens_cached": 0,
"native_tokens_cache_creation": 0,
"billable_web_search_calls": 0
}
}
This route uses different field names from the inline usage object on an inference response — tokens_prompt/tokens_completion, plus native_tokens_* for provider-reported counts (see Cross-Route Differences). Costs here are numbers, unlike the strings from /v1/credits.
Usage events are ingested asynchronously. Immediately after a request, this endpoint returns 404 Usage event not found — that is expected, not an error. Measured ingestion delay on a non-streamed completion was ~9 seconds (404 at 0s, 3s, and 6s; 200 at 9s), so poll with a short backoff rather than treating the first 404 as failure:
python <<'EOF'
import urllib.request, urllib.error, os, json, time
gen_id = 'gen_REPLACE_WITH_REAL_ID'
url = f'https://api.maton.ai/vercel-ai-gateway/v1/generation?id={gen_id}'
for attempt in range(6):
req = urllib.request.Request(url)
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
try:
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
break
except urllib.error.HTTPError as e:
if e.code != 404:
raise
print(f'not ingested yet (attempt {attempt + 1})')
time.sleep(2 ** attempt)
EOF
Error responses on this route are flat ({"error": "..."}), unlike the nested {"error": {"message", "type"}} used elsewhere:
| Request | Status | Body |
|---|---|---|
| valid-format ID, no event yet | 404 | {"error":"Usage event not found","id":"gen_...","message":"No usage event found with ID gen_..."} |
id omitted | 400 | {"error":"id is required"} |
id=notaulid | 400 | {"error":"Invalid generation ID format. Expected format: gen_<ulid>"} |
GET /vercel-ai-gateway/v1/report?start_date=2026-08-01&end_date=2026-08-04
Aggregated spend over a date range.
Requires a paid Vercel plan — this is a separate gate from having a credit card on file. On an account with a valid card and a positive credit balance, where all four inference routes and every other endpoint return 200, this route still returns 403:
{ "error": { "message": "Spend report access requires a paid plan. Please upgrade your plan to use this feature.", "type": "forbidden" } }
The plan check runs before parameter validation, so an error from this route says nothing about your query string. This is the one endpoint in this document whose success payload has not been observed; treat its response shape as unverified.
For per-request cost data without a paid plan, use /v1/generation (per generation) or /v1/credits (running total) instead.
All four inference routes are OpenAI/Anthropic-compatible, so existing SDK request bodies work unchanged apart from the model ID, which is always {creator}/{model}.
The same data is named differently on each route. Check this before writing parsing code shared across them:
/chat/completions | /responses | /messages | /embeddings | /generation | |
|---|---|---|---|---|---|
| Token usage | prompt_tokens, completion_tokens | input_tokens, output_tokens | input_tokens, output_tokens | prompt_tokens, total_tokens | tokens_prompt, tokens_completion |
| Reasoning | message.reasoning | output[] item type: "reasoning" | content[] block type: "thinking" | — | native_tokens_reasoning |
| Metadata key | provider_metadata | providerMetadata | provider_metadata | providerMetadata | — |
| Generation ID | id + generationId | id | id | metadata only | data.id |
| Error envelope | {error: {...}} | error: null on success | {type: "error", error: {...}} | {error: {...}} | {error: "string"} |
Two further traps:
usage.cost is a number while provider_metadata.gateway.cost and the /v1/credits fields are strings.provider_metadata.gateway.routing reveals which upstream actually served a request (resolvedProvider, finalProvider, plus a per-attempt log with upstream status codes) — the only way to attribute a response when several providers serve one model.POST /vercel-ai-gateway/v1/chat/completions
Content-Type: application/json
{
"model": "anthropic/claude-haiku-4.5",
"messages": [
{ "role": "user", "content": "Say hello in five words." }
],
"max_tokens": 100
}
Response (trimmed — provider_metadata is large):
{
"id": "gen_01KZ7DP24DBPWN0TR4VAG1P9X6",
"object": "chat.completion",
"created": 1785881890,
"model": "inclusionai/ling-3.0-flash-free",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Hello there, how are you?",
"reasoning": "The user is asking me to say hello in five words...",
"reasoning_details": [
{ "type": "reasoning.text", "text": "...", "format": "unknown", "index": 0 }
],
"provider_metadata": {
"gateway": {
"routing": {
"originalModelId": "inclusionai/ling-3.0-flash-free",
"resolvedProvider": "novita",
"finalProvider": "novita",
"fallbacksAvailable": [],
"modelAttempts": [ { "success": true, "providerAttempts": [ { "provider": "novita", "credentialType": "system", "statusCode": 200 } ] } ]
},
"cost": "0",
"marketCost": "0",
"gatewayCost": "0",
"generationId": "gen_01KZ7DP24DBPWN0TR4VAG1P9X6"
}
}
},
"logprobs": null,
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 26,
"completion_tokens": 36,
"total_tokens": 62,
"cost": 0,
"is_byok": false,
"completion_tokens_details": { "reasoning_tokens": 30, "reasoning_tokens_estimated": true, "image_tokens": 0 },
"prompt_tokens_details": { "cached_tokens": 0, "audio_tokens": 0, "video_tokens": 0 },
"cost_details": { "upstream_inference_cost": null, "upstream_inference_prompt_cost": 0, "upstream_inference_completions_cost": 0 },
"cache_creation_input_tokens": 0,
"market_cost": 0
},
"system_fingerprint": "fp_yyoa7nu1k5",
"generationId": "gen_01KZ7DP24DBPWN0TR4VAG1P9X6"
}
Beyond the standard OpenAI fields:
generationId appears twice — at the top level and under choices[].message.provider_metadata.gateway. Both equal id. Any of the three works for /v1/generation.provider_metadata.gateway.routing shows which upstream actually served the request (resolvedProvider, finalProvider), what fallbacks existed, and a per-attempt log with upstream status codes — this is how you tell which provider produced a given answer.message.reasoning and message.reasoning_details alongside content. usage.completion_tokens_details.reasoning_tokens may carry reasoning_tokens_estimated: true, meaning the count is inferred rather than reported by the provider.usage.cost is a number here, while provider_metadata.gateway.cost is a string.Set "stream": true for a text/event-stream of data: {...} chunks ending in data: [DONE]:
data: {"id":"gen_01KZ7ET9APJCBCY67T3NYMRXTX","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}],...}
data: {"id":"gen_...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"3!"},"finish_reason":null}],...}
data: {"id":"gen_...","choices":[{"index":0,"delta":{"provider_metadata":{...}},"finish_reason":"stop"}],"usage":{...},"generationId":"gen_..."}
data: [DONE]
delta carries only {"role": "assistant"} — no content.reasoning deltas stream before content deltas, both inside delta.usage, provider_metadata, and generationId arrive only on the final chunk, the one carrying finish_reason. Do not expect token counts until then.data: [DONE] is a bare sentinel, not JSON — guard before parsing.POST /vercel-ai-gateway/v1/responses
Content-Type: application/json
{
"model": "openai/gpt-4o-mini",
"input": "Say hello in five words."
}
OpenAI Responses API shape. input is required and accepts a string or a structured array.
Response (trimmed — the full object carries ~35 top-level fields):
{
"id": "gen_01KZ7E72711DTG68QKAB0KWV2B",
"object": "response",
"status": "completed",
"created_at": 1785882447,
"completed_at": 1785882447,
"error": null,
"incomplete_details": null,
"output": [
{
"type": "reasoning",
"id": "rs_1785882447953_oj4ccjeuzzg",
"summary": [],
"content": [ { "type": "reasoning_text", "text": "The user wants me to say hello in exactly five words..." } ]
},
{
"type": "message",
"id": "msg_1785882447953_5p6p388zyoh",
"status": "completed",
"role": "assistant",
"content": [ { "type": "output_text", "text": "Hello, nice to meet you!", "annotations": [], "logprobs": [] } ]
}
],
"usage": {
"input_tokens": 26,
"output_tokens": 251,
"total_tokens": 277,
"input_tokens_details": { "cached_tokens": 0 },
"output_tokens_details": { "reasoning_tokens": 0 }
},
"model": "inclusionai/ling-3.0-flash-free",
"text": { "format": { "type": "text" } },
"temperature": 1,
"top_p": 1,
"tool_choice": "auto",
"tools": [],
"truncation": "disabled",
"service_tier": "auto",
"status": "completed"
}
Traps in this shape:
error is present but null on success. body["error"]["type"] after a check on key presence will crash — check the value.reasoning item before the message item, so the assistant text is not reliably output[0] — filter by type == "message", then read content[].text where type == "output_text".usage.output_tokens_details.reasoning_tokens was 0 even for a response with a visible reasoning item, while output_tokens (251) far exceeded the visible text — do not rely on it to bill reasoning.POST /vercel-ai-gateway/v1/messages
Content-Type: application/json
{
"model": "anthropic/claude-haiku-4.5",
"max_tokens": 100,
"messages": [
{ "role": "user", "content": "Say hello in five words." }
]
}
Anthropic Messages API shape, including its distinct error envelope ({"type": "error", "error": {...}}). max_tokens is required here, unlike on /v1/chat/completions.
Response:
{
"id": "gen_01KZ7EGR3JRF0GM4H07G0DG3CQ",
"type": "message",
"role": "assistant",
"content": [
{ "type": "text", "text": "Hello there, how are you?" },
{ "type": "thinking", "thinking": "The user is asking me to say hello in five words..." }
],
"model": "inclusionai/ling-3.0-flash-free",
"stop_reason": "end_turn",
"stop_sequence": null,
"usage": { "input_tokens": 26, "output_tokens": 36 },
"provider_metadata": { "gateway": { "routing": { "..." : "..." }, "generationId": "gen_01KZ7EGR3JRF0GM4H07G0DG3CQ" } }
}
Differences from Anthropic's native API worth noting:
content ordering is inverted. The text block comes before the thinking block, whereas Anthropic natively emits thinking first. Never assume content[0] is the reasoning block — filter by type.usage carries only input_tokens and output_tokens — no cache or reasoning breakdown.id is a Vercel gen_<ulid>, not an Anthropic msg_... ID, and it works with /v1/generation.provider_metadata key is added, which native Anthropic responses do not have.POST /vercel-ai-gateway/v1/embeddings
Content-Type: application/json
{
"model": "openai/text-embedding-3-small",
"input": ["first string", "second string"]
}
input is required and accepts a string or an array of strings. Only models with "type": "embedding" work here (26 in the catalog).
Response:
{
"object": "list",
"data": [
{ "object": "embedding", "index": 0, "embedding": [0.0121002197265625, -0.022552490234375, 0.02215576171875, "..."] },
{ "object": "embedding", "index": 1, "embedding": ["..."] }
],
"model": "openai/text-embedding-3-small",
"usage": { "prompt_tokens": 4, "total_tokens": 4 },
"providerMetadata": { "gateway": { "..." : "..." } }
}
data entry per input string, ordered by index. openai/text-embedding-3-small returns 1536 dimensions.id on this route, so no generation ID is available in the response body to pass to /v1/generation — it appears only under providerMetadata.gateway.generationId.usage has prompt_tokens and total_tokens but no completion_tokens.Inference passes through three distinct account-state gates. All three were observed live, and each has a different error type — the distinction matters because only one of them is a code problem:
| State | Status | type | Meaning |
|---|---|---|---|
| No card on file | 403 | customer_verification_required | Inference blocked entirely |
| Card on file, free credits | 429 | rate_limit_exceeded | Works, but throttled |
| Paid credits | 200 | — | Unrestricted |
No card on file returns 403 customer_verification_required ("AI Gateway requires a valid credit card on file to service requests...") after auth and routing succeed. This is upstream Vercel account state, not a gateway or connection problem — GET /v1/models and GET /v1/credits return 200 over the same connection throughout. Adding a card unlocks the free credit grant (balance "0" → "5"), but the unlock is not immediate: it propagated roughly 15–30 seconds later, so retry before assuming failure.
Free credits are rate-limited (429 rate_limit_exceeded, "Free tier requests on this model are rate-limited"). Despite the wording, the limit is account-wide, not per-model — switching free models returns the same 429; only waiting clears it. No Retry-After header is sent, and the window took several minutes to reset under light testing. The error body also carries a providerMetadata.gateway.routing object and a generationId even though nothing was billed, and its param duplicates the whole error object rather than naming a request field as it does on 400s.
Schema validation runs before the billing check, so a malformed body returns 400 even on a blocked account and request shapes can be validated for free. But only schema validation precedes it — model resolution does not. An unknown model, a bare model ID with no {creator}/ prefix, and a type mismatch (a language model sent to /v1/embeddings) all return the same 403 rather than model_not_found. A 403 therefore confirms the body parsed, but not that the model ID is valid.
Free models (priced "0", e.g. inclusionai/ling-3.0-flash-free) are gated by the card check too — no zero-cost model bypasses it. They remain the right choice for testing: a full sweep of every endpoint in this document cost $0.00000008.
There is none. No endpoint in this API is paginated:
/v1/models returns the entire catalog in one response and ignores limit/type. Slice and filter client-side./v1/models/{creator}/{model}/endpoints returns all providers for the model at once./v1/credits and /v1/generation return single objects./v1/report is bounded by start_date/end_date rather than by cursor or page.No response contains a next, cursor, offset, or has_more field, and no Link header is returned.
const BASE = 'https://api.maton.ai/vercel-ai-gateway/v1';
const headers = { 'Authorization': `Bearer ${process.env.MATON_API_KEY}` };
// Find the cheapest language model with a large context window
const { data: models } = await fetch(`${BASE}/models`, { headers }).then(r => r.json());
// `pricing.input` is absent on some models, so filter before sorting —
// parseFloat(undefined) is NaN and would silently corrupt the ordering.
const candidates = models
.filter(m => m.type === 'language' && m.context_window >= 200000 && m.pricing.input != null)
.sort((a, b) => parseFloat(a.pricing.input) - parseFloat(b.pricing.input));
console.log(`${candidates.length} candidates, cheapest: ${candidates[0].id}`);
// Inspect which providers can serve it
const { data: detail } = await fetch(
`${BASE}/models/${candidates[0].id}/endpoints`,
{ headers }
).then(r => r.json());
for (const ep of detail.endpoints) {
console.log(` ${ep.provider_name} ctx=${ep.context_length} uptime_1d=${ep.uptime_last_1d}%`);
}
const response = await fetch(
'https://api.maton.ai/vercel-ai-gateway/v1/chat/completions',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.MATON_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: 'anthropic/claude-haiku-4.5',
messages: [{ role: 'user', content: 'Say hello in five words.' }],
max_tokens: 100
})
}
);
const result = await response.json();
if (!response.ok) throw new Error(result.error?.message ?? response.statusText);
console.log(result.choices[0].message.content);
console.log('generation id:', result.id); // gen_<ulid>, for /v1/generation
// Which upstream provider actually served this?
console.log('provider:', result.choices[0].message.provider_metadata?.gateway?.routing?.finalProvider);
console.log('cost:', result.usage.cost, 'tokens:', result.usage.total_tokens);
import os
import requests
BASE = 'https://api.maton.ai/vercel-ai-gateway/v1'
headers = {'Authorization': f'Bearer {os.environ["MATON_API_KEY"]}'}
# Credit balance (strings, not numbers)
credits = requests.get(f'{BASE}/credits', headers=headers).json()
print(f'balance ${float(credits["balance"]):.2f} / used ${float(credits["total_used"]):.2f}')
# Catalog breakdown by type
models = requests.get(f'{BASE}/models', headers=headers).json()['data']
by_type = {}
for m in models:
by_type.setdefault(m['type'], []).append(m)
for t, group in sorted(by_type.items(), key=lambda kv: -len(kv[1])):
print(f'{t:14} {len(group):4}')
# Models that accept image input
vision = [m for m in models if 'image' in m.get('modalities', {}).get('input', [])]
print(f'\n{len(vision)} models accept image input')
import os
import time
import requests
BASE = 'https://api.maton.ai/vercel-ai-gateway/v1'
headers = {
'Authorization': f'Bearer {os.environ["MATON_API_KEY"]}',
'Content-Type': 'application/json',
}
# A free model keeps test runs effectively free; see Account States for the caveats.
resp = requests.post(
f'{BASE}/chat/completions',
headers=headers,
json={
'model': 'inclusionai/ling-3.0-flash-free',
'messages': [{'role': 'user', 'content': 'Say hello in five words.'}],
'max_tokens': 100,
},
)
if resp.status_code == 403:
# Auth and routing succeeded. Either the account needs a card on file,
# or the model ID does not resolve (that check runs after the billing gate).
raise SystemExit(resp.json()['error']['message'])
if resp.status_code == 429:
# Free-tier credits are throttled account-wide; no Retry-After is sent.
raise SystemExit('rate limited — back off for a few minutes or top up credits')
resp.raise_for_status()
result = resp.json()
message = result['choices'][0]['message']
print(message['content'])
if 'reasoning' in message:
print('reasoning:', message['reasoning'][:80])
gateway = message.get('provider_metadata', {}).get('gateway', {})
print('served by:', gateway.get('routing', {}).get('finalProvider'))
print('usage:', result['usage']['total_tokens'], 'tokens, cost', result['usage']['cost'])
# Usage events are ingested asynchronously (~9s observed), so poll with backoff.
gen_id = result['id']
for attempt in range(6):
usage = requests.get(f'{BASE}/generation', headers=headers, params={'id': gen_id})
if usage.status_code == 200:
d = usage.json()['data']
# Note the distinct field names on this route.
print(f'{d["provider_name"]}: {d["tokens_prompt"]}+{d["tokens_completion"]} tokens, '
f'${d["total_cost"]}, {d["latency"]}ms')
break
time.sleep(2 ** attempt)
else:
print(f'usage for {gen_id} not ingested yet')
{creator}/{model} (e.g. anthropic/claude-haiku-4.5). A bare claude-haiku-4.5 will not resolve. Validate IDs against /v1/models rather than by sending inference — model resolution happens after the billing check, so an invalid ID can surface as a 403.language 208, image 32, video 30, embedding 26, realtime 6, reranking 5, transcription 5, speech 3. The last three types are absent from the published reference, so switch on type defensively./v1/models ignores limit and type — both return 200 with the full catalog. There is no server-side filtering or pagination anywhere in this API./v1/credits returns strings too. Never compare them as strings. pricing keys vary by model type and input is not universal — see Pricing Shapes.context_window, max_tokens, tags, knowledge, and others are conditional and omitted rather than null. Always use .get(), and check for deprecated_at before hardcoding a model ID./endpoints rather than assuming the catalog's top-level context_window applies to whichever provider you are routed to. Uptime and latency fields there are live data and change between calls.400 is about your request body while a 403 is about the account or an unresolvable model.405 with a completely empty body, and the /v1 prefix is mandatory (GET /vercel-ai-gateway/models returns 404 with an HTML body). Neither is parseable as JSON.curl -g is not normally needed. If you do build a URL containing brackets, pass curl -g to disable glob parsing.jq or other commands, environment variables like $MATON_API_KEY may not expand correctly in some shell environments. You may get "Invalid API key" errors when piping.| Status | Meaning |
|---|---|
| 400 | Invalid request body or query — missing messages/input/max_tokens, malformed JSON, or a bad generation ID format |
| 401 | Invalid or missing Maton API key |
| 403 | customer_verification_required (inference needs a card on file) or forbidden (/v1/report needs a paid plan — a card is not enough) |
| 404 | Unknown model on /v1/models/... (model_not_found), unknown path under /v1 (not_found_error), a path missing the /v1 prefix (HTML body), unknown Maton-Connection, or a generation whose usage event has not been ingested yet |
| 405 | Wrong method for the route (POST/DELETE /v1/models, GET /v1/chat/completions) — returns an empty body |
| 429 | rate_limit_exceeded — free-tier credits are throttled account-wide; no Retry-After header is sent |
| 4xx/5xx | Passthrough error from the Vercel AI Gateway API |
403 customer_verification_required — jointly caused by account state and model IDs, since resolution happens after the billing check:
GET /v1/models and GET /v1/credits both return 200. If they do, auth and routing are fine and the problem is upstream./v1/models — a typo'd or unprefixed name produces this same error.Do not recreate the Maton connection for this error; a new connection behaves identically.
429 rate_limit_exceeded — free credits are throttled account-wide, so switching models does not help. No Retry-After is sent, so back off manually (several minutes under light testing) or top up with paid credits.
404 on /v1/generation — almost always timing, not a bad ID. Confirm the ID matches gen_<ulid> (a 400 means the format is wrong), then retry with backoff while the usage event is ingested.
MATON_API_KEY environment variable is set:echo $MATON_API_KEY
python <<'EOF'
import urllib.request, os, json
req = urllib.request.Request('https://api.maton.ai/connections')
req.add_header('Authorization', f'Bearer {os.environ["MATON_API_KEY"]}')
print(json.dumps(json.load(urllib.request.urlopen(req)), indent=2))
EOF
vercel-ai-gateway. For example:https://api.maton.ai/vercel-ai-gateway/v1/modelshttps://api.maton.ai/v1/modelsvercel, which proxies api.vercel.com for projects and deployments. https://api.maton.ai/vercel/v1/models will not reach the AI Gateway.