mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(ai): internalize ChatGPT SSE protocol (#122930)
This commit is contained in:
committed by
GitHub
parent
9d9c36c459
commit
5eebaf9e5c
@@ -0,0 +1,164 @@
|
|||||||
|
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { configureAiTransportHost } from "../host.js";
|
||||||
|
import type { Context, Model } from "../types.js";
|
||||||
|
import {
|
||||||
|
closeOpenAICodexWebSocketSessions,
|
||||||
|
resetOpenAICodexWebSocketStateForTest,
|
||||||
|
streamOpenAICodexResponses,
|
||||||
|
} from "./openai-chatgpt-responses.js";
|
||||||
|
|
||||||
|
function createJwt(payload: Record<string, unknown>): string {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url");
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||||
|
return `${header}.${body}.signature`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = {
|
||||||
|
id: "gpt-5.5",
|
||||||
|
name: "GPT-5.5",
|
||||||
|
api: "openai-chatgpt-responses",
|
||||||
|
provider: "openai",
|
||||||
|
baseUrl: "https://chatgpt.test/backend-api",
|
||||||
|
reasoning: true,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 128_000,
|
||||||
|
maxTokens: 16_000,
|
||||||
|
} satisfies Model<"openai-chatgpt-responses">;
|
||||||
|
|
||||||
|
const context = {
|
||||||
|
messages: [{ role: "user", content: "hi", timestamp: 1 }],
|
||||||
|
} satisfies Context;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
closeOpenAICodexWebSocketSessions();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
resetOpenAICodexWebSocketStateForTest();
|
||||||
|
configureAiTransportHost({});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OpenAI ChatGPT Responses resource limits", () => {
|
||||||
|
it("bounds non-OK response bodies before formatting API errors", async () => {
|
||||||
|
const byteLimit = 16 * 1024;
|
||||||
|
const totalChunks = 32;
|
||||||
|
const prefix = "usage limit ";
|
||||||
|
const chunk = new TextEncoder().encode(
|
||||||
|
`${prefix}${"x".repeat(byteLimit - prefix.length - 2)}😀tail`,
|
||||||
|
);
|
||||||
|
let pullCount = 0;
|
||||||
|
let canceled = false;
|
||||||
|
const overflowing = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
pullCount += 1;
|
||||||
|
if (pullCount > totalChunks) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
canceled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValueOnce(new Response(overflowing, { status: 400, statusText: "Bad Request" }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await streamOpenAICodexResponses(model, context, {
|
||||||
|
apiKey: createJwt({
|
||||||
|
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
|
||||||
|
}),
|
||||||
|
transport: "sse",
|
||||||
|
}).result();
|
||||||
|
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
expect(result.errorMessage).toContain("usage limit");
|
||||||
|
expect(result.errorMessage).not.toContain("�");
|
||||||
|
expect(result.errorMessage).not.toContain("tail");
|
||||||
|
expect(result.errorMessage?.length).toBeLessThanOrEqual(byteLimit);
|
||||||
|
expect(canceled).toBe(true);
|
||||||
|
expect(pullCount).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(pullCount).toBeLessThanOrEqual(3);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds streamed success bodies without content-length", async () => {
|
||||||
|
// 1 MiB chunks; cap is 16 MiB so the bounded reader cancels well before
|
||||||
|
// draining the full 32 MiB advertised body.
|
||||||
|
const chunkBytes = 1024 * 1024;
|
||||||
|
const totalChunks = 32;
|
||||||
|
let pullCount = 0;
|
||||||
|
let cancelReason: unknown;
|
||||||
|
const overflowing = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
pullCount += 1;
|
||||||
|
if (pullCount > totalChunks) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(new Uint8Array(chunkBytes));
|
||||||
|
},
|
||||||
|
cancel(reason) {
|
||||||
|
cancelReason = reason;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
||||||
|
new Response(overflowing, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "text/event-stream" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
const result = await streamOpenAICodexResponses(model, context, {
|
||||||
|
apiKey: createJwt({
|
||||||
|
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
|
||||||
|
}),
|
||||||
|
transport: "sse",
|
||||||
|
}).result();
|
||||||
|
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
expect(result.errorMessage).toMatch(
|
||||||
|
/OpenAI ChatGPT Responses success body exceeded 16777216 bytes/,
|
||||||
|
);
|
||||||
|
expect(cancelReason).toBeInstanceOf(Error);
|
||||||
|
expect(pullCount).toBeGreaterThanOrEqual(17);
|
||||||
|
expect(pullCount).toBeLessThanOrEqual(20);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps oversized Retry-After delays before sleeping", async () => {
|
||||||
|
const fetchMock = vi
|
||||||
|
.fn<typeof fetch>()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response("rate limited", {
|
||||||
|
status: 429,
|
||||||
|
headers: { "retry-after": String(Number.MAX_SAFE_INTEGER) },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockRejectedValueOnce(new Error("usage limit: stop after retry delay"));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const setTimeoutSpy = vi
|
||||||
|
.spyOn(globalThis, "setTimeout")
|
||||||
|
.mockImplementation((callback: TimerHandler) => {
|
||||||
|
if (typeof callback === "function") {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await streamOpenAICodexResponses(model, context, {
|
||||||
|
apiKey: createJwt({
|
||||||
|
"https://api.openai.com/auth": { chatgpt_account_id: "acct-1" },
|
||||||
|
}),
|
||||||
|
transport: "sse",
|
||||||
|
}).result();
|
||||||
|
|
||||||
|
expect(result.stopReason).toBe("error");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseOpenAIChatGptResponsesSse } from "./openai-chatgpt-responses-protocol.js";
|
||||||
|
|
||||||
|
const completedEvent = {
|
||||||
|
type: "response.completed",
|
||||||
|
response: {
|
||||||
|
id: "resp_parser",
|
||||||
|
status: "completed",
|
||||||
|
output: [],
|
||||||
|
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const serializedCompletedEvent = JSON.stringify(completedEvent);
|
||||||
|
const multilineDataLines = JSON.stringify(completedEvent, null, 2)
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => `data: ${line}`);
|
||||||
|
|
||||||
|
describe("ChatGPT Responses SSE frame boundaries", () => {
|
||||||
|
it.each([
|
||||||
|
{ label: "LF", chunks: [`data: ${serializedCompletedEvent}\n\n`] },
|
||||||
|
{ label: "CRLF", chunks: [`data: ${serializedCompletedEvent}\r\n\r\n`] },
|
||||||
|
{ label: "lone CR", chunks: [`data: ${serializedCompletedEvent}\r\r`] },
|
||||||
|
{
|
||||||
|
label: "mixed line endings",
|
||||||
|
chunks: [`event: response.completed\r\ndata: ${serializedCompletedEvent}\n\r\n`],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "chunk-split CRLF",
|
||||||
|
chunks: [
|
||||||
|
`event: response.completed\r`,
|
||||||
|
`\ndata: ${serializedCompletedEvent}\r`,
|
||||||
|
"\n\r",
|
||||||
|
"\n",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "chunk-split lone CR",
|
||||||
|
chunks: ["event: response.completed\r", `data: ${serializedCompletedEvent}\r`, "\r"],
|
||||||
|
},
|
||||||
|
{ label: "multiline LF", chunks: [`${multilineDataLines.join("\n")}\n\n`] },
|
||||||
|
{ label: "multiline CRLF", chunks: [`${multilineDataLines.join("\r\n")}\r\n\r\n`] },
|
||||||
|
{ label: "multiline lone CR", chunks: [`${multilineDataLines.join("\r")}\r\r`] },
|
||||||
|
{
|
||||||
|
label: "multiline mixed line endings",
|
||||||
|
chunks: [
|
||||||
|
`event: response.completed\r\n${multilineDataLines
|
||||||
|
.map(
|
||||||
|
(line, index) => `${line}${index % 3 === 0 ? "\r\n" : index % 3 === 1 ? "\r" : "\n"}`,
|
||||||
|
)
|
||||||
|
.join("")}\r\n`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "multiline chunk-split CRLF",
|
||||||
|
chunks: [...multilineDataLines.flatMap((line) => [`${line}\r`, "\n"]), "\r", "\n"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "multiline chunk-split lone CR",
|
||||||
|
chunks: [...multilineDataLines.flatMap((line) => [line, "\r"]), "\r"],
|
||||||
|
},
|
||||||
|
])("parses $label SSE frame boundaries", async ({ chunks }) => {
|
||||||
|
let chunkIndex = 0;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
const chunk = chunks[chunkIndex++];
|
||||||
|
if (chunk === undefined) {
|
||||||
|
controller.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controller.enqueue(new TextEncoder().encode(chunk));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const events = [];
|
||||||
|
|
||||||
|
for await (const event of parseOpenAIChatGptResponsesSse(new Response(body))) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(events).toEqual([completedEvent]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ label: "lone CR", chunks: [`data: ${serializedCompletedEvent}\r\r`] },
|
||||||
|
{ label: "mixed LF and lone CR", chunks: [`data: ${serializedCompletedEvent}\n\r`] },
|
||||||
|
{ label: "mixed CRLF and lone CR", chunks: [`data: ${serializedCompletedEvent}\r\n\r`] },
|
||||||
|
{ label: "chunk-split lone CR", chunks: [`data: ${serializedCompletedEvent}\r`, "\r"] },
|
||||||
|
{
|
||||||
|
label: "chunk-split mixed LF and lone CR",
|
||||||
|
chunks: [`data: ${serializedCompletedEvent}\n`, "\r"],
|
||||||
|
},
|
||||||
|
])("dispatches a $label SSE frame before an open response closes", async ({ chunks }) => {
|
||||||
|
const cleanup = new AbortController();
|
||||||
|
let canceled = false;
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
cleanup.signal.addEventListener("abort", () => controller.close(), { once: true });
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
controller.enqueue(new TextEncoder().encode(chunk));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
cancel() {
|
||||||
|
canceled = true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const iterator = parseOpenAIChatGptResponsesSse(new Response(body))[Symbol.asyncIterator]();
|
||||||
|
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let receivedEvent = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await Promise.race([
|
||||||
|
iterator.next(),
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
reject(new Error("SSE frame was not dispatched while the response remained open"));
|
||||||
|
}, 1_000);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
receivedEvent = true;
|
||||||
|
|
||||||
|
expect(result).toEqual({ done: false, value: completedEvent });
|
||||||
|
expect(cleanup.signal.aborted).toBe(false);
|
||||||
|
expect(canceled).toBe(false);
|
||||||
|
} finally {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
if (!receivedEvent) {
|
||||||
|
cleanup.abort();
|
||||||
|
}
|
||||||
|
await iterator.return(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(canceled).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../transports/transport-utils.js";
|
||||||
|
import { createSseByteGuard } from "../utils/streaming-byte-guard.js";
|
||||||
|
|
||||||
|
const OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
export class CodexProtocolError extends Error {
|
||||||
|
readonly payload?: unknown;
|
||||||
|
|
||||||
|
constructor(message: string, options?: { payload?: unknown; cause?: unknown }) {
|
||||||
|
super(message);
|
||||||
|
this.name = "CodexProtocolError";
|
||||||
|
this.payload = options?.payload;
|
||||||
|
this.cause = options?.cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* parseOpenAIChatGptResponsesSse(
|
||||||
|
response: Response,
|
||||||
|
): AsyncGenerator<Record<string, unknown>> {
|
||||||
|
if (!response.body) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
// Cap the streaming 200 success-body read at 16 MiB, mirroring the
|
||||||
|
// non-streaming response cap so a hostile endpoint cannot exhaust memory.
|
||||||
|
const guard = createSseByteGuard(reader, {
|
||||||
|
maxBytes: OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES,
|
||||||
|
onOverflow: ({ size, maxBytes }) =>
|
||||||
|
new Error(
|
||||||
|
`OpenAI ChatGPT Responses success body exceeded ${maxBytes} bytes (received ${size})`,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await guard.read();
|
||||||
|
if (value) {
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
if (done) {
|
||||||
|
buffer += decoder.decode();
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// Defer a possible CRLF only when CR does not already complete a blank line.
|
||||||
|
const deferTrailingCr =
|
||||||
|
!done && buffer.endsWith("\r") && !buffer.endsWith("\r\r") && !buffer.endsWith("\n\r");
|
||||||
|
const searchable = deferTrailingCr ? buffer.slice(0, -1) : buffer;
|
||||||
|
// A CRLF is one line ending: never backtrack its CR into a false blank line.
|
||||||
|
const boundary = /(?:\r\n|\r(?!\n)|\n)(?:\r\n|\r(?!\n)|\n)/.exec(searchable);
|
||||||
|
if (!boundary) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const chunk = buffer.slice(0, boundary.index);
|
||||||
|
buffer = buffer.slice(boundary.index + boundary[0].length);
|
||||||
|
|
||||||
|
const dataLines = chunk
|
||||||
|
.split(/\r\n|\r|\n/)
|
||||||
|
.filter((line) => line.startsWith("data:"))
|
||||||
|
.map((line) => line.slice(5).trim());
|
||||||
|
if (dataLines.length > 0) {
|
||||||
|
const data = dataLines.join("\n").trim();
|
||||||
|
if (data && data !== "[DONE]") {
|
||||||
|
let event: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(data) as Record<string, unknown>;
|
||||||
|
} catch (cause) {
|
||||||
|
if (!(cause instanceof SyntaxError)) {
|
||||||
|
throw cause;
|
||||||
|
}
|
||||||
|
throw new CodexProtocolError(MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, { cause });
|
||||||
|
}
|
||||||
|
// Keep suspension outside the parse catch so consumer failures stay consumer-owned.
|
||||||
|
yield event;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
await guard.cancel();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
reader.releaseLock();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { configureAiTransportHost } from "../host.js";
|
|||||||
import type { Context, Model } from "../types.js";
|
import type { Context, Model } from "../types.js";
|
||||||
import {
|
import {
|
||||||
closeOpenAICodexWebSocketSessions,
|
closeOpenAICodexWebSocketSessions,
|
||||||
parseSSEForTest,
|
|
||||||
resetOpenAICodexWebSocketStateForTest,
|
resetOpenAICodexWebSocketStateForTest,
|
||||||
streamOpenAICodexResponses,
|
streamOpenAICodexResponses,
|
||||||
} from "./openai-chatgpt-responses.js";
|
} from "./openai-chatgpt-responses.js";
|
||||||
@@ -313,136 +312,3 @@ describe("OpenAI ChatGPT Responses inference streaming", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ChatGPT Responses SSE frame boundaries", () => {
|
|
||||||
const completedEvent = {
|
|
||||||
type: "response.completed",
|
|
||||||
response: {
|
|
||||||
id: "resp_parser",
|
|
||||||
status: "completed",
|
|
||||||
output: [],
|
|
||||||
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const serializedCompletedEvent = JSON.stringify(completedEvent);
|
|
||||||
const multilineDataLines = JSON.stringify(completedEvent, null, 2)
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => `data: ${line}`);
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
{ label: "LF", chunks: [`data: ${serializedCompletedEvent}\n\n`] },
|
|
||||||
{ label: "CRLF", chunks: [`data: ${serializedCompletedEvent}\r\n\r\n`] },
|
|
||||||
{ label: "lone CR", chunks: [`data: ${serializedCompletedEvent}\r\r`] },
|
|
||||||
{
|
|
||||||
label: "mixed line endings",
|
|
||||||
chunks: [`event: response.completed\r\ndata: ${serializedCompletedEvent}\n\r\n`],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "chunk-split CRLF",
|
|
||||||
chunks: [
|
|
||||||
`event: response.completed\r`,
|
|
||||||
`\ndata: ${serializedCompletedEvent}\r`,
|
|
||||||
"\n\r",
|
|
||||||
"\n",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "chunk-split lone CR",
|
|
||||||
chunks: ["event: response.completed\r", `data: ${serializedCompletedEvent}\r`, "\r"],
|
|
||||||
},
|
|
||||||
{ label: "multiline LF", chunks: [`${multilineDataLines.join("\n")}\n\n`] },
|
|
||||||
{ label: "multiline CRLF", chunks: [`${multilineDataLines.join("\r\n")}\r\n\r\n`] },
|
|
||||||
{ label: "multiline lone CR", chunks: [`${multilineDataLines.join("\r")}\r\r`] },
|
|
||||||
{
|
|
||||||
label: "multiline mixed line endings",
|
|
||||||
chunks: [
|
|
||||||
`event: response.completed\r\n${multilineDataLines
|
|
||||||
.map(
|
|
||||||
(line, index) => `${line}${index % 3 === 0 ? "\r\n" : index % 3 === 1 ? "\r" : "\n"}`,
|
|
||||||
)
|
|
||||||
.join("")}\r\n`,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "multiline chunk-split CRLF",
|
|
||||||
chunks: [...multilineDataLines.flatMap((line) => [`${line}\r`, "\n"]), "\r", "\n"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "multiline chunk-split lone CR",
|
|
||||||
chunks: [...multilineDataLines.flatMap((line) => [line, "\r"]), "\r"],
|
|
||||||
},
|
|
||||||
])("parses $label SSE frame boundaries", async ({ chunks }) => {
|
|
||||||
let chunkIndex = 0;
|
|
||||||
const body = new ReadableStream<Uint8Array>({
|
|
||||||
pull(controller) {
|
|
||||||
const chunk = chunks[chunkIndex++];
|
|
||||||
if (chunk === undefined) {
|
|
||||||
controller.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controller.enqueue(new TextEncoder().encode(chunk));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const events = [];
|
|
||||||
|
|
||||||
for await (const event of parseSSEForTest(new Response(body))) {
|
|
||||||
events.push(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(events).toEqual([completedEvent]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
{ label: "lone CR", chunks: [`data: ${serializedCompletedEvent}\r\r`] },
|
|
||||||
{ label: "mixed LF and lone CR", chunks: [`data: ${serializedCompletedEvent}\n\r`] },
|
|
||||||
{ label: "mixed CRLF and lone CR", chunks: [`data: ${serializedCompletedEvent}\r\n\r`] },
|
|
||||||
{ label: "chunk-split lone CR", chunks: [`data: ${serializedCompletedEvent}\r`, "\r"] },
|
|
||||||
{
|
|
||||||
label: "chunk-split mixed LF and lone CR",
|
|
||||||
chunks: [`data: ${serializedCompletedEvent}\n`, "\r"],
|
|
||||||
},
|
|
||||||
])("dispatches a $label SSE frame before an open response closes", async ({ chunks }) => {
|
|
||||||
const cleanup = new AbortController();
|
|
||||||
let canceled = false;
|
|
||||||
const body = new ReadableStream<Uint8Array>({
|
|
||||||
start(controller) {
|
|
||||||
cleanup.signal.addEventListener("abort", () => controller.close(), { once: true });
|
|
||||||
for (const chunk of chunks) {
|
|
||||||
controller.enqueue(new TextEncoder().encode(chunk));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
canceled = true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const iterator = parseSSEForTest(new Response(body))[Symbol.asyncIterator]();
|
|
||||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
let receivedEvent = false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await Promise.race([
|
|
||||||
iterator.next(),
|
|
||||||
new Promise<never>((_resolve, reject) => {
|
|
||||||
timeout = setTimeout(() => {
|
|
||||||
reject(new Error("SSE frame was not dispatched while the response remained open"));
|
|
||||||
}, 1_000);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
receivedEvent = true;
|
|
||||||
|
|
||||||
expect(result).toEqual({ done: false, value: completedEvent });
|
|
||||||
expect(cleanup.signal.aborted).toBe(false);
|
|
||||||
expect(canceled).toBe(false);
|
|
||||||
} finally {
|
|
||||||
if (timeout) {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
if (!receivedEvent) {
|
|
||||||
cleanup.abort();
|
|
||||||
}
|
|
||||||
await iterator.return(undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(canceled).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { AddressInfo } from "node:net";
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../transports/transport-utils.js";
|
import { MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE } from "../transports/transport-utils.js";
|
||||||
import type { Context, Model } from "../types.js";
|
import type { Context, Model } from "../types.js";
|
||||||
import { parseSSEForTest, streamOpenAICodexResponses } from "./openai-chatgpt-responses.js";
|
import { streamOpenAICodexResponses } from "./openai-chatgpt-responses.js";
|
||||||
|
|
||||||
// Stands in for the payload class this path exposes: text that reached the SSE
|
// Stands in for the payload class this path exposes: text that reached the SSE
|
||||||
// frame as ordinary stream content rather than as a provider error envelope.
|
// frame as ordinary stream content rather than as a provider error envelope.
|
||||||
@@ -94,50 +94,6 @@ async function streamCodexSseFrames(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Codex malformed SSE frames", () => {
|
describe("Codex malformed SSE frames", () => {
|
||||||
it("classifies only parser-owned SyntaxErrors as malformed frames", async () => {
|
|
||||||
const iterator = parseSSEForTest(
|
|
||||||
new Response(`data: ${MALFORMED_FRAME}\n\n`, {
|
|
||||||
headers: { "content-type": "text/event-stream" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let caught: unknown;
|
|
||||||
try {
|
|
||||||
await iterator.next();
|
|
||||||
} catch (error) {
|
|
||||||
caught = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(caught).toMatchObject({
|
|
||||||
name: "CodexProtocolError",
|
|
||||||
message: MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE,
|
|
||||||
cause: expect.any(SyntaxError),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves a consumer-thrown SyntaxError unchanged", async () => {
|
|
||||||
const iterator = parseSSEForTest(
|
|
||||||
new Response(`data: ${COMPLETED_FRAME}\n\n`, {
|
|
||||||
headers: { "content-type": "text/event-stream" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const first = await iterator.next();
|
|
||||||
expect(first).toMatchObject({
|
|
||||||
done: false,
|
|
||||||
value: { type: "response.completed" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const consumerError = new SyntaxError("consumer failed after receiving an event");
|
|
||||||
let caught: unknown;
|
|
||||||
try {
|
|
||||||
await iterator.throw(consumerError);
|
|
||||||
} catch (error) {
|
|
||||||
caught = error;
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(caught).toBe(consumerError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports the shared malformed-fragment error without echoing parser text", async () => {
|
it("reports the shared malformed-fragment error without echoing parser text", async () => {
|
||||||
const result = await streamCodexSseFrames([MALFORMED_FRAME]);
|
const result = await streamCodexSseFrames([MALFORMED_FRAME]);
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../utils/system-prompt-cache-bound
|
|||||||
import {
|
import {
|
||||||
closeOpenAICodexWebSocketSessions,
|
closeOpenAICodexWebSocketSessions,
|
||||||
extractOpenAICodexAccountId,
|
extractOpenAICodexAccountId,
|
||||||
parseSSEForTest,
|
|
||||||
resetOpenAICodexWebSocketStateForTest,
|
resetOpenAICodexWebSocketStateForTest,
|
||||||
streamSimpleOpenAICodexResponses,
|
streamSimpleOpenAICodexResponses,
|
||||||
streamOpenAICodexResponses,
|
streamOpenAICodexResponses,
|
||||||
@@ -981,134 +980,4 @@ describe("streamOpenAICodexResponses transport", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("caps oversized Retry-After delays before sleeping", async () => {
|
|
||||||
const fetchMock = vi
|
|
||||||
.fn<typeof fetch>()
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
new Response("rate limited", {
|
|
||||||
status: 429,
|
|
||||||
headers: { "retry-after": String(Number.MAX_SAFE_INTEGER) },
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockRejectedValueOnce(new Error("usage limit: stop after retry delay"));
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
const setTimeoutSpy = vi
|
|
||||||
.spyOn(globalThis, "setTimeout")
|
|
||||||
.mockImplementation((callback: TimerHandler) => {
|
|
||||||
if (typeof callback === "function") {
|
|
||||||
callback();
|
|
||||||
}
|
|
||||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
||||||
});
|
|
||||||
|
|
||||||
const stream = streamOpenAICodexResponses(model, context, {
|
|
||||||
apiKey: createJwt({
|
|
||||||
"https://api.openai.com/auth": {
|
|
||||||
chatgpt_account_id: "acct-1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
transport: "sse",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await stream.result();
|
|
||||||
|
|
||||||
expect(result.stopReason).toBe("error");
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
||||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("bounds non-OK ChatGPT response bodies before formatting API errors", async () => {
|
|
||||||
const byteLimit = 16 * 1024;
|
|
||||||
const totalChunks = 32;
|
|
||||||
const prefix = "usage limit ";
|
|
||||||
const chunk = new TextEncoder().encode(
|
|
||||||
`${prefix}${"x".repeat(byteLimit - prefix.length - 2)}😀tail`,
|
|
||||||
);
|
|
||||||
let pullCount = 0;
|
|
||||||
let canceled = false;
|
|
||||||
const overflowing = new ReadableStream<Uint8Array>({
|
|
||||||
pull(controller) {
|
|
||||||
pullCount += 1;
|
|
||||||
if (pullCount > totalChunks) {
|
|
||||||
controller.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controller.enqueue(chunk);
|
|
||||||
},
|
|
||||||
cancel() {
|
|
||||||
canceled = true;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
|
|
||||||
new Response(overflowing, {
|
|
||||||
status: 400,
|
|
||||||
statusText: "Bad Request",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
vi.stubGlobal("fetch", fetchMock);
|
|
||||||
|
|
||||||
const stream = streamOpenAICodexResponses(model, context, {
|
|
||||||
apiKey: createJwt({
|
|
||||||
"https://api.openai.com/auth": {
|
|
||||||
chatgpt_account_id: "acct-1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
transport: "sse",
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await stream.result();
|
|
||||||
|
|
||||||
expect(result.stopReason).toBe("error");
|
|
||||||
expect(result.errorMessage).toContain("usage limit");
|
|
||||||
expect(result.errorMessage).not.toContain("�");
|
|
||||||
expect(result.errorMessage).not.toContain("tail");
|
|
||||||
expect(result.errorMessage?.length).toBeLessThanOrEqual(16 * 1024);
|
|
||||||
expect(canceled).toBe(true);
|
|
||||||
expect(pullCount).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(pullCount).toBeLessThanOrEqual(3);
|
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("parseSSEForTest", () => {
|
|
||||||
it("bounds streamed OpenAI ChatGPT Responses success bodies without content-length", async () => {
|
|
||||||
// 1 MiB chunks; cap is 16 MiB so the bounded reader cancels well before
|
|
||||||
// draining the full 32 MiB advertised body.
|
|
||||||
const CHUNK = 1024 * 1024;
|
|
||||||
const TOTAL = 32;
|
|
||||||
let pullCount = 0;
|
|
||||||
let cancelReason: unknown;
|
|
||||||
const overflowing = new ReadableStream<Uint8Array>({
|
|
||||||
pull(controller) {
|
|
||||||
pullCount += 1;
|
|
||||||
if (pullCount > TOTAL) {
|
|
||||||
controller.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
controller.enqueue(new Uint8Array(CHUNK));
|
|
||||||
},
|
|
||||||
cancel(reason) {
|
|
||||||
cancelReason = reason;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
let caught: Error | null = null;
|
|
||||||
try {
|
|
||||||
// parseSSE expects a Response-like; pass the streaming body directly
|
|
||||||
// through a minimal Response shim that only exposes .body.
|
|
||||||
const response = { body: overflowing } as unknown as Response;
|
|
||||||
for await (const event of parseSSEForTest(response)) {
|
|
||||||
expect(event).toBeDefined();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
caught = err as Error;
|
|
||||||
}
|
|
||||||
expect(caught?.message).toMatch(
|
|
||||||
/OpenAI ChatGPT Responses success body exceeded 16777216 bytes/,
|
|
||||||
);
|
|
||||||
expect(cancelReason).toBeInstanceOf(Error);
|
|
||||||
// 16 MiB + a couple of overshoot pulls, well under 32.
|
|
||||||
expect(pullCount).toBeGreaterThanOrEqual(17);
|
|
||||||
expect(pullCount).toBeLessThanOrEqual(20);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -79,9 +79,12 @@ import {
|
|||||||
getFirstStreamEventTimeoutMs,
|
getFirstStreamEventTimeoutMs,
|
||||||
withFirstStreamEventTimeout,
|
withFirstStreamEventTimeout,
|
||||||
} from "../utils/stream-first-event-timeout.js";
|
} from "../utils/stream-first-event-timeout.js";
|
||||||
import { createSseByteGuard } from "../utils/streaming-byte-guard.js";
|
|
||||||
import { stripSystemPromptCacheBoundary } from "../utils/system-prompt-cache-boundary.js";
|
import { stripSystemPromptCacheBoundary } from "../utils/system-prompt-cache-boundary.js";
|
||||||
import { inspectTlsCertificateError } from "../utils/tls-certificate-errors.js";
|
import { inspectTlsCertificateError } from "../utils/tls-certificate-errors.js";
|
||||||
|
import {
|
||||||
|
CodexProtocolError,
|
||||||
|
parseOpenAIChatGptResponsesSse,
|
||||||
|
} from "./openai-chatgpt-responses-protocol.js";
|
||||||
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
|
import { clampOpenAIPromptCacheKey } from "./openai-prompt-cache.js";
|
||||||
import { supportsOpenAITemperature } from "./openai-reasoning-effort.js";
|
import { supportsOpenAITemperature } from "./openai-reasoning-effort.js";
|
||||||
import {
|
import {
|
||||||
@@ -105,7 +108,6 @@ const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "opencode"]);
|
|||||||
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
|
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
|
||||||
const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
|
const WEBSOCKET_CONNECTION_LIMIT_REACHED_CODE = "websocket_connection_limit_reached";
|
||||||
const OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES = 16 * 1024;
|
const OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES = 16 * 1024;
|
||||||
const OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES = 16 * 1024 * 1024;
|
|
||||||
|
|
||||||
const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
|
const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
|
||||||
"completed",
|
"completed",
|
||||||
@@ -583,7 +585,7 @@ export const streamOpenAICodexResponses: StreamFunction<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hookedResponseStream = withProviderResponseHook({
|
const hookedResponseStream = withProviderResponseHook({
|
||||||
stream: mapCodexEvents(parseSSE(response)),
|
stream: mapCodexEvents(parseOpenAIChatGptResponsesSse(response)),
|
||||||
signal: firstEventAbort.signal,
|
signal: firstEventAbort.signal,
|
||||||
abort: firstEventAbort.abort,
|
abort: firstEventAbort.abort,
|
||||||
hook: createOpenAIResponseHook(options?.onResponse, response, model),
|
hook: createOpenAIResponseHook(options?.onResponse, response, model),
|
||||||
@@ -781,17 +783,6 @@ class CodexApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CodexProtocolError extends Error {
|
|
||||||
readonly payload?: unknown;
|
|
||||||
|
|
||||||
constructor(message: string, options?: { payload?: unknown; cause?: unknown }) {
|
|
||||||
super(message);
|
|
||||||
this.name = "CodexProtocolError";
|
|
||||||
this.payload = options?.payload;
|
|
||||||
this.cause = options?.cause;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isCodexNonTransportError(error: unknown): boolean {
|
function isCodexNonTransportError(error: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
error instanceof CodexApiError ||
|
error instanceof CodexApiError ||
|
||||||
@@ -875,96 +866,6 @@ function normalizeCodexStatus(status: unknown): CodexResponseStatus | undefined
|
|||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// SSE Parsing
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
async function* parseSSE(response: Response): AsyncGenerator<Record<string, unknown>> {
|
|
||||||
if (!response.body) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader();
|
|
||||||
// Cap the streaming 200 success-body read at 16 MiB, mirroring the
|
|
||||||
// non-streaming `readProviderJsonResponse` cap so a hostile or
|
|
||||||
// malfunctioning ChatGPT Responses endpoint cannot exhaust memory by
|
|
||||||
// streaming an unbounded SSE body.
|
|
||||||
const guard = createSseByteGuard(reader, {
|
|
||||||
maxBytes: OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES,
|
|
||||||
onOverflow: ({ size, maxBytes }) =>
|
|
||||||
new Error(
|
|
||||||
`OpenAI ChatGPT Responses success body exceeded ${maxBytes} bytes (received ${size})`,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
let buffer = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await guard.read();
|
|
||||||
if (value) {
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
|
||||||
}
|
|
||||||
if (done) {
|
|
||||||
buffer += decoder.decode();
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
// Defer a possible CRLF only when CR does not already complete a blank line.
|
|
||||||
const deferTrailingCr =
|
|
||||||
!done && buffer.endsWith("\r") && !buffer.endsWith("\r\r") && !buffer.endsWith("\n\r");
|
|
||||||
const searchable = deferTrailingCr ? buffer.slice(0, -1) : buffer;
|
|
||||||
// A CRLF is one line ending: never backtrack its CR into a false blank line.
|
|
||||||
const boundary = /(?:\r\n|\r(?!\n)|\n)(?:\r\n|\r(?!\n)|\n)/.exec(searchable);
|
|
||||||
if (!boundary) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
const chunk = buffer.slice(0, boundary.index);
|
|
||||||
buffer = buffer.slice(boundary.index + boundary[0].length);
|
|
||||||
|
|
||||||
const dataLines = chunk
|
|
||||||
.split(/\r\n|\r|\n/)
|
|
||||||
.filter((l) => l.startsWith("data:"))
|
|
||||||
.map((l) => l.slice(5).trim());
|
|
||||||
if (dataLines.length > 0) {
|
|
||||||
const data = dataLines.join("\n").trim();
|
|
||||||
if (data && data !== "[DONE]") {
|
|
||||||
let event: Record<string, unknown>;
|
|
||||||
try {
|
|
||||||
event = JSON.parse(data) as Record<string, unknown>;
|
|
||||||
} catch (cause) {
|
|
||||||
if (!(cause instanceof SyntaxError)) {
|
|
||||||
throw cause;
|
|
||||||
}
|
|
||||||
// Align with the canonical transport contract: the shared marker is what
|
|
||||||
// assistant error formatting maps to the malformed-fragment retry copy.
|
|
||||||
throw new CodexProtocolError(MALFORMED_STREAMING_FRAGMENT_ERROR_MESSAGE, { cause });
|
|
||||||
}
|
|
||||||
// Keep suspension outside the parse catch so iterator.throw() cannot relabel a
|
|
||||||
// consumer failure as malformed provider input.
|
|
||||||
yield event;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (done) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
try {
|
|
||||||
await guard.cancel();
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
reader.releaseLock();
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test-only re-export of the bounded SSE parser. Mirrors
|
|
||||||
// `parseAnthropicSseBodyForTest` / `iterateSseMessagesForTest` patterns.
|
|
||||||
export const parseSSEForTest = parseSSE;
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// WebSocket Parsing
|
// WebSocket Parsing
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user