Skip to content

Commit 97ad88c

Browse files
committed
fix: validate Gemini thinkingLevel against model capabilities and handle empty streams
getGeminiReasoning() now validates the selected effort against the model's supportsReasoningEffort array before sending it as thinkingLevel. When a stale settings value (e.g. 'medium' from a different model) is not in the supported set, it falls back to the model's default reasoningEffort. GeminiHandler.createMessage() now tracks whether any text content was yielded during streaming and handles NoOutputGeneratedError gracefully instead of surfacing the cryptic 'No output generated' error.
1 parent 5d17f56 commit 97ad88c

File tree

4 files changed

+248
-9
lines changed

4 files changed

+248
-9
lines changed

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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// npx vitest run src/api/providers/__tests__/gemini.spec.ts
22

3+
import { NoOutputGeneratedError } from "ai"
4+
35
const mockCaptureException = vitest.fn()
46

57
vitest.mock("@roo-code/telemetry", () => ({
@@ -149,6 +151,84 @@ describe("GeminiHandler", () => {
149151
)
150152
})
151153

154+
it("should yield informative message when stream produces no text content", async () => {
155+
// Stream with only reasoning (no text-delta) simulates thinking-only response
156+
const mockFullStream = (async function* () {
157+
yield { type: "reasoning-delta", id: "1", text: "thinking..." }
158+
})()
159+
160+
mockStreamText.mockReturnValue({
161+
fullStream: mockFullStream,
162+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 0 }),
163+
providerMetadata: Promise.resolve({}),
164+
})
165+
166+
const stream = handler.createMessage(systemPrompt, mockMessages)
167+
const chunks = []
168+
169+
for await (const chunk of stream) {
170+
chunks.push(chunk)
171+
}
172+
173+
// Should have: reasoning chunk, empty-stream informative message, usage
174+
const textChunks = chunks.filter((c) => c.type === "text")
175+
expect(textChunks).toHaveLength(1)
176+
expect(textChunks[0]).toEqual({
177+
type: "text",
178+
text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.",
179+
})
180+
})
181+
182+
it("should suppress NoOutputGeneratedError when no text content was yielded", async () => {
183+
// Empty stream - nothing yielded at all
184+
const mockFullStream = (async function* () {
185+
// empty stream
186+
})()
187+
188+
mockStreamText.mockReturnValue({
189+
fullStream: mockFullStream,
190+
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
191+
providerMetadata: Promise.resolve({}),
192+
})
193+
194+
const stream = handler.createMessage(systemPrompt, mockMessages)
195+
const chunks = []
196+
197+
// Should NOT throw - the error is suppressed
198+
for await (const chunk of stream) {
199+
chunks.push(chunk)
200+
}
201+
202+
// Should have the informative empty-stream message only (no usage since it errored)
203+
const textChunks = chunks.filter((c) => c.type === "text")
204+
expect(textChunks).toHaveLength(1)
205+
expect(textChunks[0]).toMatchObject({
206+
type: "text",
207+
text: expect.stringContaining("empty response"),
208+
})
209+
})
210+
211+
it("should re-throw NoOutputGeneratedError when text content was yielded", async () => {
212+
// Stream yields text content but usage still throws NoOutputGeneratedError (unexpected)
213+
const mockFullStream = (async function* () {
214+
yield { type: "text-delta", text: "Hello" }
215+
})()
216+
217+
mockStreamText.mockReturnValue({
218+
fullStream: mockFullStream,
219+
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
220+
providerMetadata: Promise.resolve({}),
221+
})
222+
223+
const stream = handler.createMessage(systemPrompt, mockMessages)
224+
225+
await expect(async () => {
226+
for await (const _chunk of stream) {
227+
// consume stream
228+
}
229+
}).rejects.toThrow()
230+
})
231+
152232
it("should handle API errors", async () => {
153233
const mockError = new Error("Gemini API error")
154234
// eslint-disable-next-line require-yield

src/api/providers/gemini.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Anthropic } from "@anthropic-ai/sdk"
22
import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google"
3-
import { streamText, generateText, ToolSet } from "ai"
3+
import { streamText, generateText, NoOutputGeneratedError, ToolSet } from "ai"
44

55
import {
66
type ModelInfo,
@@ -131,6 +131,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
131131
// Use streamText for streaming responses
132132
const result = streamText(requestOptions)
133133

134+
// Track whether any text content was yielded (not just reasoning/thinking)
135+
let hasContent = false
136+
134137
// Process the full stream to get all events including reasoning
135138
for await (const part of result.fullStream) {
136139
// Capture thoughtSignature from tool-call events (Gemini 3 thought signatures)
@@ -143,10 +146,21 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
143146
}
144147

145148
for (const chunk of processAiSdkStreamPart(part)) {
149+
if (chunk.type === "text") {
150+
hasContent = true
151+
}
146152
yield chunk
147153
}
148154
}
149155

156+
// If the stream completed without yielding any text content, inform the user
157+
if (!hasContent) {
158+
yield {
159+
type: "text" as const,
160+
text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.",
161+
}
162+
}
163+
150164
// Extract grounding sources from providerMetadata if available
151165
const providerMetadata = await result.providerMetadata
152166
const groundingMetadata = providerMetadata?.google as
@@ -167,9 +181,23 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
167181
}
168182

