Skip to content

Commit e24d1aa

Browse files
committed
fix: handle reasoning-only responses from Gemini 2.5 Pro
- Add fallback text when Gemini 2.5 Pro returns only reasoning/thinking content - Prevents "no assistant messages" error for models with requiredReasoningBudget - Add comprehensive test coverage for reasoning-only and mixed content scenarios Fixes #6999
1 parent 1d8b51d commit e24d1aa

File tree

2 files changed

+133
-1
lines changed

2 files changed

+133
-1
lines changed

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

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,95 @@ describe("GeminiHandler", () => {
9090
)
9191
})
9292

93+
it("should handle reasoning-only responses for Gemini 2.5 Pro", async () => {
94+
// Mock a response with only reasoning/thinking content
95+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
96+
[Symbol.asyncIterator]: async function* () {
97+
yield {
98+
candidates: [
99+
{
100+
content: {
101+
parts: [
102+
{
103+
thought: true,
104+
text: "Let me think about this problem step by step...",
105+
},
106+
],
107+
},
108+
},
109+
],
110+
}
111+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 20, thoughtsTokenCount: 15 } }
112+
},
113+
})
114+
115+
const stream = handler.createMessage(systemPrompt, mockMessages)
116+
const chunks = []
117+
118+
for await (const chunk of stream) {
119+
chunks.push(chunk)
120+
}
121+
122+
// Should have reasoning chunk, text chunk (fallback), and usage
123+
expect(chunks.length).toBe(3)
124+
expect(chunks[0]).toEqual({ type: "reasoning", text: "Let me think about this problem step by step..." })
125+
expect(chunks[1]).toEqual({ type: "text", text: "[Thinking process completed]" })
126+
expect(chunks[2]).toEqual({
127+
type: "usage",
128+
inputTokens: 10,
129+
outputTokens: 20,
130+
cacheReadTokens: undefined,
131+
reasoningTokens: 15,
132+
totalCost: undefined,
133+
})
134+
})
135+
136+
it("should handle mixed reasoning and text content", async () => {
137+
// Mock a response with both reasoning and text content
138+
;(handler["client"].models.generateContentStream as any).mockResolvedValue({
139+
[Symbol.asyncIterator]: async function* () {
140+
yield {
141+
candidates: [
142+
{
143+
content: {
144+
parts: [
145+
{
146+
thought: true,
147+
text: "Analyzing the request...",
148+
},
149+
{
150+
text: "Here is my response",
151+
},
152+
],
153+
},
154+
},
155+
],
156+
}
157+
yield { usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 25 } }
158+
},
159+
})
160+
161+
const stream = handler.createMessage(systemPrompt, mockMessages)
162+
const chunks = []
163+
164+
for await (const chunk of stream) {
165+
chunks.push(chunk)
166+
}
167+
168+
// Should have reasoning, text, and usage chunks (no fallback text needed)
169+
expect(chunks.length).toBe(3)
170+
expect(chunks[0]).toEqual({ type: "reasoning", text: "Analyzing the request..." })
171+
expect(chunks[1]).toEqual({ type: "text", text: "Here is my response" })
172+
expect(chunks[2]).toEqual({
173+
type: "usage",
174+
inputTokens: 10,
175+
outputTokens: 25,
176+
cacheReadTokens: undefined,
177+
reasoningTokens: undefined,
178+
totalCost: undefined,
179+
})
180+
})
181+
93182
it("should handle API errors", async () => {
94183
const mockError = new Error("Gemini API error")
95184
;(handler["client"].models.generateContentStream as any).mockRejectedValue(mockError)
@@ -143,6 +232,28 @@ describe("GeminiHandler", () => {
143232
const result = await handler.completePrompt("Test prompt")
144233
expect(result).toBe("")
145234
})
235+
236+
it("should handle reasoning-only response in completePrompt", async () => {
237+
// Mock a response with only reasoning/thinking content and no text
238+
;(handler["client"].models.generateContent as any).mockResolvedValue({
239+
text: "",
240+
candidates: [
241+
{
242+
content: {
243+
parts: [
244+
{
245+
thought: true,
246+
text: "Let me analyze this request...",
247+
},
248+
],
249+
},
250+
},
251+
],
252+
})
253+
254+
const result = await handler.completePrompt("Test prompt")
255+
expect(result).toBe("[Thinking process completed]")
256+
})
146257
})
147258

148259
describe("getModel", () => {

src/api/providers/gemini.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
9494

9595
let lastUsageMetadata: GenerateContentResponseUsageMetadata | undefined
9696
let pendingGroundingMetadata: GroundingMetadata | undefined
97+
let hasTextContent = false
98+
let hasReasoningContent = false
9799

98100
for await (const chunk of result) {
99101
// Process candidates and their parts to separate thoughts from content
@@ -110,11 +112,13 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
110112
// This is a thinking/reasoning part
111113
if (part.text) {
112114
yield { type: "reasoning", text: part.text }
115+
hasReasoningContent = true
113116
}
114117
} else {
115118
// This is regular content
116119
if (part.text) {
117120
yield { type: "text", text: part.text }
121+
hasTextContent = true
118122
}
119123
}
120124
}
@@ -124,13 +128,20 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
124128
// Fallback to the original text property if no candidates structure
125129
else if (chunk.text) {
126130
yield { type: "text", text: chunk.text }
131+
hasTextContent = true
127132
}
128133

129134
if (chunk.usageMetadata) {
130135
lastUsageMetadata = chunk.usageMetadata
131136
}
132137
}
133138

139+
// If we only got reasoning content but no text content, yield a minimal text response
140+
// This prevents the "no assistant messages" error for Gemini 2.5 Pro with reasoning
141+
if (hasReasoningContent && !hasTextContent) {
142+
yield { type: "text", text: "[Thinking process completed]" }
143+
}
144+
134145
if (pendingGroundingMetadata) {
135146
const citations = this.extractCitationsOnly(pendingGroundingMetadata)
136147
if (citations) {
@@ -201,7 +212,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
201212

202213
async completePrompt(prompt: string): Promise<string> {
203214
try {
204-
const { id: model } = this.getModel()
215+
const { id: model, reasoning: thinkingConfig } = this.getModel()
205216

206217
const tools: GenerateContentConfig["tools"] = []
207218
if (this.options.enableUrlContext) {
@@ -215,6 +226,7 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
215226
? { baseUrl: this.options.googleGeminiBaseUrl }
216227
: undefined,
217228
temperature: this.options.modelTemperature ?? 0,
229+
thinkingConfig,
218230
...(tools.length > 0 ? { tools } : {}),
219231
}
220232

@@ -226,6 +238,15 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl
226238

227239
let text = result.text ?? ""
228240

241+
// Handle case where model only returns reasoning/thinking content
242+
// This can happen with Gemini 2.5 Pro when reasoning is enabled
243+
if (!text && result.candidates?.[0]?.content?.parts) {
244+
const hasThoughts = result.candidates[0].content.parts.some((part: any) => part.thought)
245+
if (hasThoughts) {
246+
text = "[Thinking process completed]"
247+
}
248+
}
249+
229250
const candidate = result.candidates?.[0]
230251
if (candidate?.groundingMetadata) {
231252
const citations = this.extractCitationsOnly(candidate.groundingMetadata)

0 commit comments

Comments
 (0)