@bitkyc08/opencodex 0.2.2 → 1.9.1
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/README.md +3 -1
- package/gui/dist/assets/{index-Dt5t57MW.js → index-CDhJ0DI7.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +3 -1
- package/src/abort.ts +29 -0
- package/src/adapters/anthropic.ts +15 -5
- package/src/adapters/google.ts +27 -11
- package/src/adapters/openai-chat.ts +38 -12
- package/src/adapters/openai-responses.ts +18 -1
- package/src/bridge.ts +155 -17
- package/src/cli.ts +0 -0
- package/src/codex-catalog.ts +102 -11
- package/src/codex-inject.ts +47 -4
- package/src/config.ts +5 -0
- package/src/debug.ts +10 -0
- package/src/errors.ts +47 -0
- package/src/generated/jawcode-model-metadata.ts +69 -0
- package/src/init.ts +5 -32
- package/src/oauth/index.ts +19 -33
- package/src/oauth/key-providers.ts +2 -63
- package/src/providers/derive.ts +163 -0
- package/src/providers/registry.ts +140 -0
- package/src/responses/parser.ts +6 -1
- package/src/server.ts +182 -9
- package/src/types.ts +6 -0
- package/src/vision/describe.ts +6 -1
- package/src/vision/index.ts +2 -1
- package/src/web-search/executor.ts +6 -1
- package/src/web-search/loop.ts +9 -3
- package/src/ws-bridge.ts +359 -0
package/src/server.ts
CHANGED
|
@@ -6,7 +6,17 @@ import { createGoogleAdapter } from "./adapters/google";
|
|
|
6
6
|
import { createOpenAIChatAdapter } from "./adapters/openai-chat";
|
|
7
7
|
import { createResponsesPassthroughAdapter } from "./adapters/openai-responses";
|
|
8
8
|
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
buildWarmupCompletionFrames,
|
|
11
|
+
buildWsErrorFrame,
|
|
12
|
+
selectForwardHeaders,
|
|
13
|
+
sendJsonFrame,
|
|
14
|
+
sendResponseToWebSocket,
|
|
15
|
+
sendTextFrame,
|
|
16
|
+
type WsData,
|
|
17
|
+
} from "./ws-bridge";
|
|
18
|
+
import type { ServerWebSocket } from "bun";
|
|
19
|
+
import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig, websocketsEnabled } from "./config";
|
|
10
20
|
import { parseRequest } from "./responses/parser";
|
|
11
21
|
import { routeModel } from "./router";
|
|
12
22
|
import { namespacedToolName } from "./types";
|
|
@@ -19,6 +29,7 @@ import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "./web-searc
|
|
|
19
29
|
import { describeImagesInPlace, planVisionSidecar } from "./vision";
|
|
20
30
|
import { removeCredential } from "./oauth/store";
|
|
21
31
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers";
|
|
32
|
+
import { deriveProviderPresets } from "./providers/derive";
|
|
22
33
|
import type { OcxConfig, OcxProviderConfig } from "./types";
|
|
23
34
|
|
|
24
35
|
const VERSION = "0.0.1";
|
|
@@ -67,7 +78,7 @@ function serveGuiFile(pathname: string): Response | null {
|
|
|
67
78
|
});
|
|
68
79
|
}
|
|
69
80
|
|
|
70
|
-
function resolveAdapter(providerConfig: OcxProviderConfig) {
|
|
81
|
+
export function resolveAdapter(providerConfig: OcxProviderConfig) {
|
|
71
82
|
switch (providerConfig.adapter) {
|
|
72
83
|
case "openai-chat":
|
|
73
84
|
return createOpenAIChatAdapter(providerConfig);
|
|
@@ -77,6 +88,7 @@ function resolveAdapter(providerConfig: OcxProviderConfig) {
|
|
|
77
88
|
return createResponsesPassthroughAdapter(providerConfig);
|
|
78
89
|
case "google":
|
|
79
90
|
return createGoogleAdapter(providerConfig);
|
|
91
|
+
case "azure":
|
|
80
92
|
case "azure-openai":
|
|
81
93
|
return createAzureAdapter(providerConfig);
|
|
82
94
|
default:
|
|
@@ -84,7 +96,12 @@ function resolveAdapter(providerConfig: OcxProviderConfig) {
|
|
|
84
96
|
}
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
async function handleResponses(
|
|
99
|
+
async function handleResponses(
|
|
100
|
+
req: Request,
|
|
101
|
+
config: OcxConfig,
|
|
102
|
+
logCtx: { model: string; provider: string },
|
|
103
|
+
options: { forceEmptyResponseId?: boolean; abortSignal?: AbortSignal } = {},
|
|
104
|
+
): Promise<Response> {
|
|
88
105
|
let body: unknown;
|
|
89
106
|
try {
|
|
90
107
|
body = await req.json();
|
|
@@ -133,24 +150,30 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
|
|
|
133
150
|
// with text BEFORE the main call, so the text-only model can reason about it.
|
|
134
151
|
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, req.headers);
|
|
135
152
|
if (visionPlan) {
|
|
136
|
-
await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings);
|
|
153
|
+
await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings, options.abortSignal);
|
|
137
154
|
}
|
|
138
155
|
|
|
139
156
|
const adapter = resolveAdapter(route.provider);
|
|
140
157
|
|
|
141
158
|
if ("passthrough" in adapter && adapter.passthrough) {
|
|
142
159
|
const request = adapter.buildRequest(parsed, { headers: req.headers });
|
|
160
|
+
// Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
|
|
161
|
+
// consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
|
|
162
|
+
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
|
|
163
|
+
const upstream = new AbortController();
|
|
164
|
+
linkAbortSignal(upstream, options.abortSignal);
|
|
143
165
|
let upstreamResponse: Response;
|
|
144
166
|
try {
|
|
145
167
|
upstreamResponse = await fetch(request.url, {
|
|
146
168
|
method: request.method,
|
|
147
169
|
headers: request.headers,
|
|
148
170
|
body: request.body,
|
|
171
|
+
signal: upstream.signal,
|
|
149
172
|
});
|
|
150
173
|
} catch (err) {
|
|
151
174
|
return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
152
175
|
}
|
|
153
|
-
return new Response(upstreamResponse.body, {
|
|
176
|
+
return new Response(relayWithAbort(upstreamResponse.body, upstream), {
|
|
154
177
|
status: upstreamResponse.status,
|
|
155
178
|
headers: sanitizePassthroughHeaders(upstreamResponse.headers),
|
|
156
179
|
});
|
|
@@ -169,17 +192,23 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
|
|
|
169
192
|
incomingHeaders: req.headers,
|
|
170
193
|
settings: wsPlan.settings,
|
|
171
194
|
maxSearches: wsPlan.maxSearches,
|
|
195
|
+
abortSignal: options.abortSignal,
|
|
172
196
|
});
|
|
173
197
|
}
|
|
174
198
|
|
|
175
199
|
const request = adapter.buildRequest(parsed, { headers: req.headers });
|
|
176
200
|
|
|
201
|
+
// Abort the upstream fetch if the client (Codex) disconnects mid-stream, so a cancelled turn does
|
|
202
|
+
// not leak the upstream connection or keep draining tokens. The bridge's cancel() fires upstream.abort() (RC2).
|
|
203
|
+
const upstream = new AbortController();
|
|
204
|
+
linkAbortSignal(upstream, options.abortSignal);
|
|
177
205
|
let upstreamResponse: Response;
|
|
178
206
|
try {
|
|
179
207
|
upstreamResponse = await fetch(request.url, {
|
|
180
208
|
method: request.method,
|
|
181
209
|
headers: request.headers,
|
|
182
210
|
body: request.body,
|
|
211
|
+
signal: upstream.signal,
|
|
183
212
|
});
|
|
184
213
|
} catch (err) {
|
|
185
214
|
return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -202,7 +231,16 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
|
|
|
202
231
|
if (t.freeform) freeformToolNames.add(t.name);
|
|
203
232
|
if (t.toolSearch) toolSearchToolNames.add(t.name);
|
|
204
233
|
}
|
|
205
|
-
const sseStream = bridgeToResponsesSSE(
|
|
234
|
+
const sseStream = bridgeToResponsesSSE(
|
|
235
|
+
eventStream,
|
|
236
|
+
parsed.modelId,
|
|
237
|
+
toolNsMap,
|
|
238
|
+
freeformToolNames,
|
|
239
|
+
toolSearchToolNames,
|
|
240
|
+
() => upstream.abort(),
|
|
241
|
+
2_000,
|
|
242
|
+
options.forceEmptyResponseId ? { responseId: "" } : undefined,
|
|
243
|
+
);
|
|
206
244
|
return new Response(sseStream, {
|
|
207
245
|
headers: {
|
|
208
246
|
"Content-Type": "text/event-stream",
|
|
@@ -224,6 +262,15 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
|
|
|
224
262
|
return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
|
|
225
263
|
}
|
|
226
264
|
|
|
265
|
+
export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): void {
|
|
266
|
+
if (!signal) return;
|
|
267
|
+
if (signal.aborted) {
|
|
268
|
+
upstream.abort(signal.reason);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
signal.addEventListener("abort", () => upstream.abort(signal.reason), { once: true });
|
|
272
|
+
}
|
|
273
|
+
|
|
227
274
|
const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
|
|
228
275
|
const MAX_LOG_SIZE = 200;
|
|
229
276
|
|
|
@@ -232,6 +279,39 @@ function addRequestLog(entry: typeof requestLog[number]) {
|
|
|
232
279
|
if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
|
|
233
280
|
}
|
|
234
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Relay an upstream body verbatim while wiring client-cancel -> upstream.abort(). A body returned
|
|
284
|
+
* directly from fetch does NOT propagate the consumer's cancel to a signalled fetch, so a client
|
|
285
|
+
* disconnect would leak the upstream connection. Pumping through this stream (whose cancel() aborts
|
|
286
|
+
* the upstream) fixes the leak with zero byte changes — passthrough fidelity is preserved (RC2).
|
|
287
|
+
*/
|
|
288
|
+
export function relayWithAbort(
|
|
289
|
+
body: ReadableStream<Uint8Array> | null,
|
|
290
|
+
upstream: AbortController,
|
|
291
|
+
): ReadableStream<Uint8Array> | null {
|
|
292
|
+
if (!body) return null;
|
|
293
|
+
const reader = body.getReader();
|
|
294
|
+
return new ReadableStream<Uint8Array>({
|
|
295
|
+
async pull(controller) {
|
|
296
|
+
try {
|
|
297
|
+
const { done, value } = await reader.read();
|
|
298
|
+
if (done) {
|
|
299
|
+
controller.close();
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
controller.enqueue(value);
|
|
303
|
+
} catch (err) {
|
|
304
|
+
try { controller.error(err); } catch { /* already torn down */ }
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
cancel(reason) {
|
|
308
|
+
// Client disconnected: abort the upstream fetch and release the reader so we do not leak it.
|
|
309
|
+
upstream.abort(reason);
|
|
310
|
+
reader.cancel(reason).catch(() => {});
|
|
311
|
+
},
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
235
315
|
/**
|
|
236
316
|
* Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
|
|
237
317
|
* (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
|
|
@@ -239,7 +319,18 @@ function addRequestLog(entry: typeof requestLog[number]) {
|
|
|
239
319
|
* Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim.
|
|
240
320
|
*/
|
|
241
321
|
export function sanitizePassthroughHeaders(upstream: Headers): Headers {
|
|
242
|
-
const DROP = new Set([
|
|
322
|
+
const DROP = new Set([
|
|
323
|
+
"content-encoding",
|
|
324
|
+
"content-length",
|
|
325
|
+
"transfer-encoding",
|
|
326
|
+
"connection",
|
|
327
|
+
"keep-alive",
|
|
328
|
+
"proxy-authenticate",
|
|
329
|
+
"proxy-authorization",
|
|
330
|
+
"te",
|
|
331
|
+
"trailer",
|
|
332
|
+
"upgrade",
|
|
333
|
+
]);
|
|
243
334
|
const out = new Headers();
|
|
244
335
|
upstream.forEach((value, key) => {
|
|
245
336
|
if (!DROP.has(key.toLowerCase())) out.set(key, value);
|
|
@@ -361,6 +452,12 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
|
|
|
361
452
|
return jsonResponse({ providers: listKeyLoginProviders() });
|
|
362
453
|
}
|
|
363
454
|
|
|
455
|
+
// Complete GUI picker presets, derived from the canonical provider registry. The GUI is a
|
|
456
|
+
// standalone Vite package, so it consumes this runtime view instead of importing repo-root src.
|
|
457
|
+
if (url.pathname === "/api/provider-presets" && req.method === "GET") {
|
|
458
|
+
return jsonResponse({ providers: deriveProviderPresets() });
|
|
459
|
+
}
|
|
460
|
+
|
|
364
461
|
// Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
|
|
365
462
|
// first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
|
|
366
463
|
if (url.pathname === "/api/subagent-models" && req.method === "GET") {
|
|
@@ -452,7 +549,7 @@ export function startServer(port?: number) {
|
|
|
452
549
|
}
|
|
453
550
|
const listenPort = port ?? config.port ?? 10100;
|
|
454
551
|
|
|
455
|
-
const server = Bun.serve({
|
|
552
|
+
const server = Bun.serve<WsData>({
|
|
456
553
|
port: listenPort,
|
|
457
554
|
async fetch(req) {
|
|
458
555
|
const url = new URL(req.url);
|
|
@@ -461,6 +558,13 @@ export function startServer(port?: number) {
|
|
|
461
558
|
return new Response(null, { status: 204, headers: corsHeaders() });
|
|
462
559
|
}
|
|
463
560
|
|
|
561
|
+
// Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
|
|
562
|
+
// handshake-time only, so capture inbound headers and thread them into the pipeline.
|
|
563
|
+
if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
564
|
+
if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response;
|
|
565
|
+
return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
|
|
566
|
+
}
|
|
567
|
+
|
|
464
568
|
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
465
569
|
return jsonResponse({ status: "ok", version: VERSION, uptime: process.uptime() });
|
|
466
570
|
}
|
|
@@ -481,7 +585,7 @@ export function startServer(port?: number) {
|
|
|
481
585
|
// Codex client → Codex catalog shape: native gpt + namespaced routed models,
|
|
482
586
|
// cloned from a native template so required fields (base_instructions, etc.) are present.
|
|
483
587
|
// Pass the subagent picks so featured models lead by priority (matches the on-disk file).
|
|
484
|
-
return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels) });
|
|
588
|
+
return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config)) });
|
|
485
589
|
}
|
|
486
590
|
// OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
|
|
487
591
|
const data = [
|
|
@@ -510,6 +614,75 @@ export function startServer(port?: number) {
|
|
|
510
614
|
|
|
511
615
|
return formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`);
|
|
512
616
|
},
|
|
617
|
+
websocket: {
|
|
618
|
+
// Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
|
|
619
|
+
// socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
|
|
620
|
+
// Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
|
|
621
|
+
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
622
|
+
let frame: Record<string, unknown>;
|
|
623
|
+
try {
|
|
624
|
+
frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
|
|
625
|
+
} catch {
|
|
626
|
+
return; // text-only contract; ignore unparseable frames
|
|
627
|
+
}
|
|
628
|
+
if (frame.type === "response.processed") return; // ack — no-op
|
|
629
|
+
if (frame.type !== "response.create") return;
|
|
630
|
+
|
|
631
|
+
ws.data.cancel?.();
|
|
632
|
+
const turnId = (ws.data.turnId ?? 0) + 1;
|
|
633
|
+
ws.data.turnId = turnId;
|
|
634
|
+
const isCurrent = () => ws.data.turnId === turnId;
|
|
635
|
+
const turnAbort = new AbortController();
|
|
636
|
+
const cancelTurn = () => {
|
|
637
|
+
turnAbort.abort("websocket turn superseded or closed");
|
|
638
|
+
};
|
|
639
|
+
ws.data.cancel = cancelTurn;
|
|
640
|
+
|
|
641
|
+
if (frame.generate === false) {
|
|
642
|
+
for (const payload of buildWarmupCompletionFrames(frame)) {
|
|
643
|
+
if (!isCurrent()) return;
|
|
644
|
+
sendTextFrame(ws, payload);
|
|
645
|
+
}
|
|
646
|
+
if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
const payload: Record<string, unknown> = { ...frame };
|
|
651
|
+
delete payload.type;
|
|
652
|
+
void (async () => {
|
|
653
|
+
const logCtx = { model: "unknown", provider: "unknown" };
|
|
654
|
+
const fwd = new Headers({ "content-type": "application/json" });
|
|
655
|
+
ws.data.headers?.forEach((value, key) => fwd.set(key, value));
|
|
656
|
+
const req = new Request("http://localhost/v1/responses", {
|
|
657
|
+
method: "POST",
|
|
658
|
+
headers: fwd,
|
|
659
|
+
body: JSON.stringify({ ...payload, stream: true }),
|
|
660
|
+
});
|
|
661
|
+
try {
|
|
662
|
+
const response = await handleResponses(req, config, logCtx, {
|
|
663
|
+
forceEmptyResponseId: true,
|
|
664
|
+
abortSignal: turnAbort.signal,
|
|
665
|
+
});
|
|
666
|
+
await sendResponseToWebSocket(ws, response, isCurrent);
|
|
667
|
+
} catch (err) {
|
|
668
|
+
if (!isCurrent()) return;
|
|
669
|
+
try {
|
|
670
|
+
sendJsonFrame(ws, buildWsErrorFrame(502, {
|
|
671
|
+
type: "proxy_error",
|
|
672
|
+
message: err instanceof Error ? err.message : String(err),
|
|
673
|
+
}));
|
|
674
|
+
} catch {
|
|
675
|
+
/* socket already gone or send dropped */
|
|
676
|
+
}
|
|
677
|
+
} finally {
|
|
678
|
+
if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
|
|
679
|
+
}
|
|
680
|
+
})();
|
|
681
|
+
},
|
|
682
|
+
close(ws: ServerWebSocket<WsData>) {
|
|
683
|
+
ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
|
|
684
|
+
},
|
|
685
|
+
},
|
|
513
686
|
});
|
|
514
687
|
|
|
515
688
|
console.log(`🚀 opencodex proxy running on http://localhost:${listenPort}`);
|
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 true; set false to force HTTPS/SSE. */
|
|
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. */
|
package/src/vision/describe.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/vision/index.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/web-search/loop.ts
CHANGED
|
@@ -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, {
|
|
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
|
}
|