From ea2f4080f8e6ac20e39dcb75024b603939c2f304 Mon Sep 17 00:00:00 2001 From: Eric Li Date: Fri, 28 Feb 2025 09:21:00 +0800 Subject: [PATCH] opt: Enhance OpenAiHandler to diagnose request issues and handle empty streams (#1702) --- .changeset/forty-timers-kick.md | 5 ++ src/api/providers/openai.ts | 102 +++++++++++++++++++++++++------- 2 files changed, 86 insertions(+), 21 deletions(-) create mode 100644 .changeset/forty-timers-kick.md diff --git a/.changeset/forty-timers-kick.md b/.changeset/forty-timers-kick.md new file mode 100644 index 0000000000..f757269ca6 --- /dev/null +++ b/.changeset/forty-timers-kick.md @@ -0,0 +1,5 @@ +--- +"claude-dev": patch +--- + +opt: Enhance OpenAiHandler to diagnose request issues and handle empty streams diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 7309d5bb98..de514c6b65 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -28,6 +28,65 @@ export class OpenAiHandler implements ApiHandler { } } + private async diagnoseRequestProblem( + modelId: string, + messages: OpenAI.Chat.ChatCompletionMessageParam[], + apiKey: string, + baseURL: string, + ) { + const url = `${baseURL}/chat/completions` + + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: modelId, + messages: messages, + temperature: 0, + stream: true, + }), + }) + + if (!response.ok) { + return `HTTP error! status: ${response.status}, statusText: ${response.statusText}` + } + + const responseData = await response.json() + return responseData + } catch (error) { + return error instanceof Error ? error.message : String(error) + } + } + + private async *handleChunk(chunk: OpenAI.Chat.Completions.ChatCompletionChunk): ApiStream { + const delta = chunk.choices[0]?.delta + if (delta?.content) { + yield { + type: "text", + text: delta.content, + } + } + + if (delta && "reasoning_content" in delta && delta.reasoning_content) { + yield { + type: "reasoning", + reasoning: (delta.reasoning_content as string | undefined) || "", + } + } + + if (chunk.usage) { + yield { + type: "usage", + inputTokens: chunk.usage.prompt_tokens || 0, + outputTokens: chunk.usage.completion_tokens || 0, + } + } + } + @withRetry() async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream { const modelId = this.options.openAiModelId ?? "" @@ -49,29 +108,30 @@ export class OpenAiHandler implements ApiHandler { stream: true, stream_options: { include_usage: true }, }) - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - if (delta?.content) { - yield { - type: "text", - text: delta.content, - } - } - if (delta && "reasoning_content" in delta && delta.reasoning_content) { - yield { - type: "reasoning", - reasoning: (delta.reasoning_content as string | undefined) || "", - } - } + const [validationStream, contentStream] = stream.tee() - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - } - } + // Check the first chunk to detect potential stream issues early + // This helps to provide better error messages for cases like: + // https://github.com/cline/cline/issues/1662 + // where the stream appears valid but contains no actual data + const firstChunk = await validationStream[Symbol.asyncIterator]().next() + if (firstChunk.done || !firstChunk.value) { + // Make an additional request to get detailed error information + // This gives us more context about what went wrong with the API call + const errorResponse = await this.diagnoseRequestProblem( + modelId, + openAiMessages, + this.client.apiKey, + this.client.baseURL, + ) + throw new Error(`Stream empty. Error details: ${JSON.stringify(errorResponse)}`) + } + + yield* this.handleChunk(firstChunk.value) + + for await (const chunk of contentStream) { + yield* this.handleChunk(chunk) } }