Skip to content

Commit 8663724

Browse files
committed
fix: handle empty stream responses from GLM models
- Add graceful handling for empty API responses in Task.ts - Implement GLM-specific fallback responses in OpenAI and base providers - Add retry logic for GLM models when empty streams occur - Add comprehensive tests for empty stream scenarios Fixes #8482
1 parent 7ba8e33 commit 8663724

File tree

4 files changed

+272
-10
lines changed

4 files changed

+272
-10
lines changed
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import { OpenAiHandler } from "../openai"
3+
import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider"
4+
5+
describe("GLM Empty Stream Handling", () => {
6+
describe("OpenAiHandler", () => {
7+
it("should provide fallback response for GLM models with empty streams", async () => {
8+
const mockClient = {
9+
chat: {
10+
completions: {
11+
create: vi.fn().mockImplementation(async function* () {
12+
// Simulate empty stream - only usage, no content
13+
yield {
14+
choices: [{ delta: {} }],
15+
usage: {
16+
prompt_tokens: 100,
17+
completion_tokens: 0,
18+
},
19+
}
20+
}),
21+
},
22+
},
23+
}
24+
25+
const handler = new OpenAiHandler({
26+
openAiApiKey: "test-key",
27+
openAiModelId: "glm-4.6",
28+
openAiStreamingEnabled: true,
29+
})
30+
31+
// Replace the client with our mock
32+
;(handler as any).client = mockClient
33+
34+
const chunks = []
35+
const stream = handler.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }])
36+
37+
for await (const chunk of stream) {
38+
chunks.push(chunk)
39+
}
40+
41+
// Should have a fallback text response
42+
const textChunks = chunks.filter((c) => c.type === "text")
43+
expect(textChunks).toHaveLength(1)
44+
expect(textChunks[0].text).toContain("trouble generating a response")
45+
46+
// Should still have usage metrics
47+
const usageChunks = chunks.filter((c) => c.type === "usage")
48+
expect(usageChunks).toHaveLength(1)
49+
})
50+
51+
it("should not provide fallback for non-GLM models with empty streams", async () => {
52+
const mockClient = {
53+
chat: {
54+
completions: {
55+
create: vi.fn().mockImplementation(async function* () {
56+
// Simulate empty stream - only usage, no content
57+
yield {
58+
choices: [{ delta: {} }],
59+
usage: {
60+
prompt_tokens: 100,
61+
completion_tokens: 0,
62+
},
63+
}
64+
}),
65+
},
66+
},
67+
}
68+
69+
const handler = new OpenAiHandler({
70+
openAiApiKey: "test-key",
71+
openAiModelId: "gpt-4",
72+
openAiStreamingEnabled: true,
73+
})
74+
75+
// Replace the client with our mock
76+
;(handler as any).client = mockClient
77+
78+
const chunks = []
79+
const stream = handler.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }])
80+
81+
for await (const chunk of stream) {
82+
chunks.push(chunk)
83+
}
84+
85+
// Should NOT have a fallback text response for non-GLM models
86+
const textChunks = chunks.filter((c) => c.type === "text")
87+
expect(textChunks).toHaveLength(0)
88+
89+
// Should still have usage metrics
90+
const usageChunks = chunks.filter((c) => c.type === "usage")
91+
expect(usageChunks).toHaveLength(1)
92+
})
93+
})
94+
95+
describe("BaseOpenAiCompatibleProvider", () => {
96+
class TestProvider extends BaseOpenAiCompatibleProvider<"glm-4.6" | "other-model"> {
97+
constructor(modelId: "glm-4.6" | "other-model") {
98+
super({
99+
providerName: "Test",
100+
baseURL: "https://test.com",
101+
apiKey: "test-key",
102+
defaultProviderModelId: modelId,
103+
providerModels: {
104+
"glm-4.6": {
105+
maxTokens: 4096,
106+
contextWindow: 8192,
107+
supportsPromptCache: false,
108+
inputPrice: 0,
109+
outputPrice: 0,
110+
},
111+
"other-model": {
112+
maxTokens: 4096,
113+
contextWindow: 8192,
114+
supportsPromptCache: false,
115+
inputPrice: 0,
116+
outputPrice: 0,
117+
},
118+
},
119+
apiModelId: modelId,
120+
})
121+
}
122+
}
123+
124+
it("should provide fallback response for GLM models with empty streams", async () => {
125+
const provider = new TestProvider("glm-4.6")
126+
127+
// Mock the client
128+
const mockClient = {
129+
chat: {
130+
completions: {
131+
create: vi.fn().mockImplementation(async function* () {
132+
// Simulate empty stream
133+
yield {
134+
choices: [{ delta: {} }],
135+
usage: {
136+
prompt_tokens: 100,
137+
completion_tokens: 0,
138+
},
139+
}
140+
}),
141+
},
142+
},
143+
}
144+
;(provider as any).client = mockClient
145+
146+
const chunks = []
147+
const stream = provider.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }])
148+
149+
for await (const chunk of stream) {
150+
chunks.push(chunk)
151+
}
152+
153+
// Should have a fallback text response
154+
const textChunks = chunks.filter((c) => c.type === "text")
155+
expect(textChunks).toHaveLength(1)
156+
expect(textChunks[0].text).toContain("trouble generating a response")
157+
})
158+
159+
it("should not provide fallback for non-GLM models", async () => {
160+
const provider = new TestProvider("other-model")
161+
162+
// Mock the client
163+
const mockClient = {
164+
chat: {
165+
completions: {
166+
create: vi.fn().mockImplementation(async function* () {
167+
// Simulate empty stream
168+
yield {
169+
choices: [{ delta: {} }],
170+
usage: {
171+
prompt_tokens: 100,
172+
completion_tokens: 0,
173+
},
174+
}
175+
}),
176+
},
177+
},
178+
}
179+
;(provider as any).client = mockClient
180+
181+
const chunks = []
182+
const stream = provider.createMessage("You are a helpful assistant", [{ role: "user", content: "Hello" }])
183+
184+
for await (const chunk of stream) {
185+
chunks.push(chunk)
186+
}
187+
188+
// Should NOT have a fallback text response
189+
const textChunks = chunks.filter((c) => c.type === "text")
190+
expect(textChunks).toHaveLength(0)
191+
})
192+
})
193+
})

