@bitkyc08/opencodex 0.2.2 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <meta name="color-scheme" content="dark" />
8
8
  <title>opencodex · proxy dashboard</title>
9
- <script type="module" crossorigin src="/assets/index-Dt5t57MW.js"></script>
9
+ <script type="module" crossorigin src="/assets/index-CDhJ0DI7.js"></script>
10
10
  <link rel="stylesheet" crossorigin href="/assets/index-C1wlp1SM.css">
11
11
  </head>
12
12
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "0.2.2",
3
+ "version": "1.9.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex — use any LLM with Codex CLI/App/SDK",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -21,7 +21,9 @@
21
21
  "scripts": {
22
22
  "dev": "bun run src/cli.ts start",
23
23
  "start": "bun run src/cli.ts start",
24
+ "test": "bun test tests",
24
25
  "typecheck": "bun x tsc --noEmit",
26
+ "generate:jawcode-metadata": "bun scripts/generate-jawcode-metadata.ts",
25
27
  "build:gui": "cd gui && bun install && bun run build",
26
28
  "postinstall": "node scripts/postinstall.mjs",
27
29
  "prepublishOnly": "bun run typecheck && bun run build:gui",
package/src/abort.ts ADDED
@@ -0,0 +1,29 @@
1
+ export interface LinkedAbortSignal {
2
+ signal: AbortSignal;
3
+ cleanup: () => void;
4
+ }
5
+
6
+ export function signalWithTimeout(timeoutMs: number, parent?: AbortSignal): LinkedAbortSignal {
7
+ const controller = new AbortController();
8
+ const timeout = setTimeout(() => {
9
+ if (!controller.signal.aborted) controller.abort(new DOMException("Timeout elapsed", "TimeoutError"));
10
+ }, timeoutMs);
11
+
12
+ const abortFromParent = () => {
13
+ if (!controller.signal.aborted) controller.abort(parent?.reason);
14
+ };
15
+
16
+ if (parent?.aborted) {
17
+ abortFromParent();
18
+ } else {
19
+ parent?.addEventListener("abort", abortFromParent, { once: true });
20
+ }
21
+
22
+ return {
23
+ signal: controller.signal,
24
+ cleanup: () => {
25
+ clearTimeout(timeout);
26
+ parent?.removeEventListener("abort", abortFromParent);
27
+ },
28
+ };
29
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ProviderAdapter } from "./base";
2
+ import { debugDroppedFrame } from "../debug";
2
3
  import type {
3
4
  AdapterEvent,
4
5
  OcxAssistantMessage,
@@ -9,6 +10,7 @@ import type {
9
10
  OcxTextContent,
10
11
  OcxThinkingContent,
11
12
  OcxToolCall,
13
+ OcxUsage,
12
14
  } from "../types";
13
15
  import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPrefix, stripClaudeToolPrefix } from "../oauth/anthropic";
14
16
  import { parseDataUrl } from "./image";
@@ -48,6 +50,16 @@ function reasoningBudget(effort: string): number {
48
50
  }
49
51
  }
50
52
 
53
+ function usageFromAnthropic(usage: Record<string, number> | undefined): OcxUsage | undefined {
54
+ if (!usage) return undefined;
55
+ const hasCache = usage.cache_read_input_tokens !== undefined || usage.cache_creation_input_tokens !== undefined;
56
+ return {
57
+ inputTokens: usage.input_tokens ?? 0,
58
+ outputTokens: usage.output_tokens ?? 0,
59
+ ...(hasCache ? { cachedInputTokens: (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0) } : {}),
60
+ };
61
+ }
62
+
51
63
  function messagesToAnthropicFormat(parsed: OcxParsedRequest, isOAuth: boolean): { system: string | undefined; messages: unknown[] } {
52
64
  const system = parsed.context.systemPrompt?.join("\n\n") || undefined;
53
65
  const messages: unknown[] = [];
@@ -215,6 +227,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
215
227
  try {
216
228
  data = JSON.parse(payload) as Record<string, unknown>;
217
229
  } catch {
230
+ debugDroppedFrame("anthropic", payload);
218
231
  continue;
219
232
  }
220
233
 
@@ -255,10 +268,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
255
268
  if (usage) {
256
269
  yield {
257
270
  type: "done",
258
- usage: {
259
- inputTokens: usage.input_tokens ?? 0,
260
- outputTokens: usage.output_tokens ?? 0,
261
- },
271
+ usage: usageFromAnthropic(usage),
262
272
  };
263
273
  }
264
274
  break;
@@ -298,7 +308,7 @@ export function createAnthropicAdapter(provider: OcxProviderConfig): ProviderAda
298
308
  const usage = json.usage as Record<string, number> | undefined;
299
309
  events.push({
300
310
  type: "done",
301
- usage: usage ? { inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0 } : undefined,
311
+ usage: usageFromAnthropic(usage),
302
312
  });
303
313
  return events;
304
314
  },
