Skip to content
4 min read · 742 words

Generation — Gemini

The Gemini-shaped engine talks to the native generativelanguage REST API's generateContent endpoint directly — no @google/genai SDK, just fetch. Its option surface extends the OpenAI engine's shared base (BaseGenerationAdapterOptions, retry/timeout/headers/fetch), adding only what Gemini's own request shape needs.

ts
import { GeminiGenerationAdapter } from '@nhtio/adk/batteries/generation/gemini'

const generator = new GeminiGenerationAdapter({
  model: 'gemini-2.5-flash-image',
  apiKey: process.env.GEMINI_API_KEY,
})

const [image] = await generator.generate('A watercolor illustration of a lighthouse at dawn')

Options

OptionTypeDefaultNotes
modelstring(required)Same base as the OpenAI engine — no default anywhere in this domain.
apiKeystringSent as x-goog-api-key. Use headers instead for a Bearer-token gateway.
baseURLstringhttps://generativelanguage.googleapis.com/v1betaInjectable.
headersRecord<string, string>Merged over defaults; can fully replace the auth scheme.
fetchtypeof fetchglobal fetchSame seam as the OpenAI engine.
responseModalities('TEXT' | 'IMAGE')[]['TEXT', 'IMAGE']See below — don't drop TEXT.
aspectRatiostringe.g. '1:1', '16:9'. Only sent when set (as generationConfig.imageConfig.aspectRatio).
nnumberPer-call (generate/edit). Sent as generationConfig.candidateCount only when n > 1; ignored otherwise.
requestTimeoutMsnumber0 (disabled)Same semantics as the OpenAI engine.
retry.*same defaults as OpenAImaxAttempts 1, baseDelayMs 500, maxDelayMs 30000, retriableStatuses [429,500,502,503,504], honorRetryAfter true.

Auth: apiKey vs gateway headers

Direct-to-Google callers set apiKey; the adapter sends it as x-goog-api-key:

ts
new GeminiGenerationAdapter({ model: 'gemini-2.5-flash-image', apiKey: process.env.GEMINI_API_KEY })

Gateway/LB callers instead skip apiKey and pass a Bearer token through headers — this is the exact shape used by this domain's own live-testing setup, and it bypasses the adapter's own apiKey → header translation entirely:

ts
new GeminiGenerationAdapter({
  model: 'gemini-2.5-flash-image',
  baseURL: process.env.LB_BASE_URL,
  headers: { Authorization: `Bearer ${process.env.LB_TOKEN}` },
})

responseModalities — why TEXT stays in the default

Gemini's image models are natively multimodal-out: a request can ask for text, image, or both back. responseModalities defaults to ['TEXT', 'IMAGE'] rather than ['IMAGE'] alone because omitting TEXT from the request has been probe-confirmed to produce inconsistent results — sometimes an image-only response, sometimes an outright refusal, depending on the prompt. Requesting both and then discarding the text (the adapter only ever returns GeneratedMediaOutput[] for images) is the reliable path. Override it only if you have verified your specific model/prompt combination behaves with a narrower modality list.

generate() and edit() — image parts first, text last

Both calls build a single contents[0].parts array. generate() has only the text prompt part. edit() is the one where ordering matters:

ts
// conceptually, inside edit():
const parts: GeminiRequestPart[] = normalizedInputs.map((input) => ({
  inlineData: { mimeType: input.mimeType, data: toBase64(input.bytes) },
}))
parts.push({ text: prompt }) // Probe-confirmed ordering: image parts first (in input order), text prompt last.
ts
const outputs = await generator.edit(
  [{ bytes: pngBytes, mimeType: 'image/png' }],
  'Recolor the car to matte black',
  { aspectRatio: '1:1' }
)

Multiple input images are sent in the order you pass them, all before the text part. This ordering was determined empirically against the live API (not documented by Google) — do not reorder it in a custom fork of this adapter.

Refusal surfacing

A 2xx response with zero image parts — most commonly a safety refusal that comes back as text only — throws E_GEMINI_GENERATION_MALFORMED_RESPONSE rather than silently resolving an empty array. The refusal's own text is embedded in the exception detail:

response contained no image parts; text: I can't create images depicting real public figures.

Treat this exception as "the model declined," not just "malformed wire data," when deciding how to surface the failure to a user.

Nano Banana family and the Imagen limitation

This adapter calls generateContent exclusively. The "Nano Banana" model family (gemini-2.5-flash-image and its successors) is built for exactly this endpoint and is the practical default for both generate() and edit(). Google's separate Imagen line is served from a different REST surface (:predict) that this adapter does not call — pointing model at an Imagen-only model name will not work here, regardless of responseModalities.

Exceptions

ExceptionStatusFatal?Thrown when
E_INVALID_GEMINI_GENERATION_OPTIONS529YesAdapter options fail geminiGenerationOptionsSchema validation.
E_GEMINI_GENERATION_HTTP_ERROR502NoThe upstream HTTP call fails and retries are exhausted. Detail: [status, body].
E_GEMINI_GENERATION_REQUEST_TIMEOUT504NorequestTimeoutMs elapses before the request settles. Detail: [requestTimeoutMs].
E_GEMINI_GENERATION_MALFORMED_RESPONSE502NoNo candidates, or zero image parts (refusal-shaped response). Detail includes any text parts' content.

Where to go next

  • OpenAI — the shared option base this engine extends, and the engine whose edit() 404s on some gateways where this one works.
  • Recipes — an edit-tool that consumes an inbound Media attachment via GenerationImageInput.
  • Assembly → Generation batteries — full option/exception tables, all three engines.