src/api/providers/base-openai-compatible-provider.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,14 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
9898
metadata?: ApiHandlerCreateMessageMetadata,
9999
): ApiStream {
100100
const stream = await this.createStream(systemPrompt, messages, metadata)
101+
let hasContent = false
102+
const modelId = this.getModel().id
101103

102104
for await (const chunk of stream) {
103105
const delta = chunk.choices[0]?.delta
104106

105107
if (delta?.content) {
108+
hasContent = true
106109
yield {
107110
type: "text",
108111
text: delta.content,
@@ -117,6 +120,18 @@ export abstract class BaseOpenAiCompatibleProvider<ModelName extends string>
117120
}
118121
}
119122
}
123+
124+
// For GLM models that may return empty streams, provide a fallback response
125+
if (
126+
!hasContent &&
127+
modelId &&
128+
(modelId.toLowerCase().includes("glm") || modelId.toLowerCase().includes("chatglm"))
129+
) {
130+
yield {
131+
type: "text",
132+
text: "I'm having trouble generating a response. Please try rephrasing your request or breaking it down into smaller steps.",
133+
}
134+
}
120135
}
121136

122137
async completePrompt(prompt: string): Promise<string> {

src/api/providers/openai.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,17 +189,20 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
189189
)
190190

191191
let lastUsage
192+
let hasContent = false
192193

193194
for await (const chunk of stream) {
194195
const delta = chunk.choices[0]?.delta ?? {}
195196

196197
if (delta.content) {
198+
hasContent = true
197199
for (const chunk of matcher.update(delta.content)) {
198200
yield chunk
199201
}
200202
}
201203

202204
if ("reasoning_content" in delta && delta.reasoning_content) {
205+
hasContent = true
203206
yield {
204207
type: "reasoning",
205208
text: (delta.reasoning_content as string | undefined) || "",
@@ -211,9 +214,22 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
211214
}
212215

213216
for (const chunk of matcher.final()) {
217+
hasContent = true
214218
yield chunk
215219
}
216220

221+
// For GLM models that may return empty streams, provide a fallback response
222+
if (
223+
!hasContent &&
224+
modelId &&
225+
(modelId.toLowerCase().includes("glm") || modelId.toLowerCase().includes("chatglm"))
226+
) {
227+
yield {
228+
type: "text",
229+
text: "I'm having trouble generating a response. Please try rephrasing your request or breaking it down into smaller steps.",
230+
}
231+
}
232+
217233
if (lastUsage) {
218234
yield this.processUsageMetrics(lastUsage, modelInfo)
219235
}

src/core/task/Task.ts

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2339,17 +2339,55 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
23392339
continue
23402340
} else {
23412341
// If there's no assistant_responses, that means we got no text
2342-
// or tool_use content blocks from API which we should assume is
2343-
// an error.
2344-
await this.say(
2345-
"error",
2346-
"Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.",
2347-
)
2342+
// or tool_use content blocks from API. This can happen with some models
2343+
// like GLM-4.6 that may return empty streams occasionally.
2344+
// Instead of treating this as a fatal error, we'll handle it gracefully.
2345+
const modelId = this.api.getModel().id
2346+
const isGLMModel =
2347+
modelId && (modelId.toLowerCase().includes("glm") || modelId.toLowerCase().includes("chatglm"))
2348+
2349+
if (isGLMModel) {
2350+
// For GLM models, treat empty response as a temporary issue and retry
2351+
await this.say(
2352+
"error",
2353+
"The GLM model returned an empty response. This can happen occasionally with GLM models. Retrying with a clarification request...",
2354+
)
23482355

2349-
await this.addToApiConversationHistory({
2350-
role: "assistant",
2351-
content: [{ type: "text", text: "Failure: I did not provide a response." }],
2352-
})
2356+
// Add a minimal assistant response to maintain conversation flow
2357+
await this.addToApiConversationHistory({
2358+
role: "assistant",
2359+
content: [
2360+
{
2361+
type: "text",
2362+
text: "I encountered an issue generating a response. Let me try again.",
2363+
},
2364+
],
2365+
})
2366+
2367+
// Add a user message prompting the model to respond
2368+
this.userMessageContent.push({
2369+
type: "text",
2370+
text: "Please provide a response to the previous request. If you're having trouble, break down the task into smaller steps.",
2371+
})
2372+
2373+
// Continue the loop to retry with the clarification
2374+
stack.push({
2375+
userContent: [...this.userMessageContent],
2376+
includeFileDetails: false,
2377+
})
2378+
continue
2379+
} else {
2380+
// For other models, log a more informative error
2381+
await this.say(
2382+
"error",
2383+
"Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output. Consider checking your API configuration or trying a different model.",
2384+
)
2385+
2386+
await this.addToApiConversationHistory({
2387+
role: "assistant",
2388+
content: [{ type: "text", text: "Failure: I did not provide a response." }],
2389+
})
2390+
}
23532391
}
23542392

23552393
// If we reach here without continuing, return false (will always be false for now)

0 commit comments

Comments
 (0)