@@ -1,4 +1,5 @@
1
1
  import type { ProviderAdapter } from "./base";
2
+ import { debugDroppedFrame } from "../debug";
2
3
  import type {
3
4
  AdapterEvent,
4
5
  OcxAssistantMessage,
@@ -7,6 +8,7 @@ import type {
7
8
  OcxProviderConfig,
8
9
  OcxTextContent,
9
10
  OcxToolCall,
11
+ OcxUsage,
10
12
  } from "../types";
11
13
  import { contentPartsToText, parseDataUrl } from "./image";
12
14
 
@@ -74,6 +76,16 @@ function toolsToGeminiFormat(parsed: OcxParsedRequest): unknown[] | undefined {
74
76
  }];
75
77
  }
76
78
 
79
+ function usageFromGemini(usage: Record<string, number> | undefined): OcxUsage | undefined {
80
+ if (!usage) return undefined;
81
+ return {
82
+ inputTokens: usage.promptTokenCount ?? 0,
83
+ outputTokens: usage.candidatesTokenCount ?? 0,
84
+ ...(usage.cachedContentTokenCount !== undefined ? { cachedInputTokens: usage.cachedContentTokenCount } : {}),
85
+ ...(usage.thoughtsTokenCount !== undefined ? { reasoningOutputTokens: usage.thoughtsTokenCount } : {}),
86
+ };
87
+ }
88
+
77
89
  export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter {
78
90
  return {
79
91
  name: "google",
@@ -113,6 +125,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
113
125
  const reader = response.body.getReader();
114
126
  const decoder = new TextDecoder();
115
127
  let buffer = "";
128
+ let pendingUsage: OcxUsage | undefined;
116
129
 
117
130
  try {
118
131
  while (true) {
@@ -129,7 +142,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
129
142
  if (!payload) continue;
130
143
 
131
144
  let chunk: Record<string, unknown>;
132
- try { chunk = JSON.parse(payload); } catch { continue; }
145
+ try { chunk = JSON.parse(payload); } catch { debugDroppedFrame("google", payload); continue; }
146
+
147
+ // Inline provider error inside a 200 stream → terminal error (see openai-chat.ts).
148
+ if (chunk.error) {
149
+ const err = chunk.error as { message?: string } | undefined;
150
+ yield { type: "error", message: err?.message ?? "upstream error" };
151
+ return;
152
+ }
133
153
 
134
154
  const candidates = chunk.candidates as { content?: { parts?: unknown[] }; finishReason?: string }[] | undefined;
135
155
  if (!candidates?.length) continue;
@@ -150,18 +170,14 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
150
170
  }
151
171
 
152
172
  const usageMeta = chunk.usageMetadata as Record<string, number> | undefined;
153
- if (candidates[0].finishReason && usageMeta) {
154
- yield {
155
- type: "done",
156
- usage: {
157
- inputTokens: usageMeta.promptTokenCount ?? 0,
158
- outputTokens: usageMeta.candidatesTokenCount ?? 0,
159
- },
160
- };
173
+ if (usageMeta) {
174
+ // Accumulate usage; emit a single terminal `done` post-loop so usage is never
175
+ // dropped on EOF and the stream never yields two `done` events.
176
+ pendingUsage = usageFromGemini(usageMeta);
161
177
  }
162
178
  }
163
179
  }
164
- yield { type: "done" };
180
+ yield { type: "done", usage: pendingUsage };
165
181
  } finally {
166
182
  reader.releaseLock();
167
183
  }
@@ -187,7 +203,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte
187
203
  const usage = json.usageMetadata as Record<string, number> | undefined;
188
204
  events.push({
189
205
  type: "done",
190
- usage: usage ? { inputTokens: usage.promptTokenCount ?? 0, outputTokens: usage.candidatesTokenCount ?? 0 } : undefined,
206
+ usage: usageFromGemini(usage),
191
207
  });
192
208
  return events;
193
209
  },
