icebear0828 Claude Opus 4.6 commited on
Commit
cf0807a
Β·
1 Parent(s): 2171187

fix: handle error SSE events from Codex API instead of silently dropping them

Browse files

When the Codex API returns `event: error` or `event: response.failed`,
the proxy now surfaces the error message to clients instead of returning
empty responses. Also detects non-SSE JSON error bodies from upstream.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

src/proxy/codex-api.ts CHANGED
@@ -302,6 +302,7 @@ export class CodexApi {
302
 
303
  const MAX_SSE_BUFFER = 10 * 1024 * 1024; // 10MB
304
  let buffer = "";
 
305
  try {
306
  while (true) {
307
  const { done, value } = await reader.read();
@@ -317,14 +318,40 @@ export class CodexApi {
317
  for (const part of parts) {
318
  if (!part.trim()) continue;
319
  const evt = this.parseSSEBlock(part);
320
- if (evt) yield evt;
 
 
 
321
  }
322
  }
323
 
324
  // Process remaining buffer
325
  if (buffer.trim()) {
326
  const evt = this.parseSSEBlock(buffer);
327
- if (evt) yield evt;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  }
329
  } finally {
330
  reader.releaseLock();
 
302
 
303
  const MAX_SSE_BUFFER = 10 * 1024 * 1024; // 10MB
304
  let buffer = "";
305
+ let yieldedAny = false;
306
  try {
307
  while (true) {
308
  const { done, value } = await reader.read();
 
318
  for (const part of parts) {
319
  if (!part.trim()) continue;
320
  const evt = this.parseSSEBlock(part);
321
+ if (evt) {
322
+ yieldedAny = true;
323
+ yield evt;
324
+ }
325
  }
326
  }
327
 
328
  // Process remaining buffer
329
  if (buffer.trim()) {
330
  const evt = this.parseSSEBlock(buffer);
331
+ if (evt) {
332
+ yieldedAny = true;
333
+ yield evt;
334
+ }
335
+ }
336
+
337
+ // Non-SSE response detection: if the entire stream yielded no SSE events
338
+ // but has content, the upstream likely returned a plain JSON error body.
339
+ if (!yieldedAny && buffer.trim()) {
340
+ let errorMessage = buffer.trim();
341
+ try {
342
+ const parsed = JSON.parse(errorMessage) as Record<string, unknown>;
343
+ const errObj = typeof parsed.error === "object" && parsed.error !== null
344
+ ? (parsed.error as Record<string, unknown>)
345
+ : undefined;
346
+ errorMessage =
347
+ (typeof parsed.detail === "string" ? parsed.detail : null)
348
+ ?? (typeof errObj?.message === "string" ? errObj.message : null)
349
+ ?? errorMessage;
350
+ } catch { /* use raw text */ }
351
+ yield {
352
+ event: "error",
353
+ data: { error: { type: "error", code: "non_sse_response", message: errorMessage } },
354
+ };
355
  }
356
  } finally {
357
  reader.releaseLock();
src/translation/codex-event-extractor.ts CHANGED
@@ -38,6 +38,7 @@ export interface ExtractedEvent {
38
  responseId?: string;
39
  textDelta?: string;
40
  usage?: UsageInfo;
 
41
  functionCallStart?: FunctionCallStart;
42
  functionCallDelta?: FunctionCallDelta;
43
  functionCallDone?: FunctionCallDone;
@@ -108,6 +109,15 @@ export async function* iterateCodexEvents(
108
  if (typed.response.id) extracted.responseId = typed.response.id;
109
  if (typed.response.usage) extracted.usage = typed.response.usage;
110
  break;
 
 
 
 
 
 
 
 
 
111
  }
112
 
113
  yield extracted;
 
38
  responseId?: string;
39
  textDelta?: string;
40
  usage?: UsageInfo;
41
+ error?: { code: string; message: string };
42
  functionCallStart?: FunctionCallStart;
43
  functionCallDelta?: FunctionCallDelta;
44
  functionCallDone?: FunctionCallDone;
 
109
  if (typed.response.id) extracted.responseId = typed.response.id;
110
  if (typed.response.usage) extracted.usage = typed.response.usage;
111
  break;
112
+
113
+ case "error":
114
+ extracted.error = { code: typed.error.code, message: typed.error.message };
115
+ break;
116
+
117
+ case "response.failed":
118
+ extracted.error = { code: typed.error.code, message: typed.error.message };
119
+ if (typed.response.id) extracted.responseId = typed.response.id;
120
+ break;
121
  }
122
 
123
  yield extracted;
src/translation/codex-to-anthropic.ts CHANGED
@@ -74,6 +74,29 @@ export async function* streamCodexToAnthropic(
74
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
75
  if (evt.responseId) onResponseId?.(evt.responseId);
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  // Handle function call start β†’ close text block, open tool_use block
78
  if (evt.functionCallStart) {
79
  hasToolCalls = true;
@@ -207,6 +230,9 @@ export async function collectCodexToAnthropicResponse(
207
 
208
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
209
  if (evt.responseId) responseId = evt.responseId;
 
 
 
210
  if (evt.textDelta) fullText += evt.textDelta;
211
  if (evt.usage) {
212
  inputTokens = evt.usage.input_tokens;
 
74
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
75
  if (evt.responseId) onResponseId?.(evt.responseId);
76
 
77
+ // Handle upstream error events
78
+ if (evt.error) {
79
+ // Close current text block if open
80
+ if (textBlockStarted) {
81
+ yield formatSSE("content_block_delta", {
82
+ type: "content_block_delta",
83
+ index: contentIndex,
84
+ delta: { type: "text_delta", text: `[Error] ${evt.error.code}: ${evt.error.message}` },
85
+ });
86
+ yield formatSSE("content_block_stop", {
87
+ type: "content_block_stop",
88
+ index: contentIndex,
89
+ });
90
+ textBlockStarted = false;
91
+ }
92
+ yield formatSSE("error", {
93
+ type: "error",
94
+ error: { type: "api_error", message: `${evt.error.code}: ${evt.error.message}` },
95
+ });
96
+ yield formatSSE("message_stop", { type: "message_stop" });
97
+ return;
98
+ }
99
+
100
  // Handle function call start β†’ close text block, open tool_use block
101
  if (evt.functionCallStart) {
102
  hasToolCalls = true;
 
230
 
231
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
232
  if (evt.responseId) responseId = evt.responseId;
233
+ if (evt.error) {
234
+ throw new Error(`Codex API error: ${evt.error.code}: ${evt.error.message}`);
235
+ }
236
  if (evt.textDelta) fullText += evt.textDelta;
237
  if (evt.usage) {
238
  inputTokens = evt.usage.input_tokens;
src/translation/codex-to-gemini.ts CHANGED
@@ -39,6 +39,25 @@ export async function* streamCodexToGemini(
39
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
40
  if (evt.responseId) onResponseId?.(evt.responseId);
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  // Function call done β†’ emit as a candidate with functionCall part
43
  if (evt.functionCallDone) {
44
  let args: Record<string, unknown> = {};
@@ -140,6 +159,9 @@ export async function collectCodexToGeminiResponse(
140
 
141
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
142
  if (evt.responseId) responseId = evt.responseId;
 
 
 
143
  if (evt.textDelta) fullText += evt.textDelta;
144
  if (evt.usage) {
145
  inputTokens = evt.usage.input_tokens;
 
39
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
40
  if (evt.responseId) onResponseId?.(evt.responseId);
41
 
42
+ // Handle upstream error events
43
+ if (evt.error) {
44
+ const errorChunk: GeminiGenerateContentResponse = {
45
+ candidates: [
46
+ {
47
+ content: {
48
+ parts: [{ text: `[Error] ${evt.error.code}: ${evt.error.message}` }],
49
+ role: "model",
50
+ },
51
+ finishReason: "OTHER",
52
+ index: 0,
53
+ },
54
+ ],
55
+ modelVersion: model,
56
+ };
57
+ yield `data: ${JSON.stringify(errorChunk)}\n\n`;
58
+ return;
59
+ }
60
+
61
  // Function call done β†’ emit as a candidate with functionCall part
62
  if (evt.functionCallDone) {
63
  let args: Record<string, unknown> = {};
 
159
 
160
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
161
  if (evt.responseId) responseId = evt.responseId;
162
+ if (evt.error) {
163
+ throw new Error(`Codex API error: ${evt.error.code}: ${evt.error.message}`);
164
+ }
165
  if (evt.textDelta) fullText += evt.textDelta;
166
  if (evt.usage) {
167
  inputTokens = evt.usage.input_tokens;
src/translation/codex-to-openai.ts CHANGED
@@ -66,6 +66,38 @@ export async function* streamCodexToOpenAI(
66
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
67
  if (evt.responseId) onResponseId?.(evt.responseId);
68
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  // Handle function call events
70
  if (evt.functionCallStart) {
71
  hasToolCalls = true;
@@ -213,6 +245,9 @@ export async function collectCodexResponse(
213
 
214
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
215
  if (evt.responseId) responseId = evt.responseId;
 
 
 
216
  if (evt.textDelta) fullText += evt.textDelta;
217
  if (evt.usage) {
218
  promptTokens = evt.usage.input_tokens;
 
66
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
67
  if (evt.responseId) onResponseId?.(evt.responseId);
68
 
69
+ // Handle upstream error events
70
+ if (evt.error) {
71
+ yield formatSSE({
72
+ id: chunkId,
73
+ object: "chat.completion.chunk",
74
+ created,
75
+ model,
76
+ choices: [
77
+ {
78
+ index: 0,
79
+ delta: { content: `[Error] ${evt.error.code}: ${evt.error.message}` },
80
+ finish_reason: null,
81
+ },
82
+ ],
83
+ });
84
+ yield formatSSE({
85
+ id: chunkId,
86
+ object: "chat.completion.chunk",
87
+ created,
88
+ model,
89
+ choices: [
90
+ {
91
+ index: 0,
92
+ delta: {},
93
+ finish_reason: "stop",
94
+ },
95
+ ],
96
+ });
97
+ yield "data: [DONE]\n\n";
98
+ return;
99
+ }
100
+
101
  // Handle function call events
102
  if (evt.functionCallStart) {
103
  hasToolCalls = true;
 
245
 
246
  for await (const evt of iterateCodexEvents(codexApi, rawResponse)) {
247
  if (evt.responseId) responseId = evt.responseId;
248
+ if (evt.error) {
249
+ throw new Error(`Codex API error: ${evt.error.code}: ${evt.error.message}`);
250
+ }
251
  if (evt.textDelta) fullText += evt.textDelta;
252
  if (evt.usage) {
253
  promptTokens = evt.usage.input_tokens;
src/types/codex-events.ts CHANGED
@@ -70,6 +70,17 @@ export interface CodexFunctionCallArgsDoneEvent {
70
  name: string;
71
  }
72
 
 
 
 
 
 
 
 
 
 
 
 
73
  export interface CodexUnknownEvent {
74
  type: "unknown";
75
  raw: unknown;
@@ -84,6 +95,8 @@ export type TypedCodexEvent =
84
  | CodexOutputItemAddedEvent
85
  | CodexFunctionCallArgsDeltaEvent
86
  | CodexFunctionCallArgsDoneEvent
 
 
87
  | CodexUnknownEvent;
88
 
89
  // ── Type guard / parser ──────────────────────────────────────────
@@ -204,6 +217,39 @@ export function parseCodexEvent(evt: CodexSSEEvent): TypedCodexEvent {
204
  }
205
  return { type: "unknown", raw: data };
206
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  default:
208
  return { type: "unknown", raw: data };
209
  }
 
70
  name: string;
71
  }
72
 
73
+ export interface CodexErrorEvent {
74
+ type: "error";
75
+ error: { type: string; code: string; message: string };
76
+ }
77
+
78
+ export interface CodexResponseFailedEvent {
79
+ type: "response.failed";
80
+ error: { type: string; code: string; message: string };
81
+ response: CodexResponseData;
82
+ }
83
+
84
  export interface CodexUnknownEvent {
85
  type: "unknown";
86
  raw: unknown;
 
95
  | CodexOutputItemAddedEvent
96
  | CodexFunctionCallArgsDeltaEvent
97
  | CodexFunctionCallArgsDoneEvent
98
+ | CodexErrorEvent
99
+ | CodexResponseFailedEvent
100
  | CodexUnknownEvent;
101
 
102
  // ── Type guard / parser ──────────────────────────────────────────
 
217
  }
218
  return { type: "unknown", raw: data };
219
  }
220
+ case "error": {
221
+ if (isRecord(data)) {
222
+ const err = isRecord(data.error) ? data.error : data;
223
+ return {
224
+ type: "error",
225
+ error: {
226
+ type: typeof err.type === "string" ? err.type : "error",
227
+ code: typeof err.code === "string" ? err.code : "unknown",
228
+ message: typeof err.message === "string" ? err.message : JSON.stringify(data),
229
+ },
230
+ };
231
+ }
232
+ return {
233
+ type: "error",
234
+ error: { type: "error", code: "unknown", message: String(data) },
235
+ };
236
+ }
237
+ case "response.failed": {
238
+ const resp = parseResponseData(data);
239
+ if (isRecord(data)) {
240
+ const err = isRecord(data.error) ? data.error : {};
241
+ return {
242
+ type: "response.failed",
243
+ error: {
244
+ type: typeof err.type === "string" ? err.type : "error",
245
+ code: typeof err.code === "string" ? err.code : "unknown",
246
+ message: typeof err.message === "string" ? err.message : JSON.stringify(data),
247
+ },
248
+ response: resp ?? {},
249
+ };
250
+ }
251
+ return { type: "unknown", raw: data };
252
+ }
253
  default:
254
  return { type: "unknown", raw: data };
255
  }