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

Commit 766cfc3

Browse files
committed
fix: validate Gemini thinkingLevel against model capabilities and handle empty streams (#11303)
* 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. * fix: guard thinkingLevel fallback against 'none' effort and add i18n TODO The array validation fallback in getGeminiReasoning() now only triggers when the selected effort IS a valid Gemini thinking level but not in the model's supported set. Values like 'none' (explicit no-reasoning signal) are no longer overridden by the model default. Also adds a TODO for moving the empty-stream message to i18n. * fix: track tool_call_start in hasContent to avoid false empty-stream warning Tool-only responses (no text) are valid content. Without this, agentic tool-call responses would incorrectly trigger the empty response warning message.
1 parent 8c193fe commit 766cfc3

4 files changed

Lines changed: 224 additions & 2 deletions

File tree

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", () => ({
@@ -103,6 +105,84 @@ describe("GeminiHandler", () => {
103105
)
104106
})
105107

108+
it("should yield informative message when stream produces no text content", async () => {
109+
// Stream with only reasoning (no text-delta) simulates thinking-only response
110+
const mockFullStream = (async function* () {
111+
yield { type: "reasoning-delta", id: "1", text: "thinking..." }
112+
})()
113+
114+
mockStreamText.mockReturnValue({
115+
fullStream: mockFullStream,
116+
usage: Promise.resolve({ inputTokens: 10, outputTokens: 0 }),
117+
providerMetadata: Promise.resolve({}),
118+
})
119+
120+
const stream = handler.createMessage(systemPrompt, mockMessages)
121+
const chunks = []
122+
123+
for await (const chunk of stream) {
124+
chunks.push(chunk)
125+
}
126+
127+
// Should have: reasoning chunk, empty-stream informative message, usage
128+
const textChunks = chunks.filter((c) => c.type === "text")
129+
expect(textChunks).toHaveLength(1)
130+
expect(textChunks[0]).toEqual({
131+
type: "text",
132+
text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.",
133+
})
134+
})
135+
136+
it("should suppress NoOutputGeneratedError when no text content was yielded", async () => {
137+
// Empty stream - nothing yielded at all
138+
const mockFullStream = (async function* () {
139+
// empty stream
140+
})()
141+
142+
mockStreamText.mockReturnValue({
143+
fullStream: mockFullStream,
144+
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
145+
providerMetadata: Promise.resolve({}),
146+
})
147+
148+
const stream = handler.createMessage(systemPrompt, mockMessages)
149+
const chunks = []
150+
151+
// Should NOT throw - the error is suppressed
152+
for await (const chunk of stream) {
153+
chunks.push(chunk)
154+
}
155+
156+
// Should have the informative empty-stream message only (no usage since it errored)
157+
const textChunks = chunks.filter((c) => c.type === "text")
158+
expect(textChunks).toHaveLength(1)
159+
expect(textChunks[0]).toMatchObject({
160+
type: "text",
161+
text: expect.stringContaining("empty response"),
162+
})
163+
})
164+
165+
it("should re-throw NoOutputGeneratedError when text content was yielded", async () => {
166+
// Stream yields text content but usage still throws NoOutputGeneratedError (unexpected)
167+
const mockFullStream = (async function* () {
168+
yield { type: "text-delta", text: "Hello" }
169+
})()
170+
171+
mockStreamText.mockReturnValue({
172+
fullStream: mockFullStream,
173+
usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })),
174+
providerMetadata: Promise.resolve({}),
175+
})
176+
177+
const stream = handler.createMessage(systemPrompt, mockMessages)
178+
179+
await expect(async () => {
180+
for await (const _chunk of stream) {
181+
// consume stream
182+
}
183+
}).rejects.toThrow()
184+
})
185+
106186
it("should handle API errors", async () => {
107187
const mockError = new Error("Gemini API error")
108188
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)

src/api/providers/gemini.ts

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

308+
// If the stream completed without yielding any text content, inform the user.
309+
// This may be caused by an unsupported thinking configuration or content filtering.
310+
if (!hasContent) {
311+
yield {
312+
type: "text" as const,
313+
text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.",
314+
}
315+
}
316+
308317
if (pendingGroundingMetadata) {
309318
const sources = this.extractGroundingSources(pendingGroundingMetadata)
310319
if (sources.length > 0) {

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

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -765,6 +765,7 @@ describe("reasoning.ts", () => {
765765
}
766766

767767
const result = getGeminiReasoning(options)
768+
// "none" is not a valid GeminiThinkingLevel, so no fallback — returns undefined
768769
expect(result).toBeUndefined()
769770
})
770771

@@ -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: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,10 +150,20 @@ 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) &&
158+
isGeminiThinkingLevel(selectedEffort) &&
159+
!model.supportsReasoningEffort.includes(selectedEffort)
160+
? model.reasoningEffort
161+
: selectedEffort
162+
153163
// Effort-based models on Google GenAI support minimal/low/medium/high levels.
154-
if (!isGeminiThinkingLevel(selectedEffort)) {
164+
if (!effortToUse || !isGeminiThinkingLevel(effortToUse)) {
155165
return undefined
156166
}
157167

158-
return { thinkingLevel: selectedEffort, includeThoughts: true }
168+
return { thinkingLevel: effortToUse, includeThoughts: true }
159169
}

0 commit comments

Comments
 (0)