Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions apps/daemon/src/image-generation/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,9 +374,10 @@ async function generateGeminiImages(
failure.publicFacts
);
}
const part = findGeminiImagePart(await response.json() as unknown);
const payload = await response.json() as unknown;
const part = findGeminiImagePart(payload);
if (!part) {
throw new ImageGenerationProviderError('upstream_error', 'Gemini returned no image data');
throw new ImageGenerationProviderError('upstream_error', geminiNoImageMessage(payload));
}
const content = Buffer.from(part.data, 'base64');
validateImageContent(content);
Expand Down Expand Up @@ -577,6 +578,28 @@ function manualImageParameters(request: CreateImageGenerationRequest, provider:
};
}

function geminiNoImageMessage(payload: unknown): string {
if (!isRecord(payload)) return 'Gemini returned no image data';
const blockReason = readNestedString(payload, ['promptFeedback', 'blockReason']);
if (blockReason) {
const detail = readNestedString(payload, ['promptFeedback', 'blockReasonMessage']);
return `Gemini blocked the prompt (${blockReason})${detail ? `: ${detail}` : ''}`;
}
const candidates = Array.isArray(payload.candidates) ? payload.candidates : [];
for (const candidate of candidates) {
if (!isRecord(candidate)) continue;
const finishReason = typeof candidate.finishReason === 'string' ? candidate.finishReason : undefined;
if (!finishReason || finishReason === 'STOP') continue;
const parts = isRecord(candidate.content) && Array.isArray(candidate.content.parts) ? candidate.content.parts : [];
const text = parts
.map(part => (isRecord(part) && typeof part.text === 'string' ? part.text.trim() : ''))
.filter(Boolean)
.join(' ');
return `Gemini blocked the image (${finishReason})${text ? `: ${text}` : ''}`;
}
return 'Gemini returned no image data';
}

function findGeminiImagePart(payload: unknown): { data: string; mime: string | null } | undefined {
if (!isRecord(payload) || !Array.isArray(payload.candidates)) return undefined;
for (const candidate of payload.candidates) {
Expand Down
11 changes: 11 additions & 0 deletions apps/daemon/test/integration/image-generation-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@ describe('image generation API', () => {
expect(fetchImpl).not.toHaveBeenCalled();
});

it.each([
['prompt blocked', { promptFeedback: { blockReason: 'PROHIBITED_CONTENT' } }, 'PROHIBITED_CONTENT'],
['image safety finish', { candidates: [{ finishReason: 'IMAGE_SAFETY', content: { parts: [{ text: 'I cannot create that image.' }] } }] }, 'IMAGE_SAFETY']
])('surfaces Gemini safety blocks instead of a generic no-image error (%s)', async (_name, payload, reason) => {
const config = createDefaultCreatorServicesConfig();
config.image.gemini.apiKey = 'test-key';
const fetchImpl = vi.fn(async () => Response.json(payload));
await expect(generateImageContents({ prompt: 'A city', provider: 'gemini', size: '1024x1024', quality: 'medium', count: 1 }, config, { fetchImpl }))
.rejects.toThrow(new RegExp(`blocked.*${reason}`));
});

it('generates and serves native ChatGPT artifacts through the existing image API without HTTP fallback', async () => {
const home = join(dataDir, 'codex');
await mkdir(home);
Expand Down
Loading