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
5 changes: 5 additions & 0 deletions .changeset/google-generation-failures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@livekit/agents-plugin-google': patch
---

fix(google): surface Gemini content blocking and empty generation failures
109 changes: 56 additions & 53 deletions plugins/google/src/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
//
// SPDX-License-Identifier: Apache-2.0
import type * as types from '@google/genai';
import { FunctionCallingConfigMode, type GenerateContentConfig, GoogleGenAI } from '@google/genai';
import {
FinishReason,
FunctionCallingConfigMode,
type GenerateContentConfig,
GoogleGenAI,
} from '@google/genai';
import type { APIConnectOptions } from '@livekit/agents';
import {
APIConnectionError,
Expand Down Expand Up @@ -296,13 +301,13 @@ export class LLM extends llm.LLM {
}
}

const BLOCKED_REASONS = [
'SAFETY',
'SPII',
'PROHIBITED_CONTENT',
'BLOCKLIST',
'LANGUAGE',
'RECITATION',
const BLOCKED_REASONS: types.FinishReason[] = [
FinishReason.SAFETY,
FinishReason.SPII,
FinishReason.PROHIBITED_CONTENT,
FinishReason.BLOCKLIST,
FinishReason.LANGUAGE,
FinishReason.RECITATION,
];

export class LLMStream extends llm.LLMStream {
Expand Down Expand Up @@ -380,6 +385,9 @@ export class LLMStream extends llm.LLMStream {
},
});

let responseGenerated = false;
let finishReason: types.FinishReason | undefined;

for await (const chunk of response) {
if (chunk.promptFeedback) {
throw new APIStatusError({
Expand All @@ -391,32 +399,20 @@ export class LLMStream extends llm.LLMStream {
});
}

// Check for blocked reasons first — safety-blocked responses often lack content.parts,
// so this must run before the no-content guard to avoid wasting retries.
if (
chunk.candidates?.[0]?.finishReason &&
BLOCKED_REASONS.includes(chunk.candidates[0].finishReason)
) {
throw new APIStatusError({
message: `Google LLM: generation blocked - ${chunk.candidates[0].finishReason}`,
options: {
retryable: false,
requestId,
if (chunk.usageMetadata) {
const usage = chunk.usageMetadata;
this.queue.put({
id: requestId,
usage: {
completionTokens: usage.candidatesTokenCount || 0,
promptTokens: usage.promptTokenCount || 0,
promptCachedTokens: usage.cachedContentTokenCount || 0,
totalTokens: usage.totalTokenCount || 0,
},
});
}

if (!chunk.candidates || !chunk.candidates[0]?.content?.parts) {
this.logger.warn(`No content in the response: ${JSON.stringify(chunk)}`);
if (retryable) {
throw new APIStatusError({
message: 'Google LLM: no content in the response',
options: {
retryable: true,
requestId,
},
});
}
if (!chunk.candidates) {
continue;
}

Expand All @@ -427,40 +423,47 @@ export class LLMStream extends llm.LLMStream {
}

const candidate = chunk.candidates[0];
const finishReason = candidate.finishReason;
if (!candidate) {
continue;
}

let chunksYielded = false;
for (const part of candidate.content!.parts!) {
const chatChunk = this.#parsePart(requestId, part);
if (chatChunk) {
chunksYielded = true;
retryable = false;
this.queue.put(chatChunk);
}
if (candidate.finishReason) {
finishReason = candidate.finishReason;
}

if (finishReason === 'STOP' && !chunksYielded && retryable) {
if (candidate.finishReason && BLOCKED_REASONS.includes(candidate.finishReason)) {
throw new APIStatusError({
message: 'Google LLM: no response generated',
message: `Google LLM: generation blocked by Gemini: ${candidate.finishReason}`,
options: {
retryable,
retryable: false,
requestId,
},
});
}

if (chunk.usageMetadata) {
const usage = chunk.usageMetadata;
this.queue.put({
id: requestId,
usage: {
completionTokens: usage.candidatesTokenCount || 0,
promptTokens: usage.promptTokenCount || 0,
promptCachedTokens: usage.cachedContentTokenCount || 0,
totalTokens: usage.totalTokenCount || 0,
},
});
if (!candidate.content?.parts) {
continue;
}

for (const part of candidate.content.parts) {
const chatChunk = this.#parsePart(requestId, part);
responseGenerated = true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 responseGenerated set to true unconditionally, suppressing "no response" error for empty parts

responseGenerated is set to true on line 450 before checking whether #parsePart returned a non-null chunk. This means if Gemini returns candidate.content.parts containing parts that all parse to null (e.g., parts with neither text nor functionCall, such as executableCode, codeExecutionResult, or empty text parts), responseGenerated becomes true and the post-loop "no response generated" error at line 458 is never thrown.

In contrast, the old code tracked this with chunksYielded which was only set to true inside the if (chatChunk) block, so it correctly detected and retried when parts existed but produced no meaningful output. The new code silently completes the stream with zero content chunks enqueued.

Suggested change
responseGenerated = true;
if (chatChunk) {
responseGenerated = true;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

if (chatChunk) {
retryable = false;
this.queue.put(chatChunk);
}
}
}

if (!responseGenerated) {
throw new APIStatusError({
message: 'Google LLM: no response generated',
options: {
body: { finishReason },
retryable,
requestId,
},
});
}
} catch (error: unknown) {
if (error instanceof APIStatusError || error instanceof APIConnectionError) {
Expand Down
Loading