169183
// Yield usage metrics at the end
170-
const usage = await result.usage
171-
if (usage) {
172-
yield this.processUsageMetrics(usage, info, providerMetadata)
184+
// Wrap in try-catch to handle NoOutputGeneratedError thrown by the AI SDK
185+
// when the stream produces no output (e.g., thinking-only, safety block)
186+
try {
187+
const usage = await result.usage
188+
if (usage) {
189+
yield this.processUsageMetrics(usage, info, providerMetadata)
190+
}
191+
} catch (usageError) {
192+
if (usageError instanceof NoOutputGeneratedError) {
193+
// If we already yielded the empty-stream message, suppress this error
194+
if (hasContent) {
195+
throw usageError
196+
}
197+
// Otherwise the informative message was already yielded above — no-op
198+
} else {
199+
throw usageError
200+
}
173201
}
174202
} catch (error) {
175203
const errorMessage = error instanceof Error ? error.message : String(error)

src/api/transform/__tests__/reasoning.spec.ts

Lines changed: 126 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -745,7 +745,7 @@ describe("reasoning.ts", () => {
745745
expect(result).toBeUndefined()
746746
})
747747

748-
it("should return undefined for none effort (invalid for Gemini)", () => {
748+
it("should fall back to model default for none effort (invalid for Gemini but model has default)", () => {
749749
const geminiModel: ModelInfo = {
750750
...baseModel,
751751
supportsReasoningEffort: ["minimal", "low", "medium", "high"] as ModelInfo["supportsReasoningEffort"],
@@ -764,8 +764,9 @@ describe("reasoning.ts", () => {
764764
settings,
765765
}
766766

767-
const result = getGeminiReasoning(options)
768-
expect(result).toBeUndefined()
767+
const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined
768+
// "none" is not in ["minimal", "low", "medium", "high"], falls back to model.reasoningEffort "low"
769+
expect(result).toEqual({ thinkingLevel: "low", includeThoughts: true })
769770
})
770771

771772
it("should use thinkingBudget for budget-based models", () => {
@@ -838,6 +839,128 @@ describe("reasoning.ts", () => {
838839
const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined
839840
expect(result).toEqual({ thinkingLevel: "medium", includeThoughts: true })
840841
})
842+
843+
it("should fall back to model default when settings effort is not in supportsReasoningEffort array", () => {
844+
// Simulates gemini-3-pro-preview which only supports ["low", "high"]
845+
// but user has reasoningEffort: "medium" from a different model
846+
const geminiModel: ModelInfo = {
847+
...baseModel,
848+
supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"],
849+
reasoningEffort: "low",
850+
}
851+
852+
const settings: ProviderSettings = {
853+
apiProvider: "gemini",
854+
reasoningEffort: "medium",
855+
}
856+
857+
const options: GetModelReasoningOptions = {
858+
model: geminiModel,
859+
reasoningBudget: undefined,
860+
reasoningEffort: "medium",
861+
settings,
862+
}
863+
864+
const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined
865+
// "medium" is not in ["low", "high"], so falls back to model.reasoningEffort "low"
866+
expect(result).toEqual({ thinkingLevel: "low", includeThoughts: true })
867+
})
868+
869+
it("should return undefined when unsupported effort and model default is also invalid", () => {
870+
const geminiModel: ModelInfo = {
871+
...baseModel,
872+
supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"],
873+
// No reasoningEffort default set
874+
}
875+
876+
const settings: ProviderSettings = {
877+
apiProvider: "gemini",
878+
reasoningEffort: "medium",
879+
}
880+
881+
const options: GetModelReasoningOptions = {
882+
model: geminiModel,
883+
reasoningBudget: undefined,
884+
reasoningEffort: "medium",
885+
settings,
886+
}
887+
888+
const result = getGeminiReasoning(options)
889+
// "medium" is not in ["low", "high"], fallback is undefined → returns undefined
890+
expect(result).toBeUndefined()
891+
})
892+
893+
it("should pass through effort that IS in the supportsReasoningEffort array", () => {
894+
const geminiModel: ModelInfo = {
895+
...baseModel,
896+
supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"],
897+
reasoningEffort: "low",
898+
}
899+
900+
const settings: ProviderSettings = {
901+
apiProvider: "gemini",
902+
reasoningEffort: "high",
903+
}
904+
905+
const options: GetModelReasoningOptions = {
906+
model: geminiModel,
907+
reasoningBudget: undefined,
908+
reasoningEffort: "high",
909+
settings,
910+
}
911+
912+
const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined
913+
// "high" IS in ["low", "high"], so it should be used directly
914+
expect(result).toEqual({ thinkingLevel: "high", includeThoughts: true })
915+
})
916+
917+
it("should skip validation when supportsReasoningEffort is boolean (not array)", () => {
918+
const geminiModel: ModelInfo = {
919+
...baseModel,
920+
supportsReasoningEffort: true,
921+
reasoningEffort: "low",
922+
}
923+
924+
const settings: ProviderSettings = {
925+
apiProvider: "gemini",
926+
reasoningEffort: "medium",
927+
}
928+
929+
const options: GetModelReasoningOptions = {
930+
model: geminiModel,
931+
reasoningBudget: undefined,
932+
reasoningEffort: "medium",
933+
settings,
934+
}
935+
936+
const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined
937+
// boolean supportsReasoningEffort should not trigger array validation
938+
expect(result).toEqual({ thinkingLevel: "medium", includeThoughts: true })
939+
})
940+
941+
it("should fall back to model default when settings has 'minimal' but model only supports ['low', 'high']", () => {
942+
const geminiModel: ModelInfo = {
943+
...baseModel,
944+
supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"],
945+
reasoningEffort: "low",
946+
}
947+
948+
const settings: ProviderSettings = {
949+
apiProvider: "gemini",
950+
reasoningEffort: "minimal",
951+
}
952+
953+
const options: GetModelReasoningOptions = {
954+
model: geminiModel,
955+
reasoningBudget: undefined,
956+
reasoningEffort: "minimal",
957+
settings,
958+
}
959+
960+
const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined
961+
// "minimal" is not in ["low", "high"], falls back to "low"
962+
expect(result).toEqual({ thinkingLevel: "low", includeThoughts: true })
963+
})
841964
})
842965

843966
describe("Integration scenarios", () => {

src/api/transform/reasoning.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,18 @@ export const getGeminiReasoning = ({
150150
return undefined
151151
}
152152

153+
// Validate that the selected effort is supported by this specific model.
154+
// e.g. gemini-3-pro-preview only supports ["low", "high"] — sending
155+
// "medium" (carried over from a different model's settings) causes errors.
156+
const effortToUse =
157+
Array.isArray(model.supportsReasoningEffort) && !model.supportsReasoningEffort.includes(selectedEffort)
158+
? model.reasoningEffort
159+
: selectedEffort
160+
153161
// Effort-based models on Google GenAI support minimal/low/medium/high levels.
154-
if (!isGeminiThinkingLevel(selectedEffort)) {
162+
if (!effortToUse || !isGeminiThinkingLevel(effortToUse)) {
155163
return undefined
156164
}
157165

158-
return { thinkingLevel: selectedEffort, includeThoughts: true }
166+
return { thinkingLevel: effortToUse, includeThoughts: true }
159167
}

0 commit comments

Comments
 (0)