Use the Gemini API with a scoped key and a spend cap
Point the Google Gen AI SDK at https://relay.keylayer.dev with a Keylayer key to put a hard or estimated spend cap on the Gemini API per UTC hour, day, week or month. Only the base URL and the API key change: Keylayer swaps in your Gemini API key and forwards each request to generativelanguage.googleapis.com with the same method, path and query. A request can run for up to 900 seconds.
Set up
Add your Gemini API key as a credential with Provider set to Google AI, then mint a Keylayer key from it; the quickstart walks through both. Create the provider key in Google AI Studio and restrict it to the Generative Language API only.
Put the Keylayer key in an environment variable:
export KEYLAYER_KEY=kl_k_YOUR_KEYThe SDKs add the API version and model path themselves, such as /v1beta/models/gemini-3.8-flash:generateContent, so the base URL is the bare relay host:
import os
from google import genai
from google.genai import types
client = genai.Client(
api_key=os.environ["KEYLAYER_KEY"],
http_options=types.HttpOptions(base_url="https://relay.keylayer.dev"),
)
response = client.models.generate_content(
model="gemini-3.8-flash",
contents="Write a haiku about rate limits.",
config=types.GenerateContentConfig(max_output_tokens=1024),
)
print(response.text)import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({
apiKey: process.env.KEYLAYER_KEY,
httpOptions: { baseUrl: "https://relay.keylayer.dev" },
});
const response = await ai.models.generateContent({
model: "gemini-3.8-flash",
contents: "Write a haiku about rate limits.",
config: { maxOutputTokens: 1024 },
});
console.log(response.text);curl https://relay.keylayer.dev/v1beta/models/gemini-3.8-flash:generateContent \
-H "x-goog-api-key: $KEYLAYER_KEY" \
-H "Content-Type: application/json" \
-d '{"contents": [{"parts": [{"text": "Write a haiku about rate limits."}]}], "generationConfig": {"maxOutputTokens": 1024}}'Both SDKs also read GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY, so a client created with no base URL or key switches over with two variables:
export GOOGLE_GEMINI_BASE_URL="https://relay.keylayer.dev"
export GEMINI_API_KEY="$KEYLAYER_KEY"If GOOGLE_API_KEY is set too, the SDKs use it instead of GEMINI_API_KEY, so unset it, and leave Vertex AI mode off: Keylayer reaches the Gemini API, not Vertex AI. The SDKs send the key in the x-goog-api-key header, which the relay reads. A Keylayer key in a key= query parameter gets 400 token_in_query instead. Keylayer drops the x-goog-user-project, x-goog-quota-user, x-goog-request-params and x-goog-fieldmask headers.
Spend caps on Gemini
Keylayer prices Gemini requests at the Gemini API's standard prices, and the Priority tier at 1.8 times them. What a request reserves depends on its method:
| Request | Hard cap | Estimated cap |
|---|---|---|
models/MODEL:generateContent or :streamGenerateContent |
Worst-case cost | Worst-case cost |
models/MODEL:embedContent or :batchEmbedContents |
Input cost | Input cost |
:countTokens, uploads to /upload/v1beta/files, and any GET, HEAD, DELETE or OPTIONS |
$0.000001 | $0.000001 |
:batchGenerateContent, :asyncBatchEmbedContent, :predictLongRunning, File Search :importFile and :uploadToFileSearchStore, and a POST or PATCH to cachedContents |
Refused (403) | Refused (403) |
POST files, files:register or fileSearchStores, or a batch's :cancel |
Refused (403) | $0.000001 |
| Any other POST or PATCH | Refused (403) | Refused (403) |
The worst case counts about one token per byte of the request body, plus a fixed count per inline image, and for output maxOutputTokens (65,536 tokens without it) plus any thinkingBudget you set, where -1 counts as 65,536, for each of candidateCount candidates. For gemini-3.8-flash, the example above reserves about $0.004, and leaving out maxOutputTokens reserves about $0.25 per request in flight. From January 1, 2027, Keylayer prices gemini-3.8-flash at twice today's rates, so both figures double. When a generate call ends, Keylayer charges the cost from the usageMetadata Google reports and frees the rest. A cancelled request or a 504 or 524 timeout is charged the full reservation; any other 5xx error is free. Embedding calls report no cost, so they're charged their full reservation.
Under a hard cap, Keylayer refuses with 403 unpriced_operation any request it can't bound up front: models missing from its price table, fileData parts, inline audio, video or PDF data, more than 3,600 inline images, a candidateCount above 8, image or audio output, speechConfig, cachedContent, a long-decode continuationToken, a serviceTier other than standard, built-in tools such as Google Search, code execution and URL context. If Google reports a tier Keylayer can't price, Keylayer suspends the key.
Under an estimated cap, those requests go through, except a model missing from the price table and a body Keylayer can't read, such as one with a duplicate key, which get 403 unpriced_operation under either cap. Keylayer reserves a quarter token per byte of the body at the model's input price, plus $0.01, then charges the reported usage when it can price it, or else that reservation, so spend can pass the cap. A request whose only built-in tools are code execution and URL context, which Google bills as tokens, and that doesn't finish, such as a stream cut short after your client hangs up, or that ends without usage Keylayer can price, is charged its reservation plus the worst case it would have without those tools, or plus the usage a cut stream reported if that's more, unless something else in it, such as cachedContent, leaves that unknown. With escape hatches on, a model missing from the table goes through at the highest input price in it, $4 per million tokens. Keylayer never rewrites Gemini requests, and streams carry their own usage. Under either cap, Keylayer also refuses with 403 unpriced_operation a POST or PATCH whose query has anything but key and an alt of json or sse, since Google reads other query parameters as request fields. Any key, with or without a cap, gets 400 method_override for a $httpMethod query parameter, which makes Google run the call the path names whatever the HTTP method.
Blocked routes
These Gemini API routes return 403 path_not_allowed under v1, v1beta and v1alpha:
tunedModels/**, model tuningcorpora/*/permissions/**, sharing for semantic retrieval corporaauth_tokens/**, ephemeral tokens that reach the Live API without Keylayercredentials/**,webhooks/**andtriggers/**
Google manages API keys at apikeys.googleapis.com, which Keylayer never reaches. Path blocks are a backstop: restricting the provider key to the Generative Language API is what limits it. Escape hatches lift the block for one key; the key limits page explains how.
Errors
When Keylayer refuses a request, it answers in Google's error format, with the code in an ErrorInfo detail with the domain keylayer and in a keylayer-error header:
HTTP/1.1 403 Forbidden
content-type: application/json
cache-control: no-store
keylayer-error: budget_exceeded
retry-after: 3600
{
"error": {
"code": 403,
"message": "Keylayer budget_exceeded: this key's spend cap is reached; retry after 60m.",
"status": "PERMISSION_DENIED",
"details": [
{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"reason": "BUDGET_EXCEEDED",
"domain": "keylayer"
}
]
}
}Refusals before Keylayer knows the provider, such as 401 missing_token and invalid_token, have a plain body like {"error": "invalid_token"} instead, with the same keylayer-error header.
The Python SDK raises errors.ClientError on 4xx codes, with the status in error.code and the response headers in error.response. The Node.js SDK raises ApiError with the status and the response body as its message, but no headers. Errors from Google itself pass through with no keylayer-error header:
from google.genai import errors
try:
response = client.models.generate_content(
model="gemini-3.8-flash",
contents="Write a haiku about rate limits.",
config=types.GenerateContentConfig(max_output_tokens=1024),
)
except errors.APIError as error:
reason = error.response.headers.get("keylayer-error")
if reason is None:
raise
print(f"Keylayer refused the request: {reason} (HTTP {error.code})")The error reference lists every code.
What doesn't work
- The Live API. It runs over WebSockets, which the relay doesn't carry, and its ephemeral tokens are blocked.
- Vertex AI. Keylayer sends Google AI requests only to generativelanguage.googleapis.com.
- Request bodies over 16 MiB on a key with a spend cap, file uploads included. Keylayer reads the whole body first, so it answers 413
body_too_large_for_budget, and 415content_encoding_unsupportedto a compressed body. Keys without a spend cap stream the body straight through. - Requests that run past 900 seconds. Keylayer ends them with 504
deadline_exceeded, or cuts off a started response.
Next steps
- Make your first request: add a credential and mint a key with a spend cap.
- Set limits on a key: every limit a key can have and how spend caps count.
- Handle Keylayer errors: every code Keylayer returns and what to do about it.
Updated