@llm-ports/adapter-google
Native Google Gemini adapter for llm-ports, built on the unified @google/genai SDK (v2.x). Implements LLMPort with full multimodal support — image content blocks pass through as inlineData (base64) or fileData (URL), not degraded to placeholder text.
Shipped in 0.1.0-alpha.5.
Install
pnpm add @llm-ports/core @llm-ports/adapter-google @google/genai zodConfigure
import { createRegistryFromEnv } from "@llm-ports/core";
import { createGoogleAdapter } from "@llm-ports/adapter-google";
const registry = createRegistryFromEnv({
adapters: {
google: createGoogleAdapter({
apiKey: process.env.GOOGLE_API_KEY!, // from https://aistudio.google.com/apikey
}),
},
});
export const llm = registry.getPort();.env:
LLM_PROVIDER_FAST=google|gemini-2.5-flash|cost:5/day
LLM_PROVIDER_PREMIUM=google|gemini-2.5-pro|cost:50/day
LLM_TASK_ROUTE_TRIAGE=fast,premiumAdapter options
interface GoogleAdapterOptions {
apiKey: string;
pricingOverrides?: Record<string, ModelPricing>;
validationStrategy?: ValidationStrategy;
imageSizeLimitBytes?: number; // default 20 MB
onRetry?: OnRetry; // alpha.17+
}onRetry observability hook (alpha.17)
The adapter fires onRetry whenever it retries a generateStructured call after a Zod validation failure. Sync or async; called fire-and-forget; throwing from the hook does NOT cancel the retry. Pipe events into any tracing or metrics stack.
import { createGoogleAdapter } from "@llm-ports/adapter-google";
const adapter = createGoogleAdapter({
apiKey: process.env.GOOGLE_API_KEY!,
onRetry: (event) => {
// Langfuse / Phoenix / OpenLLMetry / Datadog all accept this shape
span.addEvent("llm.retry", {
reason: event.reason, // "validation-feedback" for Gemini
attempt: event.attempt, // 0-indexed retry number
modelId: event.modelId,
providerAlias: event.providerAlias,
delayMs: event.delayMs,
});
},
});Gemini only fires the validation-feedback reason (no transient-auth or capability-fallback retry paths — Gemini doesn't 401 in the burst-protection shape and its parameter compatibility is uniform across models). The event shape matches the OpenAI and Anthropic adapters so consumers can wire one hook across all adapters.
Why this over the OpenAI-compat baseURL
Gemini exposes an OpenAI-compatible surface at https://generativelanguage.googleapis.com/v1beta/openai/. It works for most cases. Reasons to prefer this native adapter:
| Concern | OpenAI-compat baseURL | adapter-google |
|---|---|---|
ImageSource.detail | Silently ignored — Gemini has no equivalent | Ignored explicitly (consistent with adapter-anthropic) |
systemInstruction | Prepended to user message, changing Gemini's behavior | Native top-level field |
| Multimodal richness | image_url with base64 data URI (lossy) | inlineData with explicit mediaType |
| Bundled pricing | None — bring your own | Gemini 2.5 + 2.0 family bundled |
| Image-block boundary validation | Inherits from adapter-openai | First-class, with imageSizeLimitBytes option |
Native responseSchema | Not exposed | ✓ (alpha.9; falls back to prompted-JSON when the schema contains oneOf/allOf/$ref) |
Bundled pricing
| Model | Input/1M | Output/1M | Cache read |
|---|---|---|---|
gemini-2.5-pro | $1.25 | $5.00 | $0.3125 |
gemini-2.5-flash | $0.075 | $0.30 | $0.01875 |
gemini-2.5-flash-lite | $0.0375 | $0.15 | $0.009375 |
gemini-2.0-flash | $0.10 | $0.40 | $0.025 |
gemini-2.0-flash-lite | $0.075 | $0.30 | — |
Source: https://ai.google.dev/gemini-api/docs/pricing (verified 2026-05).
Long-context premium: bundled values are the under-200k-token rates. Gemini charges a higher rate above 200k tokens. For long-context workloads, supply
pricingOverrideswith the over-200k rates.
Supported features (v0.1)
| Feature | Status |
|---|---|
generateText | ✓ |
generateStructured (Zod schemas) | ✓ (alpha.9: native responseSchema constrained-decoding when the schema converts cleanly; falls back to prompted JSON + alpha.5 repair pass for unsupported schema features) |
streamText | ✓ |
streamStructured | ✓ (best-effort partial parse) |
runAgent (multi-turn tool use) | ✓ (alpha.9: full function-calling loop with parallel-call support, aggregated usage, populated toolCalls) |
| Vision input — base64 images | ✓ (inlineData) |
| Vision input — URL images | ✓ (fileData) |
| Audio input — base64 | ✓ (inlineData) |
| Image-block size + URL validation at boundary | ✓ (alpha.5) |
AbortSignal cancellation | ✓ entry + in-flight (alpha.6) |
listModels() | ✓ (alpha.9; via @google/genai client.models.list()) |
Embeddings (gemini-embedding-001) | ✗ — v0.2 |
| Explicit context caching | ✗ — v0.2 |
| Code execution tool | ✗ — v0.2 |
Native responseSchema (alpha.9)
generateStructured emits Gemini's native config.responseSchema + config.responseMimeType: "application/json" when the Zod schema converts cleanly. Gemini constrains decoding to the schema before tokens are produced — invalid JSON and missing required fields are impossible (modulo provider bugs). Zod validation + the alpha.5 repair pass + retry-with-feedback remain the safety net.
const result = await llm.generateStructured({
taskType: "extract",
prompt: "Extract the person: 'Babak is 42'",
schema: z.object({ name: z.string(), age: z.number() }),
});
// Adapter sends config.responseSchema; Gemini constrains decoding.
// validationAttempts will typically be 1 (constrained decoding rarely fails Zod).Fallback path. Gemini's responseSchema accepts the OpenAPI 3.0 subset of JSON Schema. The adapter detects unsupported features and falls back to the prompted-JSON path:
| Zod construct | JSON Schema output | Native path? |
|---|---|---|
z.object(...) | { type: "object", properties: ... } | ✓ |
z.array(z.string()) | { type: "array", items: ... } | ✓ |
z.discriminatedUnion(...) | { anyOf: [...] } | ✓ (Gemini accepts anyOf) |
z.enum(...) | { enum: [...] } | ✓ |
z.intersection(a, b) | { allOf: [...] } | ✗ — falls back to prompted JSON |
z.lazy(...) (recursive) | { $ref: "#" } | ✗ — falls back |
Hand-rolled oneOf / not | as-is | ✗ — falls back |
When a fallback fires, the adapter emits a one-time console.warn per (model, feature) pair naming the unsupported construct. The output is still correct in either case — only the constrained-decoding guarantee differs.
Multi-turn runAgent (alpha.9)
runAgent translates options.tools to Gemini's Tool[] shape (function declarations with JSON Schema, OpenAPI 3.0 subset, via zod-to-json-schema), then loops the chat / function-call / function-response cycle until the model returns text only (terminationReason: "completed") or maxSteps is reached (terminationReason: "max_steps").
Gemini emits parallel function calls (multiple functionCall parts in a single response) and expects all functionResponse parts back together — the adapter executes them in order and groups the responses. toolCalls in the result is fully populated; usage aggregates across steps.
const result = await llm.runAgent({
taskType: "research",
instructions: "Answer the user's question using tools as needed.",
messages: [{ role: "user", content: "What's the weather in Paris?" }],
tools: {
getWeather: {
name: "getWeather",
description: "Fetch current weather for a city.",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ tempC: 22, condition: "sunny", city }),
},
},
maxSteps: 4,
});
console.log(result.text); // "It's 22°C and sunny in Paris."
console.log(result.toolCalls); // [{ name: "getWeather", input: {city: "Paris"}, output: {...} }]
console.log(result.stepsTaken); // 2
console.log(result.terminationReason); // "completed"
console.log(result.usage.inputTokens); // aggregated across both stepslistModels() (alpha.9)
const models = await port.listModels();
// [{ id: "gemini-2.5-flash", displayName: "Gemini 2.5 Flash", contextWindow: 1048576, ... }, ...]Pricing is not surfaced (Gemini's /models endpoint exposes catalog metadata but not USD rates). Registry.checkPricingFreshness() uses this to detect added/removed models.
Content blocks supported
text, image (base64 → inlineData; URL → fileData), audio (base64 only), tool_use, tool_result. The adapter throws ContentBlockUnsupportedError for unsupported variants (audio URLs).
Cancellation
Full AbortSignal support shipped in 0.1.0-alpha.6. Threading the signal cancels the in-flight provider HTTP fetch, not just the JS await:
const controller = new AbortController();
const promise = llm.generateText({
taskType: "describe_image",
prompt: [...],
signal: controller.signal,
});
// User clicks cancel:
controller.abort();
// promise rejects; the HTTP request to generativelanguage.googleapis.com is cancelled.See the Cancellation guide for the full pattern.
Image cost note
Gemini does not have a separate cost-vs-fidelity knob equivalent to OpenAI's image_url.detail. Image cost is determined by the model's automatic tiling — typically ~258 tokens per image for gemini-2.5-flash, ~1,290 for high-resolution inputs to gemini-2.5-pro. If you set ImageSource.detail on a call routed to a Gemini model, the adapter ignores the field (consistent with adapter-anthropic).
Reading next
@llm-ports/adapter-openai— comparison if you're choosing between native Gemini and the OpenAI-compat path- Cancellation guide —
AbortSignalusage - Multi-provider routing — chain Gemini with Anthropic / OpenAI fallbacks
- Gemini API pricing — verify bundled table
- @google/genai SDK docs — underlying SDK reference