@@ -1,5 +1,6 @@
1
1
  import type { ProviderAdapter } from "./base";
2
- import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxToolCall } from "../types";
2
+ import { debugDroppedFrame } from "../debug";
3
+ import type { AdapterEvent, OcxAssistantMessage, OcxContentPart, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxTextContent, OcxToolCall, OcxUsage } from "../types";
3
4
  import { namespacedToolName } from "../types";
4
5
  import { contentPartsToText } from "./image";
5
6
 
@@ -96,6 +97,18 @@ function toolChoiceToChatFormat(tc: OcxParsedRequest["options"]["toolChoice"]):
96
97
  return undefined;
97
98
  }
98
99
 
100
+ function usageFromOpenAIChat(usage: Record<string, unknown> | undefined): OcxUsage | undefined {
101
+ if (!usage) return undefined;
102
+ const promptDetails = usage.prompt_tokens_details as Record<string, number> | undefined;
103
+ const completionDetails = usage.completion_tokens_details as Record<string, number> | undefined;
104
+ return {
105
+ inputTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
106
+ outputTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
107
+ ...(promptDetails?.cached_tokens !== undefined ? { cachedInputTokens: promptDetails.cached_tokens } : {}),
108
+ ...(completionDetails?.reasoning_tokens !== undefined ? { reasoningOutputTokens: completionDetails.reasoning_tokens } : {}),
109
+ };
110
+ }
111
+
99
112
  export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAdapter {
100
113
  return {
101
114
  name: "openai-chat",
@@ -151,7 +164,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
151
164
  let buffer = "";
152
165
  let currentToolCallId = "";
153
166
  let currentToolCallName = "";
154
- let pendingUsage: { inputTokens: number; outputTokens: number } | undefined;
167
+ let pendingUsage: OcxUsage | undefined;
155
168
 
156
169
  try {
157
170
  while (true) {
@@ -178,16 +191,25 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
178
191
  try {
179
192
  chunk = JSON.parse(payload) as Record<string, unknown>;
180
193
  } catch {
194
+ debugDroppedFrame("openai-chat", payload);
181
195
  continue;
182
196
  }
183
197
 
198
+ // A 200/OK chat-completions stream may carry an inline provider error envelope
199
+ // instead of a clean [DONE]. Surface it as a terminal error so the bridge emits a
200
+ // classified response.failed (bridge case "error") — never a truncated completion.
201
+ if (chunk.error) {
202
+ const err = chunk.error as { message?: string } | undefined;
203
+ if (currentToolCallId) yield { type: "tool_call_end" };
204
+ yield { type: "error", message: err?.message ?? "upstream error" };
205
+ return;
206
+ }
207
+
184
208
  if (chunk.usage) {
185
- const u = chunk.usage as Record<string, number>;
186
- pendingUsage = {
187
- inputTokens: u.prompt_tokens ?? 0,
188
- outputTokens: u.completion_tokens ?? 0,
189
- };
190
- continue;
209
+ // Record usage but keep parsing: some providers send usage and the final content
210
+ // delta in the SAME chunk; a `continue` here would drop that content. The choices
211
+ // guard below no-ops a usage-only chunk.
212
+ pendingUsage = usageFromOpenAIChat(chunk.usage as Record<string, unknown>);
191
213
  }
192
214
 
193
215
  const choices = chunk.choices as { delta?: Record<string, unknown>; finish_reason?: string }[] | undefined;
@@ -200,7 +222,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
200
222
  }
201
223
 
202
224
  if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) {
203
- yield { type: "thinking_delta", thinking: delta.reasoning_content };
225
+ yield { type: "reasoning_raw_delta", text: delta.reasoning_content };
204
226
  }
205
227
 
206
228
  const toolCalls = delta.tool_calls as { index: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined;
@@ -228,7 +250,8 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
228
250
  if (currentToolCallId) {
229
251
  yield { type: "tool_call_end" };
230
252
  }
231
- yield { type: "done" };
253
+ // EOF without a [DONE] sentinel: still surface any usage accumulated mid-stream.
254
+ yield { type: "done", usage: pendingUsage };
232
255
  } finally {
233
256
  reader.releaseLock();
234
257
  }
@@ -244,6 +267,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
244
267
  if (typeof msg.content === "string") {
245
268
  events.push({ type: "text_delta", text: msg.content });
246
269
  }
270
+ if (typeof msg.reasoning_content === "string" && msg.reasoning_content.length > 0) {
271
+ events.push({ type: "reasoning_raw_delta", text: msg.reasoning_content });
272
+ }
247
273
  const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined;
248
274
  if (toolCalls) {
249
275
  for (const tc of toolCalls) {
@@ -254,10 +280,10 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
254
280
  }
255
281
  }
256
282
  }
257
- const usage = json.usage as Record<string, number> | undefined;
283
+ const usage = json.usage as Record<string, unknown> | undefined;
258
284
  events.push({
259
285
  type: "done",
260
- usage: usage ? { inputTokens: usage.prompt_tokens ?? 0, outputTokens: usage.completion_tokens ?? 0 } : undefined,
286
+ usage: usageFromOpenAIChat(usage),
261
287
  });
262
288
  return events;
263
289
  },
