Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit f6db4c3

Browse files
committed
fix: surface Gemini blocked/empty response reasons instead of generic error
When the Gemini API returns a non-STOP finishReason (SAFETY, RECITATION, PROHIBITED_CONTENT) and no content was produced, throw a descriptive error with the actual finish reason instead of silently yielding nothing and falling into the generic "no assistant messages" error path. Also handle reasoning-only responses (thinking models like gemini-3.1-pro-preview that return only reasoning without actionable text/tool calls) by yielding a placeholder text so downstream retry logic can re-prompt the model. Closes #12045
1 parent 137d3f4 commit f6db4c3

2 files changed

Lines changed: 247 additions & 0 deletions

File tree

src/api/providers/__tests__/gemini.spec.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,229 @@ describe("GeminiHandler", () => {
257257
})
258258
})
259259

260+
describe("empty response handling", () => {
261+
const mockMessages: Anthropic.Messages.MessageParam[] = [
262+
{
263+
role: "user",
264+
content: "Hello",
265+
},
266+
]
267+
268+
const systemPrompt = "You are a helpful assistant"
269+
270+
it("should throw a descriptive error when finishReason is SAFETY and no content was produced", async () => {
271+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
272+
[Symbol.asyncIterator]: async function* () {
273+
yield {
274+
candidates: [
275+
{
276+
finishReason: "SAFETY",
277+
content: { parts: [] },
278+
},
279+
],
280+
}
281+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 } }
282+
},
283+
})
284+
285+
const stream = handler.createMessage(systemPrompt, mockMessages)
286+
287+
await expect(async () => {
288+
for await (const _chunk of stream) {
289+
// collect
290+
}
291+
}).rejects.toThrow(
292+
t("common:errors.gemini.generate_stream", {
293+
error: "Gemini response blocked: finishReason=SAFETY. The model did not produce any content. This may be caused by safety filters, content policy, or recitation checks.",
294+
}),
295+
)
296+
})
297+
298+
it("should throw a descriptive error when finishReason is RECITATION and no content was produced", async () => {
299+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
300+
[Symbol.asyncIterator]: async function* () {
301+
yield {
302+
candidates: [
303+
{
304+
finishReason: "RECITATION",
305+
content: { parts: [] },
306+
},
307+
],
308+
}
309+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 } }
310+
},
311+
})
312+
313+
const stream = handler.createMessage(systemPrompt, mockMessages)
314+
315+
await expect(async () => {
316+
for await (const _chunk of stream) {
317+
// collect
318+
}
319+
}).rejects.toThrow(
320+
t("common:errors.gemini.generate_stream", {
321+
error: "Gemini response blocked: finishReason=RECITATION. The model did not produce any content. This may be caused by safety filters, content policy, or recitation checks.",
322+
}),
323+
)
324+
})
325+
326+
it("should throw a descriptive error when finishReason is PROHIBITED_CONTENT", async () => {
327+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
328+
[Symbol.asyncIterator]: async function* () {
329+
yield {
330+
candidates: [
331+
{
332+
finishReason: "PROHIBITED_CONTENT",
333+
content: { parts: [] },
334+
},
335+
],
336+
}
337+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 } }
338+
},
339+
})
340+
341+
const stream = handler.createMessage(systemPrompt, mockMessages)
342+
343+
await expect(async () => {
344+
for await (const _chunk of stream) {
345+
// collect
346+
}
347+
}).rejects.toThrow(
348+
t("common:errors.gemini.generate_stream", {
349+
error: "Gemini response blocked: finishReason=PROHIBITED_CONTENT. The model did not produce any content. This may be caused by safety filters, content policy, or recitation checks.",
350+
}),
351+
)
352+
})
353+
354+
it("should NOT throw when finishReason is STOP even without content", async () => {
355+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
356+
[Symbol.asyncIterator]: async function* () {
357+
yield {
358+
candidates: [
359+
{
360+
finishReason: "STOP",
361+
content: { parts: [] },
362+
},
363+
],
364+
}
365+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 0 } }
366+
},
367+
})
368+
369+
const stream = handler.createMessage(systemPrompt, mockMessages)
370+
const chunks = []
371+
372+
for await (const chunk of stream) {
373+
chunks.push(chunk)
374+
}
375+
376+
// Should not throw; just yield usage
377+
expect(chunks.some((c) => c.type === "usage")).toBe(true)
378+
})
379+
380+
it("should NOT throw when finishReason is SAFETY but content was produced", async () => {
381+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
382+
[Symbol.asyncIterator]: async function* () {
383+
yield {
384+
candidates: [
385+
{
386+
content: { parts: [{ text: "Some content" }] },
387+
},
388+
],
389+
}
390+
yield {
391+
candidates: [
392+
{
393+
finishReason: "SAFETY",
394+
content: { parts: [] },
395+
},
396+
],
397+
}
398+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
399+
},
400+
})
401+
402+
const stream = handler.createMessage(systemPrompt, mockMessages)
403+
const chunks = []
404+
405+
for await (const chunk of stream) {
406+
chunks.push(chunk)
407+
}
408+
409+
// Should not throw because content was produced
410+
expect(chunks.some((c) => c.type === "text")).toBe(true)
411+
})
412+
413+
it("should yield a placeholder text when only reasoning content is produced (no actionable content)", async () => {
414+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
415+
[Symbol.asyncIterator]: async function* () {
416+
yield {
417+
candidates: [
418+
{
419+
finishReason: "STOP",
420+
content: {
421+
parts: [{ thought: true, text: "Let me think about this..." }],
422+
},
423+
},
424+
],
425+
}
426+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
427+
},
428+
})
429+
430+
const stream = handler.createMessage(systemPrompt, mockMessages)
431+
const chunks = []
432+
433+
for await (const chunk of stream) {
434+
chunks.push(chunk)
435+
}
436+
437+
// Should have reasoning chunk, a placeholder text chunk, and usage
438+
expect(chunks.some((c) => c.type === "reasoning")).toBe(true)
439+
const textChunks = chunks.filter((c) => c.type === "text")
440+
expect(textChunks.length).toBe(1)
441+
expect(textChunks[0].text).toContain("reasoning but no actionable response")
442+
})
443+
444+
it("should NOT yield a placeholder when reasoning AND text content are produced", async () => {
445+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
446+
[Symbol.asyncIterator]: async function* () {
447+
yield {
448+
candidates: [
449+
{
450+
content: {
451+
parts: [{ thought: true, text: "Let me think..." }, { text: "Here is my answer" }],
452+
},
453+
},
454+
],
455+
}
456+
yield {
457+
candidates: [
458+
{
459+
finishReason: "STOP",
460+
content: { parts: [] },
461+
},
462+
],
463+
}
464+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 } }
465+
},
466+
})
467+
468+
const stream = handler.createMessage(systemPrompt, mockMessages)
469+
const chunks = []
470+
471+
for await (const chunk of stream) {
472+
chunks.push(chunk)
473+
}
474+
475+
// Should have reasoning and real text, but NOT the placeholder
476+
expect(chunks.some((c) => c.type === "reasoning")).toBe(true)
477+
const textChunks = chunks.filter((c) => c.type === "text")
478+
expect(textChunks.length).toBe(1)
479+
expect(textChunks[0].text).toBe("Here is my answer")
480+
})
481+
})
482+
260483
describe("error telemetry", () => {
261484
const mockMessages: Anthropic.Messages.MessageParam[] = [
262485
{

src/api/providers/gemini.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,30 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
305305
this.lastResponseId = finalResponse.responseId
306306
}
307307

308+
// When the Gemini API returns a non-STOP finishReason (e.g. SAFETY,
309+
// RECITATION, MAX_TOKENS, PROHIBITED_CONTENT) and no usable content
310+
// was produced, surface a descriptive error instead of silently
311+
// yielding nothing and falling into the generic "no assistant
312+
// messages" error path downstream.
313+
if (!hasContent && finishReason && finishReason !== "STOP" && finishReason !== "MAX_TOKENS") {
314+
throw new Error(
315+
`Gemini response blocked: finishReason=${finishReason}. The model did not produce any content. This may be caused by safety filters, content policy, or recitation checks.`,
316+
)
317+
}
318+
319+
// When a thinking/reasoning model (e.g. gemini-3.1-pro-preview)
320+
// returns only reasoning content without any actionable text or
321+
// tool calls, yield a minimal text chunk so that the downstream
322+
// "no assistant messages" check in Task.ts is not triggered. This
323+
// gives the retry logic a chance to re-prompt the model instead of
324+
// treating it as a hard failure.
325+
if (!hasContent && hasReasoning) {
326+
yield {
327+
type: "text",
328+
text: "[The model produced reasoning but no actionable response. Retrying...]",
329+
}
330+
}
331+
308332
if (pendingGroundingMetadata) {
309333
const sources = this.extractGroundingSources(pendingGroundingMetadata)
310334
if (sources.length > 0) {

0 commit comments

Comments
 (0)