@bitkyc08/opencodex 0.2.1 → 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.
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export interface OcxParsedRequest {
2
2
  modelId: string;
3
+ previousResponseId?: string;
3
4
  context: OcxContext;
4
5
  stream: boolean;
5
6
  options: OcxRequestOptions;
@@ -149,6 +150,7 @@ export interface OcxRequestOptions {
149
150
  export type AdapterEvent =
150
151
  | { type: "text_delta"; text: string }
151
152
  | { type: "thinking_delta"; thinking: string }
153
+ | { type: "reasoning_raw_delta"; text: string }
152
154
  | { type: "tool_call_start"; id: string; name: string }
153
155
  | { type: "tool_call_delta"; arguments: string }
154
156
  | { type: "tool_call_end" }
@@ -158,6 +160,8 @@ export type AdapterEvent =
158
160
  export interface OcxUsage {
159
161
  inputTokens: number;
160
162
  outputTokens: number;
163
+ cachedInputTokens?: number;
164
+ reasoningOutputTokens?: number;
161
165
  }
162
166
 
163
167
  export interface OcxConfig {
@@ -171,6 +175,8 @@ export interface OcxConfig {
171
175
  subagentModels?: string[];
172
176
  /** Routed model ids ("<provider>/<model>") hidden from Codex (excluded from the catalog + /v1/models). */
173
177
  disabledModels?: string[];
178
+ /** Advertise supports_websockets so Codex opens the WS endpoint. Default false; set true to opt into WS. */
179
+ websockets?: boolean;
174
180
  /** Freshness window (ms) for the per-provider live `/models` cache. Defaults to 5 min. */
175
181
  modelCacheTtlMs?: number;
176
182
  /** Web-search sidecar: route web_search for non-OpenAI models through a gpt-mini via ChatGPT passthrough. */
@@ -1,5 +1,6 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
+ import { signalWithTimeout } from "../abort";
3
4
  import { parseSidecarSSE } from "../web-search/parse";
4
5
 
5
6
  export interface VisionSettings {
@@ -48,6 +49,7 @@ export async function describeImage(
48
49
  forwardProvider: OcxProviderConfig,
49
50
  incomingHeaders: Headers,
50
51
  settings: VisionSettings,
52
+ abortSignal?: AbortSignal,
51
53
  ): Promise<DescribeOutcome> {
52
54
  const invalid = validateImageUrl(imageUrl);
53
55
  if (invalid) return { text: "", error: invalid };
@@ -76,12 +78,13 @@ export async function describeImage(
76
78
  store: false,
77
79
  stream: true,
78
80
  };
81
+ const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
79
82
  try {
80
83
  const res = await fetch(`${forwardProvider.baseUrl}/responses`, {
81
84
  method: "POST",
82
85
  headers,
83
86
  body: JSON.stringify(body),
84
- signal: AbortSignal.timeout(settings.timeoutMs),
87
+ signal: linkedSignal.signal,
85
88
  });
86
89
  if (!res.ok) {
87
90
  const t = await res.text().catch(() => "");
@@ -94,5 +97,7 @@ export async function describeImage(
94
97
  return { text: parsed.text };
95
98
  } catch (e) {
96
99
  return { text: "", error: e instanceof Error ? e.message : String(e) };
100
+ } finally {
101
+ linkedSignal.cleanup();
97
102
  }
98
103
  }
@@ -107,6 +107,7 @@ export async function describeImagesInPlace(
107
107
  forwardProvider: OcxProviderConfig,
108
108
  incomingHeaders: Headers,
109
109
  settings: VisionSettings,
110
+ abortSignal?: AbortSignal,
110
111
  ): Promise<void> {
111
112
  // 1. Gather every image part across messages, each with its own message's text as context.
112
113
  const jobs: ImageJob[] = [];
@@ -129,7 +130,7 @@ export async function describeImagesInPlace(
129
130
 
130
131
  // 2. Describe all images with bounded concurrency (order preserved).
131
132
  const outcomes = await runBounded(jobs, VISION_CONCURRENCY, j =>
132
- describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, incomingHeaders, settings));
133
+ describeImage(j.imageUrl, j.detail, j.contextText, forwardProvider, incomingHeaders, settings, abortSignal));
133
134
 
134
135
  // 3. Rebuild each message, replacing image parts with their descriptions in order.
135
136
  let oi = 0;
@@ -1,5 +1,6 @@
1
1
  import type { OcxProviderConfig } from "../types";
2
2
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
3
+ import { signalWithTimeout } from "../abort";
3
4
  import { parseSidecarSSE, type WebSearchResult } from "./parse";
4
5
 
5
6
  export interface SidecarSettings {
@@ -36,6 +37,7 @@ export async function runWebSearch(
36
37
  forwardProvider: OcxProviderConfig,
37
38
  incomingHeaders: Headers,
38
39
  settings: SidecarSettings,
40
+ abortSignal?: AbortSignal,
39
41
  ): Promise<SidecarOutcome> {
40
42
  const headers: Record<string, string> = { "Content-Type": "application/json" };
41
43
  if (forwardProvider.headers) Object.assign(headers, forwardProvider.headers);
@@ -57,12 +59,13 @@ export async function runWebSearch(
57
59
  stream: true,
58
60
  };
59
61
  const url = `${forwardProvider.baseUrl}/responses`;
62
+ const linkedSignal = signalWithTimeout(settings.timeoutMs, abortSignal);
60
63
  try {
61
64
  const res = await fetch(url, {
62
65
  method: "POST",
63
66
  headers,
64
67
  body: JSON.stringify(body),
65
- signal: AbortSignal.timeout(settings.timeoutMs),
68
+ signal: linkedSignal.signal,
66
69
  });
67
70
  if (!res.ok) {
68
71
  const t = await res.text().catch(() => "");
@@ -71,5 +74,7 @@ export async function runWebSearch(
71
74
  return await parseSidecarSSE(res);
72
75
  } catch (e) {
73
76
  return { text: "", sources: [], error: e instanceof Error ? e.message : String(e) };
77
+ } finally {
78
+ linkedSignal.cleanup();
74
79
  }
75
80
  }
@@ -95,6 +95,7 @@ export interface WebSearchLoopDeps {
95
95
  incomingHeaders: Headers;
96
96
  settings: SidecarSettings;
97
97
  maxSearches: number;
98
+ abortSignal?: AbortSignal;
98
99
  }
99
100
 
100
101
  /**
@@ -104,7 +105,7 @@ export interface WebSearchLoopDeps {
104
105
  * streamed Responses SSE. web_search calls are executed internally and never relayed to Codex.
105
106
  */
106
107
  export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Response> {
107
- const { parsed, adapter, incomingHeaders, forwardProvider, hostedTool, settings, maxSearches } = deps;
108
+ const { parsed, adapter, incomingHeaders, forwardProvider, hostedTool, settings, maxSearches, abortSignal } = deps;
108
109
  if (!adapter.parseResponse) return jsonError(500, "web-search sidecar requires a non-streaming adapter");
109
110
 
110
111
  const messages: OcxMessage[] = [...parsed.context.messages];
@@ -129,7 +130,12 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
129
130
  const request = adapter.buildRequest(iterParsed, { headers: incomingHeaders });
130
131
  let resp: Response;
131
132
  try {
132
- resp = await fetch(request.url, { method: request.method, headers: request.headers, body: request.body });
133
+ resp = await fetch(request.url, {
134
+ method: request.method,
135
+ headers: request.headers,
136
+ body: request.body,
137
+ signal: abortSignal,
138
+ });
133
139
  } catch (e) {
134
140
  return jsonError(502, `Provider unreachable: ${e instanceof Error ? e.message : String(e)}`);
135
141
  }
@@ -159,7 +165,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise<Respons
159
165
  outcome = { text: "", sources: [], error: "the model called web_search with an empty query" };
160
166
  searchesExecuted++;
161
167
  } else {
162
- outcome = await runWebSearch(call.query, hostedTool, forwardProvider, incomingHeaders, settings);
168
+ outcome = await runWebSearch(call.query, hostedTool, forwardProvider, incomingHeaders, settings, abortSignal);
163
169
  searchesExecuted++;
164
170
  if (outcome.error) failedQueries.add(normalizeQuery(call.query));
165
171
  }
@@ -0,0 +1,359 @@
1
+ import type { ServerWebSocket } from "bun";
2
+ import { FORWARD_HEADERS } from "./adapters/openai-responses";
3
+
4
+ const OPEN = 1;
5
+ const TERMINAL_TYPES = new Set(["response.completed", "response.failed", "response.incomplete"]);
6
+ const SAFE_RESPONSE_HEADER_EXACT = new Set([
7
+ "retry-after",
8
+ "x-request-id",
9
+ "openai-request-id",
10
+ "x-codex-turn-state",
11
+ "openai-model",
12
+ "x-models-etag",
13
+ "x-reasoning-included",
14
+ ]);
15
+
16
+ export interface WsData {
17
+ headers?: Headers; // selected inbound upgrade headers only; never store full cookies/handshake internals
18
+ cancel?: () => void; // cancels the in-flight stream reader/fetch
19
+ turnId?: number; // monotonically increasing per socket; prevents stale frames after replacement turns
20
+ }
21
+
22
+ export class WsSendDroppedError extends Error {
23
+ constructor() {
24
+ super("websocket send dropped the message");
25
+ }
26
+ }
27
+
28
+ export function selectForwardHeaders(headers: Headers): Headers {
29
+ const selected = new Headers();
30
+ for (const name of FORWARD_HEADERS) {
31
+ const value = headers.get(name);
32
+ if (value) selected.set(name, value);
33
+ }
34
+ return selected;
35
+ }
36
+
37
+ export function safeResponseHeaders(headers: Headers): Record<string, string> {
38
+ const out: Record<string, string> = {};
39
+ for (const [name, value] of headers) {
40
+ const lower = name.toLowerCase();
41
+ if (
42
+ SAFE_RESPONSE_HEADER_EXACT.has(lower) ||
43
+ lower.startsWith("x-ratelimit-") ||
44
+ /^x-codex(?:-[a-z0-9-]+)?-(primary|secondary)-(used-percent|window-minutes|reset-at)$/.test(lower) ||
45
+ /^x-codex(?:-[a-z0-9-]+)?-limit-name$/.test(lower)
46
+ ) {
47
+ out[lower] = value;
48
+ }
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export function buildWarmupCompletionFrames(frame: Record<string, unknown>): string[] {
54
+ const createdAt = Math.floor(Date.now() / 1000);
55
+ const baseResponse: Record<string, unknown> = {
56
+ id: "",
57
+ object: "response",
58
+ created_at: createdAt,
59
+ model: typeof frame.model === "string" ? frame.model : undefined,
60
+ output: [],
61
+ };
62
+ return [
63
+ JSON.stringify({
64
+ type: "response.created",
65
+ sequence_number: 0,
66
+ response: { ...baseResponse, status: "in_progress" },
67
+ }),
68
+ JSON.stringify({
69
+ type: "response.completed",
70
+ sequence_number: 1,
71
+ response: { ...baseResponse, status: "completed" },
72
+ }),
73
+ ];
74
+ }
75
+
76
+ export function sendTextFrame(ws: ServerWebSocket<WsData>, payload: string): void {
77
+ if (ws.readyState !== OPEN) throw new WsSendDroppedError();
78
+ const result = ws.send(payload);
79
+ if (result === 0) throw new WsSendDroppedError();
80
+ // Bun returns -1 when queued with backpressure. That is accepted; a later 0 is the hard failure.
81
+ }
82
+
83
+ export function sendJsonFrame(ws: ServerWebSocket<WsData>, payload: Record<string, unknown>): void {
84
+ sendTextFrame(ws, JSON.stringify(payload));
85
+ }
86
+
87
+ export function buildWsErrorFrame(
88
+ status: number,
89
+ error: Record<string, unknown>,
90
+ headers?: Headers,
91
+ ): Record<string, unknown> {
92
+ return {
93
+ type: "error",
94
+ status,
95
+ error,
96
+ headers: headers ? safeResponseHeaders(headers) : {},
97
+ };
98
+ }
99
+
100
+ function parseSseBlock(block: string): string | null {
101
+ const data: string[] = [];
102
+ for (const line of block.split(/\r?\n/)) {
103
+ if (line.startsWith("data:")) {
104
+ const value = line.slice(5);
105
+ data.push(value.startsWith(" ") ? value.slice(1) : value);
106
+ }
107
+ }
108
+ return data.length > 0 ? data.join("\n") : null;
109
+ }
110
+
111
+ function nextSseBlock(buffer: string): { block: string; rest: string } | null {
112
+ const match = buffer.match(/\r?\n\r?\n/);
113
+ if (!match || match.index === undefined) return null;
114
+ return {
115
+ block: buffer.slice(0, match.index),
116
+ rest: buffer.slice(match.index + match[0].length),
117
+ };
118
+ }
119
+
120
+ function payloadType(payload: string): string | null {
121
+ try {
122
+ const json = JSON.parse(payload) as { type?: unknown };
123
+ return typeof json.type === "string" ? json.type : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function protocolError(message: string): Record<string, unknown> {
130
+ return {
131
+ type: "protocol_error",
132
+ code: "websocket_protocol_error",
133
+ message,
134
+ };
135
+ }
136
+
137
+ function sendProtocolError(ws: ServerWebSocket<WsData>, status: number, message: string): void {
138
+ sendJsonFrame(ws, buildWsErrorFrame(status, protocolError(message)));
139
+ }
140
+
141
+ export async function pumpResponsesSseToWebSocket(
142
+ ws: ServerWebSocket<WsData>,
143
+ sseStream: ReadableStream<Uint8Array>,
144
+ options: { isCurrent?: () => boolean } = {},
145
+ ): Promise<void> {
146
+ const reader = sseStream.getReader();
147
+ const isCurrent = options.isCurrent ?? (() => true);
148
+ const cancel = () => {
149
+ void reader.cancel().catch(() => {});
150
+ };
151
+ ws.data.cancel = cancel;
152
+
153
+ const decoder = new TextDecoder();
154
+ let buffer = "";
155
+ let terminalSeen = false;
156
+
157
+ const handlePayload = (payload: string): boolean => {
158
+ if (!isCurrent()) return true;
159
+ if (payload === "[DONE]") return false;
160
+ const type = payloadType(payload);
161
+ if (!type) {
162
+ sendProtocolError(ws, 502, "Invalid JSON payload in upstream SSE frame");
163
+ terminalSeen = true;
164
+ void reader.cancel().catch(() => {});
165
+ return true;
166
+ }
167
+ if (terminalSeen) return true;
168
+ sendTextFrame(ws, payload);
169
+ if (TERMINAL_TYPES.has(type)) {
170
+ terminalSeen = true;
171
+ void reader.cancel().catch(() => {});
172
+ return true;
173
+ }
174
+ return false;
175
+ };
176
+
177
+ try {
178
+ while (!terminalSeen) {
179
+ const { done, value } = await reader.read();
180
+ if (done) break;
181
+ buffer += decoder.decode(value, { stream: true });
182
+ let next: { block: string; rest: string } | null;
183
+ while ((next = nextSseBlock(buffer))) {
184
+ buffer = next.rest;
185
+ const payload = parseSseBlock(next.block);
186
+ if (payload && handlePayload(payload)) break;
187
+ }
188
+ }
189
+ buffer += decoder.decode();
190
+ if (!terminalSeen && buffer.trim()) {
191
+ const payload = parseSseBlock(buffer);
192
+ if (payload) handlePayload(payload);
193
+ }
194
+ if (!terminalSeen && isCurrent()) {
195
+ sendProtocolError(ws, 502, "Upstream stream ended before response terminal event");
196
+ }
197
+ } catch (err) {
198
+ if (!terminalSeen && isCurrent() && ws.readyState === OPEN) {
199
+ sendProtocolError(ws, 502, err instanceof Error ? err.message : String(err));
200
+ }
201
+ } finally {
202
+ if (ws.data.cancel === cancel) ws.data.cancel = undefined;
203
+ }
204
+ }
205
+
206
+ export function sendResponsesJsonAsEvents(
207
+ ws: ServerWebSocket<WsData>,
208
+ response: Record<string, unknown>,
209
+ ): void {
210
+ const output = Array.isArray(response.output) ? response.output : [];
211
+ sendJsonFrame(ws, {
212
+ type: "response.created",
213
+ response: { ...response, status: "in_progress", output: [] },
214
+ });
215
+ output.forEach((item, outputIndex) => {
216
+ sendJsonFrame(ws, {
217
+ type: "response.output_item.done",
218
+ output_index: outputIndex,
219
+ item,
220
+ });
221
+ });
222
+ sendJsonFrame(ws, {
223
+ type: "response.completed",
224
+ response: { ...response, status: "completed" },
225
+ });
226
+ }
227
+
228
+ function errorPayloadFromText(text: string): Record<string, unknown> {
229
+ try {
230
+ const json = JSON.parse(text) as { error?: unknown };
231
+ if (json.error && typeof json.error === "object" && !Array.isArray(json.error)) {
232
+ return json.error as Record<string, unknown>;
233
+ }
234
+ } catch {
235
+ /* fall through */
236
+ }
237
+ return {
238
+ type: "upstream_error",
239
+ message: text ? text.slice(0, 500) : "Upstream request failed",
240
+ };
241
+ }
242
+
243
+ export async function sendResponseToWebSocket(
244
+ ws: ServerWebSocket<WsData>,
245
+ response: Response,
246
+ isCurrent: () => boolean,
247
+ ): Promise<void> {
248
+ if (!isCurrent()) {
249
+ await response.body?.cancel().catch(() => {});
250
+ return;
251
+ }
252
+
253
+ if (!response.ok) {
254
+ const text = await response.text().catch(() => "");
255
+ if (!isCurrent()) return;
256
+ sendJsonFrame(ws, buildWsErrorFrame(response.status, errorPayloadFromText(text), response.headers));
257
+ return;
258
+ }
259
+
260
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
261
+ if (!response.body) {
262
+ sendJsonFrame(ws, buildWsErrorFrame(502, {
263
+ type: "protocol_error",
264
+ code: "websocket_protocol_error",
265
+ message: `Unexpected successful upstream response without a body (${response.status})`,
266
+ }, response.headers));
267
+ return;
268
+ }
269
+
270
+ if (contentType.includes("text/event-stream")) {
271
+ await pumpResponsesSseToWebSocket(ws, response.body, { isCurrent });
272
+ return;
273
+ }
274
+
275
+ if (contentType.includes("application/json")) {
276
+ const text = await response.text();
277
+ if (!isCurrent()) return;
278
+ const json = JSON.parse(text) as Record<string, unknown>;
279
+ sendResponsesJsonAsEvents(ws, json);
280
+ return;
281
+ }
282
+
283
+ const { prefix, stream } = await readBoundedPrefix(response.body);
284
+ if (!isCurrent()) {
285
+ await stream.cancel().catch(() => {});
286
+ return;
287
+ }
288
+ if (looksLikeSse(prefix)) {
289
+ await pumpResponsesSseToWebSocket(ws, stream, { isCurrent });
290
+ return;
291
+ }
292
+
293
+ const text = await new Response(stream).text();
294
+ if (!isCurrent()) return;
295
+ const trimmed = text.trim();
296
+ if (trimmed.startsWith("{")) {
297
+ const json = JSON.parse(trimmed) as Record<string, unknown>;
298
+ sendResponsesJsonAsEvents(ws, json);
299
+ return;
300
+ }
301
+
302
+ sendJsonFrame(ws, buildWsErrorFrame(502, {
303
+ type: "protocol_error",
304
+ code: "websocket_protocol_error",
305
+ message: `Unexpected successful non-SSE upstream response (${contentType || "missing content-type"})`,
306
+ }, response.headers));
307
+ }
308
+
309
+ export async function readBoundedPrefix(
310
+ body: ReadableStream<Uint8Array>,
311
+ maxBytes = 4096,
312
+ ): Promise<{ prefix: Uint8Array; stream: ReadableStream<Uint8Array> }> {
313
+ const reader = body.getReader();
314
+ const chunks: Uint8Array[] = [];
315
+ let remainder: Uint8Array | undefined;
316
+ let total = 0;
317
+ while (total < maxBytes) {
318
+ const { done, value } = await reader.read();
319
+ if (done) break;
320
+ const take = Math.min(value.byteLength, maxBytes - total);
321
+ if (take > 0) {
322
+ chunks.push(value.slice(0, take));
323
+ total += take;
324
+ }
325
+ if (take < value.byteLength) {
326
+ remainder = value.slice(take);
327
+ break;
328
+ }
329
+ }
330
+ const prefix = new Uint8Array(total);
331
+ let offset = 0;
332
+ for (const chunk of chunks) {
333
+ prefix.set(chunk, offset);
334
+ offset += chunk.byteLength;
335
+ }
336
+ const stream = new ReadableStream<Uint8Array>({
337
+ start(controller) {
338
+ if (prefix.byteLength > 0) controller.enqueue(prefix);
339
+ if (remainder && remainder.byteLength > 0) controller.enqueue(remainder);
340
+ },
341
+ async pull(controller) {
342
+ const { done, value } = await reader.read();
343
+ if (done) {
344
+ controller.close();
345
+ return;
346
+ }
347
+ controller.enqueue(value);
348
+ },
349
+ cancel(reason) {
350
+ return reader.cancel(reason);
351
+ },
352
+ });
353
+ return { prefix, stream };
354
+ }
355
+
356
+ export function looksLikeSse(prefix: Uint8Array): boolean {
357
+ const text = new TextDecoder().decode(prefix);
358
+ return /^\s*(event:|data:)/.test(text);
359
+ }