Google image adapter discards candidate finish reasons from NoImageGeneratedError diagnostics
Description
generateImage loses the underlying Gemini candidate finish reason before constructing public call diagnostics. A no-image candidate ending with IMAGE_SAFETY, without optional safetyRatings or finishMessage, becomes indistinguishable from an unexplained empty candidate ending with OTHER.
The reproduction injects these two valid response shapes. After removing the generated timestamp, their complete NoImageGeneratedError.calls[0] objects are deeply equal. Neither the raw finish reason nor the normalized finish reason nor the raw response body survives. promptFeedback, safetyRatings, and finishMessage are all null.
Expected: preserve the provider's finish reason in the public image call diagnostics, for example as additive Google provider metadata. Applications need to distinguish a terminal moderation outcome from a transient empty response without making extra provider calls or intercepting transport bodies. An exact metadata field name is not required by this report.
#20153 fixed retention of the call objects in NoImageGeneratedError; this is separate information loss inside the Google image adapter before those calls are assembled. It is why an application needing these diagnostics still has to use generateText with image response modalities.
Reproduction
In an empty directory:
bun add --exact [email protected] @ai-sdk/[email protected] [email protected]
node --test repro.mjsSave the following as repro.mjs. It uses synthetic fixtures only; no API key, provider access, or live provider request is needed. The assertion expresses the expected behavior and fails against the versions below.
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { generateImage, NoImageGeneratedError } from 'ai';
import { createGoogleGenerativeAI } from '@ai-sdk/google';
test('candidate safety and unexplained emptiness retain distinguishable diagnostics', async () => {
async function diagnostics(finishReason) {
const google = createGoogleGenerativeAI({
apiKey: 'fixture',
fetch: async () => Response.json({
candidates: [{ content: { parts: [] }, finishReason }],
usageMetadata: { promptTokenCount: 10, totalTokenCount: 10 },
}),
});
try {
await generateImage({ model: google.image('gemini-3.1-flash-image-preview'), prompt: 'Fixture.', maxRetries: 0 });
assert.fail('Expected an empty-image error');
} catch (error) {
assert.ok(NoImageGeneratedError.isInstance(error));
assert.equal(error.calls.length, 1);
// Remove the automatically generated timestamp before comparing.
const { response, ...call } = error.calls[0];
const { timestamp, ...responseWithoutTimestamp } = response;
return { ...call, response: responseWithoutTimestamp };
}
}
const blocked = await diagnostics('IMAGE_SAFETY');
const unexplained = await diagnostics('OTHER');
console.log({ blocked, unexplained });
assert.notDeepEqual(blocked, unexplained, 'The provider supplied different finish reasons, but the public diagnostics erased them');
});Observed result: One failing test: the blocked and unexplained-empty public diagnostics are identical, despite different provider finish reasons.
Relevant implementation
The language model returns finishReason separately from provider metadata at google-language-model.ts:609–631. The image adapter uses the unified reason internally to disable retries but only forwards the provider metadata, and strips the response body, at google-image-model.ts:239–260.
The reproduction uses maxRetries: 0 so both cases contain exactly one call; differing retry counts cannot act as a substitute for diagnostics.
AI SDK Version
[email protected]@ai-sdk/[email protected][email protected]- Reproduced on Node.js
26.7.0; installed with Bun1.3.12.
Source: vercel/ai