Skip to content

Commit e27f44c

Browse files
committed
fix: harden responses websocket protocol
1 parent 334f7c2 commit e27f44c

6 files changed

Lines changed: 558 additions & 76 deletions

File tree

src/bridge.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export function bridgeToResponsesSSE(
4343
toolSearchToolNames?: Set<string>,
4444
onCancel?: () => void,
4545
heartbeatMs = 2_000,
46+
options?: { responseId?: string },
4647
): ReadableStream<Uint8Array> {
4748
// Freeform/custom tools (apply_patch) carry their body in `input`; the model is given a
4849
// function with `{input:string}`, so unwrap it here when relaying back as a custom_tool_call.
@@ -55,7 +56,7 @@ export function bridgeToResponsesSSE(
5556
try { const o = JSON.parse(args); return o && typeof o === "object" ? o : {}; } catch { return {}; }
5657
};
5758
const encoder = new TextEncoder();
58-
const responseId = `resp_${uuid()}`;
59+
const responseId = options?.responseId ?? `resp_${uuid()}`;
5960
let seq = 0;
6061
// Set once the client is gone (cancel) or an enqueue throws on a torn-down controller, so we
6162
// never enqueue again and never throw a second time inside start() — the RC2 double-throw that

src/responses/parser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,12 @@ export function parseRequest(body: unknown): OcxParsedRequest {
386386
const structuredOutput = detectStructuredOutput(data.text);
387387

388388
return {
389-
modelId: data.model, context, stream: data.stream === true, options, _rawBody: body,
389+
modelId: data.model,
390+
...(data.previous_response_id ? { previousResponseId: data.previous_response_id } : {}),
391+
context,
392+
stream: data.stream === true,
393+
options,
394+
_rawBody: body,
390395
...(webSearch ? { _webSearch: webSearch } : {}),
391396
...(structuredOutput ? { _structuredOutput: true } : {}),
392397
};

src/server.ts

Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,17 @@ import { createAnthropicAdapter } from "./adapters/anthropic";
44
import { createAzureAdapter } from "./adapters/azure";
55
import { createGoogleAdapter } from "./adapters/google";
66
import { createOpenAIChatAdapter } from "./adapters/openai-chat";
7-
import { createResponsesPassthroughAdapter, FORWARD_HEADERS } from "./adapters/openai-responses";
7+
import { createResponsesPassthroughAdapter } from "./adapters/openai-responses";
88
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
9-
import { buildWarmupCompletionFrames, pumpSseToWebSocket, type WsData } from "./ws-bridge";
9+
import {
10+
buildWarmupCompletionFrames,
11+
buildWsErrorFrame,
12+
selectForwardHeaders,
13+
sendJsonFrame,
14+
sendResponseToWebSocket,
15+
sendTextFrame,
16+
type WsData,
17+
} from "./ws-bridge";
1018
import type { ServerWebSocket } from "bun";
1119
import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig, websocketsEnabled } from "./config";
1220
import { parseRequest } from "./responses/parser";
@@ -88,7 +96,12 @@ export function resolveAdapter(providerConfig: OcxProviderConfig) {
8896
}
8997
}
9098

