From 5d2b7cabe8fa493c126e1c66a41977fae8ac715e Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Tue, 20 Oct 2026 15:30:07 -0400 Subject: [PATCH 1/3] fix(proxy): return the upstream error when a collapsed Responses stream fails When the ChatGPT sign-in path forces a streamed Responses request for a non-streaming call, the stream is collapsed back into one message. Only response.completed and response.incomplete were read; response.failed and error events were dropped, so a failed generation came back as an empty end_turn reply with HTTP 200. The streaming translator already turns both events into an error event. The collapse now throws OpenAIResponsesStreamError for them when no final response arrived, and the fetch wrapper answers with an api_error body and status 502 like its other upstream failures. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++ src/server/__tests__/proxy-streaming.test.ts | 46 ++++++++++++++++++- ...penaiResponsesStreamToAnthropicResponse.ts | 19 ++++++++ src/services/openaiAuth/fetch.test.ts | 27 +++++++++++ src/services/openaiAuth/fetch.ts | 26 ++++++++--- 5 files changed, 115 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da686c0a..a408343a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to DreamCoder will be documented in this file. ## [Unreleased] +### Fixed + +- **Proxy**: when the ChatGPT sign-in path streams a non-streaming request and the upstream ends with `response.failed` or `error`, the caller now gets an `api_error` (HTTP 502) with the upstream message, instead of an empty `end_turn` reply. Thanks @pcbeingused333. + ## [0.6.1] — 2026-10-21 ### Added diff --git a/src/server/__tests__/proxy-streaming.test.ts b/src/server/__tests__/proxy-streaming.test.ts index 29c77c91..cf74b0a4 100644 --- a/src/server/__tests__/proxy-streaming.test.ts +++ b/src/server/__tests__/proxy-streaming.test.ts @@ -1,7 +1,10 @@ import { describe, test, expect } from 'bun:test' import { openaiChatStreamToAnthropic } from '../proxy/streaming/openaiChatStreamToAnthropic.js' import { openaiResponsesStreamToAnthropic } from '../proxy/streaming/openaiResponsesStreamToAnthropic.js' -import { openaiResponsesStreamToAnthropicResponse } from '../proxy/streaming/openaiResponsesStreamToAnthropicResponse.js' +import { + OpenAIResponsesStreamError, + openaiResponsesStreamToAnthropicResponse, +} from '../proxy/streaming/openaiResponsesStreamToAnthropicResponse.js' function makeStream(chunks: string[]): ReadableStream { const encoder = new TextEncoder() @@ -566,6 +569,47 @@ describe('openaiResponsesStreamToAnthropicResponse', () => { }) }) +describe('openaiResponsesStreamToAnthropicResponse upstream errors', () => { + test.each([ + [ + 'response.failed', + { type: 'response.failed', response: { id: 'resp_1', status: 'failed', error: { message: 'model overloaded' } } }, + 'model overloaded', + ], + ['error', { type: 'error', error: { message: 'rate limited' } }, 'rate limited'], + ['error', { type: 'error' }, 'Upstream Responses stream failed'], + ])('rejects on a %s event instead of returning an empty reply', async (event, payload, message) => { + const result = openaiResponsesStreamToAnthropicResponse( + makeStream([ + 'event: response.created\ndata: {"type":"response.created","id":"resp_1","status":"in_progress"}\n\n', + `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`, + ]), + 'm', + ) + + await expect(result).rejects.toBeInstanceOf(OpenAIResponsesStreamError) + await expect(result).rejects.toThrow(message) + }) + + test('keeps a completed response that follows an earlier error event', async () => { + const response = { + id: 'resp_1', + model: 'm', + status: 'completed', + output: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'ok' }] }], + } + const result = await openaiResponsesStreamToAnthropicResponse( + makeStream([ + 'event: error\ndata: {"type":"error","error":{"message":"transient"}}\n\n', + `event: response.completed\ndata: ${JSON.stringify({ type: 'response.completed', response })}\n\n`, + ]), + 'm', + ) + + expect(result.content).toEqual([{ type: 'text', text: 'ok' }]) + }) +}) + describe('stream cancellation', () => { test.each([ ['openaiChatStreamToAnthropic', openaiChatStreamToAnthropic, 'data: {"id":"c","object":"chat.completion.chunk","created":0,"model":"m","choices":[{"index":0,"delta":{"content":"x"},"finish_reason":null}]}\n\n'], diff --git a/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts index 42c43ec7..c7f5bfa4 100644 --- a/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts @@ -13,6 +13,9 @@ type StreamFallbackState = { usage: OpenAIResponsesResponse['usage'] } +/** The upstream Responses stream ended with `response.failed` or `error`. */ +export class OpenAIResponsesStreamError extends Error {} + export async function openaiResponsesStreamToAnthropicResponse( upstream: ReadableStream, model: string, @@ -22,6 +25,7 @@ export async function openaiResponsesStreamToAnthropicResponse( let buffer = '' let currentEvent = '' let completedResponse: OpenAIResponsesResponse | null = null + let upstreamError: string | null = null const fallback: StreamFallbackState = { id: `resp_${Date.now()}`, @@ -71,6 +75,8 @@ export async function openaiResponsesStreamToAnthropicResponse( if (isOpenAIResponsesResponse(response)) { completedResponse = response } + } else if (currentEvent === 'response.failed' || currentEvent === 'error') { + upstreamError = upstreamErrorMessage(data) } else { updateFallbackState(currentEvent, data, fallback) } @@ -78,12 +84,25 @@ export async function openaiResponsesStreamToAnthropicResponse( } } + if (!completedResponse && upstreamError !== null) { + throw new OpenAIResponsesStreamError(upstreamError) + } + return openaiResponsesToAnthropic( completedResponse ?? buildFallbackResponse(fallback), model, ) } +// Same message lookup as the streaming converter's `error` / `response.failed` case. +function upstreamErrorMessage(data: Record): string { + const response = data.response as Record | undefined + const error = (response?.error ?? data.error ?? data) as Record | undefined + return typeof error?.message === 'string' && error.message + ? error.message + : 'Upstream Responses stream failed' +} + function updateFallbackState( event: string, data: Record, diff --git a/src/services/openaiAuth/fetch.test.ts b/src/services/openaiAuth/fetch.test.ts index ace92bb7..ff28642e 100644 --- a/src/services/openaiAuth/fetch.test.ts +++ b/src/services/openaiAuth/fetch.test.ts @@ -130,4 +130,31 @@ describe('buildOpenAICodexFetch', () => { content: [{ type: 'text', text: 'streamed ok' }], }) }) + + test('returns an error when a streamed Codex response fails for a non-streaming caller', async () => { + const fetchOverride: typeof fetch = async () => + new Response([ + 'event: response.failed', + 'data: {"type":"response.failed","response":{"id":"resp_789","status":"failed","error":{"message":"model overloaded"}}}', + '', + ].join('\n'), { + headers: { 'Content-Type': 'text/event-stream' }, + }) + + const openAIFetch = buildOpenAICodexFetch(fetchOverride, 'test') + const response = await openAIFetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + body: JSON.stringify({ + model: 'gpt-5.5', + max_tokens: 64, + messages: [{ role: 'user', content: 'Say ok' }], + }), + }) + + expect(response.status).toBe(502) + await expect(response.json()).resolves.toEqual({ + type: 'error', + error: { type: 'api_error', message: 'model overloaded' }, + }) + }) }) diff --git a/src/services/openaiAuth/fetch.ts b/src/services/openaiAuth/fetch.ts index 35f44a27..81be5320 100644 --- a/src/services/openaiAuth/fetch.ts +++ b/src/services/openaiAuth/fetch.ts @@ -7,7 +7,10 @@ import { getOpenAIOAuthTokens } from './storage.js' import { anthropicToOpenaiResponses } from '../../server/proxy/transform/anthropicToOpenaiResponses.js' import { openaiResponsesToAnthropic } from '../../server/proxy/transform/openaiResponsesToAnthropic.js' import { openaiResponsesStreamToAnthropic } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropic.js' -import { openaiResponsesStreamToAnthropicResponse } from '../../server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.js' +import { + OpenAIResponsesStreamError, + openaiResponsesStreamToAnthropicResponse, +} from '../../server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.js' import type { AnthropicRequest } from '../../server/proxy/transform/types.js' import { logForDebugging } from '../../utils/debug.js' @@ -109,11 +112,22 @@ export function buildOpenAICodexFetch( } if (upstream.body && isEventStreamResponse(upstream)) { - const responseBody = await openaiResponsesStreamToAnthropicResponse( - upstream.body, - mappedModel, - ) - return Response.json(responseBody) + try { + const responseBody = await openaiResponsesStreamToAnthropicResponse( + upstream.body, + mappedModel, + ) + return Response.json(responseBody) + } catch (error) { + if (!(error instanceof OpenAIResponsesStreamError)) throw error + return Response.json( + { + type: 'error', + error: { type: 'api_error', message: error.message }, + }, + { status: 502 }, + ) + } } const responseBody = await upstream.json() From 48e4259ebf6c4c76c6973c72d2d149adb09a3ec7 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Tue, 20 Oct 2026 15:51:59 -0400 Subject: [PATCH 2/3] docs(changelog): add the PR number Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a408343a..79af6359 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to DreamCoder will be documented in this file. ### Fixed -- **Proxy**: when the ChatGPT sign-in path streams a non-streaming request and the upstream ends with `response.failed` or `error`, the caller now gets an `api_error` (HTTP 502) with the upstream message, instead of an empty `end_turn` reply. Thanks @pcbeingused333. +- **Proxy** (#148): when the ChatGPT sign-in path streams a non-streaming request and the upstream ends with `response.failed` or `error`, the caller now gets an `api_error` (HTTP 502) with the upstream message, instead of an empty `end_turn` reply. Thanks @pcbeingused333. ## [0.6.1] — 2026-10-21 From e1152ec14acc45633e10a9ae77ba38ad73aa3c57 Mon Sep 17 00:00:00 2001 From: Alex Castillo Date: Fri, 23 Oct 2026 23:18:39 -0400 Subject: [PATCH 3/3] refactor(proxy): share the upstream error lookup between the streaming and non-streaming converters Co-Authored-By: Claude Sonnet 5.5 --- .../streaming/openaiResponsesStreamToAnthropic.ts | 14 +++++++++----- .../openaiResponsesStreamToAnthropicResponse.ts | 11 +---------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts index 57f7798b..f869f7c8 100644 --- a/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropic.ts @@ -12,6 +12,14 @@ type StreamState = { messageStopped: boolean } +export function upstreamErrorMessage(data: Record): string { + const response = data.response as Record | undefined + const error = (response?.error ?? data.error ?? data) as Record | undefined + return typeof error?.message === 'string' && error.message + ? error.message + : 'Upstream Responses stream failed' +} + function formatSse(event: string, data: unknown): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n` } @@ -298,11 +306,7 @@ function processEvent( case 'response.failed': case 'error': { - const response = data.response as Record | undefined - const error = (response?.error ?? data.error ?? data) as Record | undefined - const message = typeof error?.message === 'string' && error.message - ? error.message - : 'Upstream Responses stream failed' + const message = upstreamErrorMessage(data) state.messageStopped = true controller.enqueue(encoder.encode(formatSse('error', { type: 'error', diff --git a/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts b/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts index c7f5bfa4..9ecd8b88 100644 --- a/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts +++ b/src/server/proxy/streaming/openaiResponsesStreamToAnthropicResponse.ts @@ -1,4 +1,5 @@ import { openaiResponsesToAnthropic } from '../transform/openaiResponsesToAnthropic.js' +import { upstreamErrorMessage } from './openaiResponsesStreamToAnthropic.js' import type { AnthropicResponse, OpenAIResponsesResponse, @@ -13,7 +14,6 @@ type StreamFallbackState = { usage: OpenAIResponsesResponse['usage'] } -/** The upstream Responses stream ended with `response.failed` or `error`. */ export class OpenAIResponsesStreamError extends Error {} export async function openaiResponsesStreamToAnthropicResponse( @@ -94,15 +94,6 @@ export async function openaiResponsesStreamToAnthropicResponse( ) } -// Same message lookup as the streaming converter's `error` / `response.failed` case. -function upstreamErrorMessage(data: Record): string { - const response = data.response as Record | undefined - const error = (response?.error ?? data.error ?? data) as Record | undefined - return typeof error?.message === 'string' && error.message - ? error.message - : 'Upstream Responses stream failed' -} - function updateFallbackState( event: string, data: Record,