Generate an image under the allowance #
Prompt:
Have the backend draw a picture for each new recipe. I don't have a key for any AI service.
Also works:
- "Make a cover image with the AI that comes with my plan."
- "Draw an avatar from each user's description."
What your tool does #
- Takes the AI Allowance package, as it does for a text call.
- Writes the transport from the settings a deployed copy receives, as step 3's sample does.
- Sends the image request on that transport itself, because the package's
generateTextreturns text alone. - Asks you which image model to use where the prompt does not say. It asks nothing else.
What you need #
- An application on a plan with an AI allowance. Plan and usage says how to read and change the plan.
- No provider account and no provider key.
Before your AI starts #
This section is for your AI coding tool: what it checks and gathers before it begins. You don't need to do these steps yourself.
- Your tool connected and signed in (Connect Claude Code or Codex).
- The application's identifier from
list_applications, and its plan's quantity fromread_plan_quotas, thegemini-allowancerow, in micro-dollars. - The backend's folder, the one that contains
package.json.
Steps #
1. Take the package #
Your tool takes the ai_allowance library entry as Call an AI model under the allowance describes. The allowance covers image generation as well as text, on the same route, with no provider key.
The package's generateText returns text alone and drops any picture. So an image call sends its request on your transport directly. The package still gives the route's start, ALLOWANCE_PATH_PREFIX, and isGatewayAnswer, which tells the platform's own answer from the provider's.
2. Choose an image model #
The platform forwards any model whose name begins with gemini-. It has a price for each of these four image models, charged by the tokens of the picture they make:
gemini-3.1-flash-imagegemini-3.1-flash-lite-imagegemini-nano-banana-2.1gemini-3-pro-image
Each picture draws Google's published price for the model it names. A price can change without a release, so this page names no rate.
The platform also forwards another gemini- image model the provider still serves. A model the platform has no price for draws at the rates of the dearest model it prices.
A request for audio or video output is refused with allowance_feature_refused.
3. Send the request from a route #
The request is POST to /egress/v0/ai-allowance/v1beta/models/<model>:generateContent on the platform's origin. The transport adds the origin and the application's credential as the bearer, as for a text call. The body holds the prompt as one user turn and asks for image output with responseModalities: ['IMAGE'].
This server responds to POST /pictures with a picture drawn from the prompt in the request body, sent with its image type. It retries while the provider is busy, and responds 422 when the provider makes no picture. Its health path is /health. A test runs this sample over a stand-in for the route and checks the picture, the request it sends, the retry, and a prompt with no picture.
// src/server.mjs: POST /pictures responds with a picture drawn from the prompt in the body.
import { createServer } from 'node:http';
import { ALLOWANCE_PATH_PREFIX, isGatewayAnswer } from '@turnzero/ai_allowance';
// The platform's origin and this copy's credential, from the settings it receives.
const origin = process.env.TURNZERO_CLOUD_GATEWAY_URL ?? process.env.TURNZERO_CLOUD_API;
const transport = (path, init = {}) =>
fetch(origin + path, { ...init, headers: { ...init.headers, authorization: `Bearer ${process.env.TURNZERO_CLOUD_TOKEN}` } });
const MODEL = 'gemini-3.1-flash-image';
async function drawPicture(prompt) {
for (let attempt = 1; ; attempt += 1) {
const response = await transport(`${ALLOWANCE_PATH_PREFIX}/v1beta/models/${MODEL}:generateContent`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { responseModalities: ['IMAGE'] },
}),
});
const text = await response.text();
const fromPlatform = isGatewayAnswer(response);
// The provider answers 429 while its shared capacity is busy, which frees in seconds: wait, then try again.
if (response.status === 429 && !fromPlatform && attempt < 5) {
await new Promise((resume) => setTimeout(resume, 1000 * attempt));
continue;
}
// A platform refusal names itself, such as allowance_exhausted. Anything else is the provider's own answer.
if (fromPlatform || !response.ok) throw new Error(`${fromPlatform ? 'platform' : 'provider'} answered ${response.status}: ${text}`);
const parts = JSON.parse(text).candidates?.[0]?.content?.parts ?? [];
const picture = parts.find((part) => !part.thought && part.inlineData?.data);
// No picture: the provider declined the prompt, and its promptFeedback or finishReason says why.
if (!picture) return null;
return { bytes: Buffer.from(picture.inlineData.data, 'base64'), type: picture.inlineData.mimeType };
}
}
const port = Number(process.env.PORT ?? 8080);
createServer(async (req, res) => {
if (req.method === 'GET' && req.url === '/health') return res.writeHead(200).end('ok');
if (req.method !== 'POST' || req.url !== '/pictures') return res.writeHead(404).end();
let prompt = '';
for await (const chunk of req) prompt += chunk;
try {
const picture = await drawPicture(prompt);
if (!picture) return res.writeHead(422).end('no picture for this prompt');
res.writeHead(200, { 'content-type': picture.type }).end(picture.bytes);
} catch (error) {
console.error(error);
res.writeHead(502).end();
}
}).listen(port, () => console.log(`listening on ${port}`));
4. Read the picture from the answer #
The answer is the provider's own JSON, passed through unchanged. The picture is in candidates[0].content.parts: the first part with inlineData, which holds mimeType and data. The data is the picture's bytes in base64. Skip a part marked thought, as the sample does.
When the provider blocks a prompt, the platform still passes its answer through unchanged. That answer holds no picture part. The provider's own members say why: its promptFeedback, or a candidate's finishReason.
An answer without the x-egress-upstream header is the platform's own refusal, and isGatewayAnswer returns true for it. Every other answer is the provider's.
5. Retry a busy provider #
The image model's provider can answer 429 RESOURCE_EXHAUSTED while its shared capacity is busy. That capacity frees in seconds, so retry with a short backoff, as the sample does.
Never retry allowance_exhausted within the month. The platform's allowance_busy may be retried once one of your calls in progress ends, though the sample treats it as a failure. Fall back when the allowance runs out says what to do instead.
6. Know what a picture costs #
A picture's tokens are drawn once, at the model's image-output rate. The prompt's tokens are drawn at the model's input rate. AI Allowance says how every call is counted.
One 1408x768 PNG, about 2.2 MB, from gemini-3.1-flash-image drew 67,209 micro-dollars, about 7 cents, when measured on 2026-10-13. At that cost, the Free plan's monthly allowance bought about 7 such pictures. A plan's allowance can change without a release, so read yours with read_plan_quotas.
read_usage returns the month's draw as the measure ai_allowance_usd_micros, in micro-dollars, so you can check what your own pictures cost.
7. Deploy #
Your tool deploys as Deploy an application describes. The deploy injects the platform credential the transport sends, so the call takes no environment header.
Expected result #
POST /pictures responds with the picture's bytes and its image type, such as image/png. After a call, read_usage shows ai_allowance_usd_micros above its earlier figure. Once the allowance is drawn, the call is refused allowance_exhausted, and the route responds 502.
Refusals #
This table lists the refusals these steps most often meet; the refusals page lists every refusal the platform answers, with its cause and remedy. A backend package's own failures stand on its package page.
| Refusal | Status | Cause | Remedy |
|---|---|---|---|
allowance_exhausted |
429 | This UTC month's allowance is used up. | Wait for the next month, move to a larger plan, or call your own upstream. Never retry it this month. |
allowance_busy |
429 | The application already has the most allowance calls in progress that the platform allows. | Retry after one of them ends. |
allowance_feature_refused |
400 | The request asked for audio or video output, or another feature the allowance does not cover. | Ask for image or text output alone. |
allowance_path_refused |
403 | The request is not a POST to a generate method of a model whose name opens with gemini-. |
Name a Gemini image model from step 2. |
Related #
- Call an AI model under the allowance: text calls, the fallback, and the test double.
- AI Allowance: the package's names, the count in dollars, and every refusal.
- Plan and usage: each plan's monthly allowance, and what stops while it is over.
- The settings a deployed copy receives: the origin and the credential the transport reads.