91-
async function handleResponses(req: Request, config: OcxConfig, logCtx: { model: string; provider: string }): Promise<Response> {
99+
async function handleResponses(
100+
req: Request,
101+
config: OcxConfig,
102+
logCtx: { model: string; provider: string },
103+
options: { forceEmptyResponseId?: boolean } = {},
104+
): Promise<Response> {
92105
let body: unknown;
93106
try {
94107
body = await req.json();
@@ -215,7 +228,16 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
215228
if (t.freeform) freeformToolNames.add(t.name);
216229
if (t.toolSearch) toolSearchToolNames.add(t.name);
217230
}
218-
const sseStream = bridgeToResponsesSSE(eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => upstream.abort());
231+
const sseStream = bridgeToResponsesSSE(
232+
eventStream,
233+
parsed.modelId,
234+
toolNsMap,
235+
freeformToolNames,
236+
toolSearchToolNames,
237+
() => upstream.abort(),
238+
2_000,
239+
options.forceEmptyResponseId ? { responseId: "" } : undefined,
240+
);
219241
return new Response(sseStream, {
220242
headers: {
221243
"Content-Type": "text/event-stream",
@@ -527,7 +549,7 @@ export function startServer(port?: number) {
527549
// Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
528550
// handshake-time only, so capture inbound headers and thread them into the pipeline.
529551
if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
530-
if (server.upgrade(req, { data: { headers: req.headers } })) return undefined as unknown as Response;
552+
if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response;
531553
return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
532554
}
533555

@@ -584,7 +606,7 @@ export function startServer(port?: number) {
584606
// Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
585607
// socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
586608
// Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
587-
async message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
609+
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
588610
let frame: Record<string, unknown>;
589611
try {
590612
frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
@@ -593,43 +615,46 @@ export function startServer(port?: number) {
593615
}
594616
if (frame.type === "response.processed") return; // ack — no-op
595617
if (frame.type !== "response.create") return;
618+
619+
ws.data.cancel?.();
620+
const turnId = (ws.data.turnId ?? 0) + 1;
621+
ws.data.turnId = turnId;
622+
const isCurrent = () => ws.data.turnId === turnId;
623+
596624
if (frame.generate === false) {
597625
for (const payload of buildWarmupCompletionFrames(frame)) {
598-
if (ws.readyState === 1) ws.send(payload);
626+
if (!isCurrent()) return;
627+
sendTextFrame(ws, payload);
599628
}
600629
return;
601630
}
631+
602632
const payload: Record<string, unknown> = { ...frame };
603633
delete payload.type;
604-
const logCtx = { model: "unknown", provider: "unknown" };
605-
const fwd = new Headers({ "content-type": "application/json" });
606-
for (const h of FORWARD_HEADERS) {
607-
const v = ws.data.headers?.get(h);
608-
if (v) fwd.set(h, v); // native passthrough auth is handshake-time only
609-
}
610-
const req = new Request("http://localhost/v1/responses", {
611-
method: "POST",
612-
headers: fwd,
613-
body: JSON.stringify({ ...payload, stream: true }),
614-
});
615-
const response = await handleResponses(req, config, logCtx);
616-
if (response.ok && response.body) {
617-
await pumpSseToWebSocket(ws, response.body);
618-
return;
619-
}
620-
// Non-SSE (error or non-stream) → wrap as a terminal WS frame.
621-
const text = await response.text();
622-
try {
623-
const json = JSON.parse(text) as Record<string, unknown>;
624-
if (response.ok) {
625-
if (ws.readyState === 1) ws.send(JSON.stringify({ type: "response.completed", response: json }));
626-
return;
634+
void (async () => {
635+
const logCtx = { model: "unknown", provider: "unknown" };
636+
const fwd = new Headers({ "content-type": "application/json" });
637+
ws.data.headers?.forEach((value, key) => fwd.set(key, value));
638+
const req = new Request("http://localhost/v1/responses", {
639+
method: "POST",
640+
headers: fwd,
641+
body: JSON.stringify({ ...payload, stream: true }),
642+
});
643+
try {
644+
const response = await handleResponses(req, config, logCtx, { forceEmptyResponseId: true });
645+
await sendResponseToWebSocket(ws, response, isCurrent);
646+
} catch (err) {
647+
if (!isCurrent()) return;
648+
try {
649+
sendJsonFrame(ws, buildWsErrorFrame(502, {
650+
type: "proxy_error",
651+
message: err instanceof Error ? err.message : String(err),
652+
}));
653+
} catch {
654+
/* socket already gone or send dropped */
655+
}
627656
}
628-
const error = (json.error as Record<string, unknown>) ?? { message: text.slice(0, 500) };
629-
if (ws.readyState === 1) ws.send(JSON.stringify({ type: "response.failed", response: { status: "failed", error } }));
630-
} catch {
631-
if (ws.readyState === 1) ws.send(JSON.stringify({ type: "response.failed", response: { status: "failed", error: { message: text.slice(0, 500) } } }));
632-
}
657+
})();
633658
},
634659
close(ws: ServerWebSocket<WsData>) {
635660
ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export interface OcxParsedRequest {
22
modelId: string;
3+
previousResponseId?: string;
34
context: OcxContext;
45
stream: boolean;
56
options: OcxRequestOptions;

0 commit comments

Comments
 (0)