@@ -3,7 +3,24 @@ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../types
3
3
 
4
4
  // Headers relayed verbatim from the caller in OAuth-passthrough ("forward") mode.
5
5
  // Exported so the web-search sidecar reuses the exact same forwarded-auth set for its ChatGPT call.
6
- export const FORWARD_HEADERS = ["authorization", "chatgpt-account-id", "openai-beta", "originator", "session_id"];
6
+ export const FORWARD_HEADERS = [
7
+ "authorization",
8
+ "chatgpt-account-id",
9
+ "openai-beta",
10
+ "originator",
11
+ "session_id",
12
+ "session-id",
13
+ "thread-id",
14
+ "x-client-request-id",
15
+ "x-codex-beta-features",
16
+ "x-codex-installation-id",
17
+ "x-codex-parent-thread-id",
18
+ "x-codex-turn-metadata",
19
+ "x-codex-turn-state",
20
+ "x-codex-window-id",
21
+ "x-oai-attestation",
22
+ "x-responsesapi-include-timing-metrics",
23
+ ];
7
24
 
8
25
  export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): ProviderAdapter & { passthrough: true } {
9
26
  return {
package/src/bridge.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { AdapterEvent, OcxUsage } from "./types";
2
+ import { classifyError, type OcxErrorPayload } from "./errors";
2
3
 
3
4
  function uuid(): string {
4
5
  return crypto.randomUUID().replace(/-/g, "");
@@ -8,6 +9,26 @@ function sseEvent(name: string, data: Record<string, unknown>): string {
8
9
  return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
9
10
  }
10
11
 
12
+ function responsesUsage(usage: OcxUsage | undefined): Record<string, unknown> {
13
+ if (!usage) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
14
+ const out: Record<string, unknown> = {
15
+ input_tokens: usage.inputTokens,
16
+ output_tokens: usage.outputTokens,
17
+ total_tokens: usage.inputTokens + usage.outputTokens,
18
+ };
19
+ if (usage.cachedInputTokens !== undefined) {
20
+ out.input_tokens_details = { cached_tokens: usage.cachedInputTokens };
21
+ }
22
+ if (usage.reasoningOutputTokens !== undefined) {
23
+ out.output_tokens_details = { reasoning_tokens: usage.reasoningOutputTokens };
24
+ }
25
+ return out;
26
+ }
27
+
28
+ function responseError(status: number, type: string, message: string): OcxErrorPayload {
29
+ return classifyError(status, type, message);
30
+ }
31
+
11
32
  interface OutputItem {
12
33
  type: string;
13
34
  id: string;
@@ -20,6 +41,9 @@ export function bridgeToResponsesSSE(
20
41
  toolNsMap?: Map<string, { namespace: string; name: string }>,
21
42
  freeformToolNames?: Set<string>,
22
43
  toolSearchToolNames?: Set<string>,
44
+ onCancel?: () => void,
45
+ heartbeatMs = 2_000,
46
+ options?: { responseId?: string },
23
47
  ): ReadableStream<Uint8Array> {
24
48
  // Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
25
49
  // function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call.
@@ -32,15 +56,38 @@ export function bridgeToResponsesSSE(
32
56
  try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
33
57
  };
34
58
  const encoder = new TextEncoder();
35
- const responseId = `resp_${uuid()}`;
59
+ const responseId = options?.responseId ?? `resp_${uuid()}`;
36
60
  let seq = 0;
61
+ // Set once the client is gone (cancel) or an enqueue throws on a torn-down controller, so we
62
+ // never enqueue again and never throw a second time inside start() — the RC2 double-throw that
63
+ // otherwise surfaced as proxy-side stream noise on every client disconnect.
64
+ let closed = false;
65
+ // RC3 keep-alive: Codex's idle timer is timeout(idle_timeout, stream.next()) over an
66
+ // eventsource_stream; ANY received event re-arms it, while an unknown type is ignored
67
+ // (responses.rs `_ => Ok(None)`). We emit a real, parser-ignored `response.heartbeat` only during
68
+ // upstream silence so a stalled routed provider never trips "idle timeout waiting for SSE".
69
+ let activity = false;
70
+ let beat: ReturnType<typeof setInterval> | undefined;
37
71
 
38
72
  return new ReadableStream<Uint8Array>({
39
73
  async start(controller) {
40
74
  const emit = (name: string, data: Record<string, unknown>) => {
41
- controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
75
+ if (closed) return;
76
+ activity = true;
77
+ try {
78
+ controller.enqueue(encoder.encode(sseEvent(name, { type: name, sequence_number: seq++, ...data })));
79
+ } catch {
80
+ closed = true;
81
+ }
82
+ };
83
+ const emitDone = () => {
84
+ if (closed) return;
85
+ try {
86
+ controller.enqueue(encoder.encode("data: [DONE]\n\n"));
87
+ } catch {
88
+ closed = true;
89
+ }
42
90
  };
43
- const emitDone = () => controller.enqueue(encoder.encode("data: [DONE]\n\n"));
44
91
 
45
92
  const createdAt = Math.floor(Date.now() / 1000);
46
93
  let outputIndex = 0;
@@ -53,8 +100,18 @@ export function bridgeToResponsesSSE(
53
100
 
54
101
  emit("response.created", { response: responseSnapshot("in_progress", []) });
55
102
 
103
+ // Re-arm Codex's idle timer during silence with a parser-ignored heartbeat (RC3). Skips a tick
104
+ // whenever a real event was emitted since the last tick, so it only fires on a genuine stall.
105
+ const heartbeatFrame = encoder.encode('event: response.heartbeat\ndata: {"type":"response.heartbeat"}\n\n');
106
+ beat = setInterval(() => {
107
+ if (closed) return;
108
+ if (activity) { activity = false; return; }
109
+ try { controller.enqueue(heartbeatFrame); } catch { closed = true; }
110
+ }, heartbeatMs);
111
+
56
112
  let currentMsg: { itemId: string; outputIndex: number; text: string } | null = null;
57
113
  let currentReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
114
+ let currentRawReasoning: { itemId: string; outputIndex: number; text: string } | null = null;
58
115
  let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; namespace?: string; freeform?: boolean; toolSearch?: boolean } | null = null;
59
116
 
60
117
  const closeCurrentMessage = () => {
@@ -97,6 +154,18 @@ export function bridgeToResponsesSSE(
97
154
  currentReasoning = null;
98
155
  };
99
156
 
157
+ const closeCurrentRawReasoning = () => {
158
+ if (!currentRawReasoning) return;
159
+ const item = {
160
+ type: "reasoning", id: currentRawReasoning.itemId, summary: [],
161
+ content: [{ type: "reasoning_text", text: currentRawReasoning.text }],
162
+ };
163
+ emit("response.output_item.done", { output_index: currentRawReasoning.outputIndex, item });
164
+ finishedItems.push(item as OutputItem);
165
+ outputIndex++;
166
+ currentRawReasoning = null;
167
+ };
168
+
100
169
  const closeCurrentToolCall = () => {
101
170
  if (!currentToolCall) return;
102
171
  // Empty input (no-arg tools like computer_use get_app_state / list_apps) must serialize as
@@ -133,11 +202,18 @@ export function bridgeToResponsesSSE(
133
202
  currentToolCall = null;
134
203
  };
135
204
 
205
+ // RC1: guarantee the Responses stream always ends with exactly one terminal event. Set true
206
+ // when a done/error/catch terminal is emitted; if the adapter generator returns without one
207
+ // we synthesize response.completed below, so Codex never hits the parser's
208
+ // "stream closed before response.completed" (responses.rs) -> ApiError::Stream.
209
+ let terminated = false;
210
+
136
211
  try {
137
212
  for await (const event of events) {
138
213
  switch (event.type) {
139
214
  case "text_delta": {
140
215
  if (currentReasoning) closeCurrentReasoning();
216
+ if (currentRawReasoning) closeCurrentRawReasoning();
141
217
  if (currentToolCall) closeCurrentToolCall();
142
218
  if (!currentMsg) {
143
219
  const itemId = `msg_${uuid()}`;
@@ -161,6 +237,7 @@ export function bridgeToResponsesSSE(
161
237
  }
162
238
  case "thinking_delta": {
163
239
  if (currentMsg) closeCurrentMessage();
240
+ if (currentRawReasoning) closeCurrentRawReasoning();
164
241
  if (currentToolCall) closeCurrentToolCall();
165
242
  if (!currentReasoning) {
166
243
  const itemId = `rs_${uuid()}`;
@@ -179,9 +256,27 @@ export function bridgeToResponsesSSE(
179
256
  });
180
257
  break;
181
258
  }
259
+ case "reasoning_raw_delta": {
260
+ if (currentMsg) closeCurrentMessage();
261
+ if (currentReasoning) closeCurrentReasoning();
262
+ if (currentToolCall) closeCurrentToolCall();
263
+ if (!currentRawReasoning) {
264
+ const itemId = `rs_${uuid()}`;
265
+ const item = { type: "reasoning", id: itemId, summary: [] as never[], content: [] as { type: string; text: string }[] };
266
+ emit("response.output_item.added", { output_index: outputIndex, item });
267
+ currentRawReasoning = { itemId, outputIndex, text: "" };
268
+ }
269
+ currentRawReasoning.text += event.text;
270
+ emit("response.reasoning_text.delta", {
271
+ item_id: currentRawReasoning.itemId, output_index: currentRawReasoning.outputIndex,
272
+ content_index: 0, delta: event.text,
273
+ });
274
+ break;
275
+ }
182
276
  case "tool_call_start": {
183
277
  if (currentMsg) closeCurrentMessage();
184
278
  if (currentReasoning) closeCurrentReasoning();
279
+ if (currentRawReasoning) closeCurrentRawReasoning();
185
280
  if (currentToolCall) closeCurrentToolCall();
186
281
  const itemId = `fc_${uuid()}`;
187
282
  const mapped = toolNsMap?.get(event.name);
@@ -217,27 +312,27 @@ export function bridgeToResponsesSSE(
217
312
  case "done": {
218
313
  if (currentMsg) closeCurrentMessage();
219
314
  if (currentReasoning) closeCurrentReasoning();
315
+ if (currentRawReasoning) closeCurrentRawReasoning();
220
316
  if (currentToolCall) closeCurrentToolCall();
221
- const usage = event.usage ? {
222
- input_tokens: event.usage.inputTokens,
223
- output_tokens: event.usage.outputTokens,
224
- total_tokens: event.usage.inputTokens + event.usage.outputTokens,
225
- } : { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
226
317
  emit("response.completed", {
227
- response: { ...responseSnapshot("completed", finishedItems), usage },
318
+ response: { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(event.usage) },
228
319
  });
320
+ terminated = true;
229
321
  break;
230
322
  }
231
323
  case "error": {
232
324
  if (currentMsg) closeCurrentMessage();
233
325
  if (currentReasoning) closeCurrentReasoning();
326
+ if (currentRawReasoning) closeCurrentRawReasoning();
234
327
  if (currentToolCall) closeCurrentToolCall();
235
328
  emit("response.failed", {
236
329
  response: {
237
330
  ...responseSnapshot("failed", finishedItems),
238
- last_error: { type: "upstream_error", message: event.message },
331
+ error: responseError(502, "upstream_error", event.message),
332
+ last_error: responseError(502, "upstream_error", event.message),
239
333
  },
240
334
  });
335
+ terminated = true;
241
336
  break;
242
337
  }
243
338
  }
@@ -246,13 +341,41 @@ export function bridgeToResponsesSSE(
246
341
  emit("response.failed", {
247
342
  response: {
248
343
  ...responseSnapshot("failed", finishedItems),
249
- last_error: { type: "proxy_error", message: err instanceof Error ? err.message : String(err) },
344
+ error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
345
+ last_error: responseError(500, "proxy_error", err instanceof Error ? err.message : String(err)),
250
346
  },
251
347
  });
348
+ terminated = true;
349
+ }
350
+
351
+ if (beat) clearInterval(beat);
352
+
353
+ if (!terminated) {
354
+ // The adapter generator ended without a done/error event (e.g. an upstream that closes
355
+ // after message_stop, or a routed provider that drops the connection cleanly). Close any
356
+ // open items and synthesize a clean completion so the stream is never terminal-less.
357
+ if (currentMsg) closeCurrentMessage();
358
+ if (currentReasoning) closeCurrentReasoning();
359
+ if (currentRawReasoning) closeCurrentRawReasoning();
360
+ if (currentToolCall) closeCurrentToolCall();
361
+ emit("response.completed", {
362
+ response: { ...responseSnapshot("completed", finishedItems), usage: responsesUsage(undefined) },
363
+ });
252
364
  }
253
365
 
254
366
  emitDone();
255
- controller.close();
367
+ try {
368
+ controller.close();
369
+ } catch {
370
+ /* already closed (e.g. client cancelled) */
371
+ }
372
+ },
373
+ cancel() {
374
+ // Client (Codex) disconnected. Stop emitting and let the caller abort the upstream fetch so a
375
+ // cancelled turn does not leak the upstream stream or keep draining tokens (RC2).
376
+ closed = true;
377
+ if (beat) clearInterval(beat);
378
+ onCancel?.();
256
379
  },
257
380
  });
258
381
  }
@@ -264,13 +387,31 @@ export function buildResponseJSON(
264
387
  const responseId = `resp_${uuid()}`;
265
388
  const output: OutputItem[] = [];
266
389
  let text = "";
390
+ let summaryReasoning = "";
391
+ let rawReasoning = "";
267
392
  let usage: OcxUsage | undefined;
268
393
 
269
394
  for (const e of events) {
270
395
  if (e.type === "text_delta") text += e.text;
396
+ if (e.type === "thinking_delta") summaryReasoning += e.thinking;
397
+ if (e.type === "reasoning_raw_delta") rawReasoning += e.text;
271
398
  if (e.type === "done") usage = e.usage;
272
399
  }
273
400
 
401
+ if (rawReasoning) {
402
+ output.push({
403
+ type: "reasoning", id: `rs_${uuid()}`, summary: [],
404
+ content: [{ type: "reasoning_text", text: rawReasoning }],
405
+ });
406
+ }
407
+
408
+ if (summaryReasoning) {
409
+ output.push({
410
+ type: "reasoning", id: `rs_${uuid()}`,
411
+ summary: [{ type: "summary_text", text: summaryReasoning }],
412
+ });
413
+ }
414
+
274
415
  if (text) {
275
416
  output.push({
276
417
  type: "message", id: `msg_${uuid()}`, role: "assistant", status: "completed",
@@ -282,15 +423,12 @@ export function buildResponseJSON(
282
423
  id: responseId, object: "response",
283
424
  created_at: Math.floor(Date.now() / 1000),
284
425
  status: "completed", model: modelId, output,
285
- usage: usage ? {
286
- input_tokens: usage.inputTokens, output_tokens: usage.outputTokens,
287
- total_tokens: usage.inputTokens + usage.outputTokens,
288
- } : { input_tokens: 0, output_tokens: 0, total_tokens: 0 },
426
+ usage: responsesUsage(usage),
289
427
  };
290
428
  }
291
429
 
292
430
  export function formatErrorResponse(status: number, type: string, message: string): Response {
293
- return new Response(JSON.stringify({ error: { message, type, code: null } }), {
431
+ return new Response(JSON.stringify({ error: classifyError(status, type, message) }), {
294
432
  status, headers: { "Content-Type": "application/json" },
295
433
  });
296
434
  }
package/src/cli.ts CHANGED
File without changes