@moxxy/plugin-provider-openai-codex 0.21.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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/dist/codex/headers.d.ts +5 -0
  3. package/dist/codex/headers.d.ts.map +1 -0
  4. package/dist/codex/headers.js +39 -0
  5. package/dist/codex/headers.js.map +1 -0
  6. package/dist/codex/sse-event-handler.d.ts +16 -0
  7. package/dist/codex/sse-event-handler.d.ts.map +1 -0
  8. package/dist/codex/sse-event-handler.js +155 -0
  9. package/dist/codex/sse-event-handler.js.map +1 -0
  10. package/dist/codex/stream-consumer.d.ts +4 -0
  11. package/dist/codex/stream-consumer.d.ts.map +1 -0
  12. package/dist/codex/stream-consumer.js +176 -0
  13. package/dist/codex/stream-consumer.js.map +1 -0
  14. package/dist/codex/stream-types.d.ts +61 -0
  15. package/dist/codex/stream-types.d.ts.map +1 -0
  16. package/dist/codex/stream-types.js +19 -0
  17. package/dist/codex/stream-types.js.map +1 -0
  18. package/dist/index.d.ts +11 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +31 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/login.d.ts +33 -0
  23. package/dist/login.d.ts.map +1 -0
  24. package/dist/login.js +94 -0
  25. package/dist/login.js.map +1 -0
  26. package/dist/models.d.ts +10 -0
  27. package/dist/models.d.ts.map +1 -0
  28. package/dist/models.js +19 -0
  29. package/dist/models.js.map +1 -0
  30. package/dist/oauth.d.ts +74 -0
  31. package/dist/oauth.d.ts.map +1 -0
  32. package/dist/oauth.js +170 -0
  33. package/dist/oauth.js.map +1 -0
  34. package/dist/profile.d.ts +13 -0
  35. package/dist/profile.d.ts.map +1 -0
  36. package/dist/profile.js +39 -0
  37. package/dist/profile.js.map +1 -0
  38. package/dist/provider.d.ts +79 -0
  39. package/dist/provider.d.ts.map +1 -0
  40. package/dist/provider.js +306 -0
  41. package/dist/provider.js.map +1 -0
  42. package/dist/translate.d.ts +77 -0
  43. package/dist/translate.d.ts.map +1 -0
  44. package/dist/translate.js +172 -0
  45. package/dist/translate.js.map +1 -0
  46. package/dist/types.d.ts +27 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +2 -0
  49. package/dist/types.js.map +1 -0
  50. package/package.json +66 -0
  51. package/src/codex/headers.ts +41 -0
  52. package/src/codex/sse-event-handler.test.ts +85 -0
  53. package/src/codex/sse-event-handler.ts +173 -0
  54. package/src/codex/stream-consumer.test.ts +280 -0
  55. package/src/codex/stream-consumer.ts +181 -0
  56. package/src/codex/stream-types.ts +61 -0
  57. package/src/index.ts +65 -0
  58. package/src/login.ts +121 -0
  59. package/src/models.ts +21 -0
  60. package/src/oauth.test.ts +223 -0
  61. package/src/oauth.ts +200 -0
  62. package/src/profile.ts +52 -0
  63. package/src/provider.test.ts +507 -0
  64. package/src/provider.ts +360 -0
  65. package/src/translate.test.ts +95 -0
  66. package/src/translate.ts +224 -0
  67. package/src/types.ts +28 -0
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@moxxy/plugin-provider-openai-codex",
3
+ "version": "0.21.1",
4
+ "description": "OpenAI Codex (ChatGPT Pro/Plus OAuth) LLMProvider plugin for moxxy. Streams against https://chatgpt.com/backend-api/codex/responses using PKCE-based OAuth tokens.",
5
+ "keywords": [
6
+ "moxxy",
7
+ "agent",
8
+ "provider",
9
+ "openai",
10
+ "codex",
11
+ "chatgpt",
12
+ "oauth",
13
+ "llm"
14
+ ],
15
+ "homepage": "https://moxxy.ai",
16
+ "bugs": {
17
+ "url": "https://github.com/moxxy-ai/moxxy/issues"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/moxxy-ai/moxxy.git",
22
+ "directory": "packages/plugin-provider-openai-codex"
23
+ },
24
+ "author": "Michal Makowski <michal.makowski97@gmail.com>",
25
+ "license": "MIT",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "type": "module",
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "src"
41
+ ],
42
+ "moxxy": {
43
+ "plugin": {
44
+ "entry": "./dist/index.js",
45
+ "kind": "provider"
46
+ }
47
+ },
48
+ "dependencies": {
49
+ "@moxxy/sdk": "0.21.1",
50
+ "@moxxy/plugin-oauth": "0.0.33"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "^22.10.0",
54
+ "typescript": "^5.7.3",
55
+ "vitest": "^2.1.8",
56
+ "zod": "^3.24.0",
57
+ "@moxxy/vitest-preset": "0.0.0",
58
+ "@moxxy/tsconfig": "0.0.0"
59
+ },
60
+ "scripts": {
61
+ "build": "tsc -p tsconfig.json",
62
+ "typecheck": "tsc -p tsconfig.json --noEmit",
63
+ "test": "vitest run",
64
+ "clean": "rm -rf dist .turbo"
65
+ }
66
+ }
@@ -0,0 +1,41 @@
1
+ import { createRequire } from 'node:module';
2
+ import { ORIGINATOR } from '../oauth.js';
3
+ import type { CodexTokens } from '../types.js';
4
+
5
+ /**
6
+ * Resolve this plugin's real version from its package.json once at module load
7
+ * rather than freezing a stale literal in two places (the UA and the plugin
8
+ * def). The User-Agent is sent on every request to the ChatGPT backend; a
9
+ * permanently-`0.0.0` UA defeats server-side version gating and diverges from
10
+ * the real release. Resolves correctly when the package runs from its own dist
11
+ * (dev, tests, third-party `~/.moxxy/plugins` installs); falls back to `0.0.0`
12
+ * defensively — never throws on a header build. (NOTE: when inlined into the
13
+ * single-file CLI bundle the relative resolve fails and we keep the `0.0.0`
14
+ * fallback; a build-time constant would close that gap — see TECH_DEBT.)
15
+ */
16
+ function resolvePluginVersion(): string {
17
+ try {
18
+ const require = createRequire(import.meta.url);
19
+ const pkg = require('../../package.json') as { version?: unknown };
20
+ return typeof pkg.version === 'string' && pkg.version ? pkg.version : '0.0.0';
21
+ } catch {
22
+ return '0.0.0';
23
+ }
24
+ }
25
+
26
+ export const PLUGIN_VERSION = resolvePluginVersion();
27
+
28
+ export const CODEX_USER_AGENT = `moxxy/${PLUGIN_VERSION} (codex)`;
29
+
30
+ export function buildCodexHeaders(tokens: CodexTokens, sessionId: string): Record<string, string> {
31
+ const headers: Record<string, string> = {
32
+ 'Content-Type': 'application/json',
33
+ Accept: 'text/event-stream',
34
+ Authorization: `Bearer ${tokens.access}`,
35
+ originator: ORIGINATOR,
36
+ 'User-Agent': CODEX_USER_AGENT,
37
+ session_id: sessionId,
38
+ };
39
+ if (tokens.accountId) headers['ChatGPT-Account-Id'] = tokens.accountId;
40
+ return headers;
41
+ }
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { handleSseEvent } from './sse-event-handler.js';
3
+ import type { PendingFunctionCall, ResponsesSseEvent } from './stream-types.js';
4
+
5
+ const run = (ev: ResponsesSseEvent, emitReasoning: boolean) =>
6
+ handleSseEvent(ev, new Map<string, PendingFunctionCall>(), emitReasoning);
7
+
8
+ describe('handleSseEvent — reasoning summary', () => {
9
+ it('maps reasoning_summary_text.delta to a reasoning_delta when enabled', () => {
10
+ const out = run({ type: 'response.reasoning_summary_text.delta', delta: 'planning…' }, true);
11
+ expect(out.events).toEqual([{ type: 'reasoning_delta', delta: 'planning…' }]);
12
+ });
13
+
14
+ it('drops the reasoning summary entirely when the toggle is off', () => {
15
+ const out = run({ type: 'response.reasoning_summary_text.delta', delta: 'planning…' }, false);
16
+ expect(out.events ?? []).toEqual([]);
17
+ });
18
+
19
+ it('captures a reasoning item encrypted_content as a reasoning_signature', () => {
20
+ const out = run(
21
+ { type: 'response.output_item.added', item: { type: 'reasoning', encrypted_content: 'blob' } },
22
+ true,
23
+ );
24
+ expect(out.events).toEqual([{ type: 'reasoning_signature', encrypted: 'blob' }]);
25
+ });
26
+
27
+ it('still maps text + function-call events regardless of the reasoning toggle', () => {
28
+ expect(run({ type: 'response.output_text.delta', delta: 'hi' }, false).events).toEqual([
29
+ { type: 'text_delta', delta: 'hi' },
30
+ ]);
31
+ });
32
+ });
33
+
34
+ describe('handleSseEvent — hostile-stream bounds', () => {
35
+ it('caps a single tool call argument accumulation across many delta frames', () => {
36
+ const pending = new Map<string, PendingFunctionCall>();
37
+ // Seed a function call.
38
+ handleSseEvent(
39
+ {
40
+ type: 'response.output_item.added',
41
+ item: { type: 'function_call', id: 'fc1', call_id: 'call_1', name: 'echo' },
42
+ },
43
+ pending,
44
+ false,
45
+ );
46
+ // A hostile stream sends 2 MiB per delta frame; each frame is individually
47
+ // small enough to pass the consumer's frame buffer but accumulates across
48
+ // frames. The handler must surface a terminal error before unbounded growth.
49
+ const bigDelta = 'a'.repeat(2 * 1024 * 1024);
50
+ let result;
51
+ for (let i = 0; i < 20; i++) {
52
+ result = handleSseEvent(
53
+ { type: 'response.function_call_arguments.delta', item_id: 'fc1', delta: bigDelta },
54
+ pending,
55
+ false,
56
+ );
57
+ if (result.terminal) break;
58
+ }
59
+ expect(result?.terminal).toBe(true);
60
+ expect(result?.events?.[0]).toMatchObject({ type: 'error', retryable: false });
61
+ // It bailed well before 20 * 2 MiB = 40 MiB accumulated (cap is 16 MiB).
62
+ expect(pending.get('fc1')!.args.length).toBeLessThanOrEqual(16 * 1024 * 1024);
63
+ });
64
+
65
+ it('caps the number of concurrently-pending tool calls', () => {
66
+ const pending = new Map<string, PendingFunctionCall>();
67
+ let result;
68
+ // A hostile stream seeds unbounded distinct function-call ids, never .done-ing
69
+ // any. The Map must not grow without limit.
70
+ for (let i = 0; i < 2000; i++) {
71
+ result = handleSseEvent(
72
+ {
73
+ type: 'response.output_item.added',
74
+ item: { type: 'function_call', id: `fc_${i}`, call_id: `call_${i}`, name: 'echo' },
75
+ },
76
+ pending,
77
+ false,
78
+ );
79
+ if (result.terminal) break;
80
+ }
81
+ expect(result?.terminal).toBe(true);
82
+ expect(result?.events?.[0]).toMatchObject({ type: 'error', retryable: false });
83
+ expect(pending.size).toBeLessThanOrEqual(1024);
84
+ });
85
+ });
@@ -0,0 +1,173 @@
1
+ import type { ProviderEvent, StopReason } from '@moxxy/sdk';
2
+ import {
3
+ parseToolArgs,
4
+ type PendingFunctionCall,
5
+ type ResponsesSseEvent,
6
+ type SseStepResult,
7
+ } from './stream-types.js';
8
+
9
+ /**
10
+ * Hard cap on a single tool call's accumulated argument text. `entry.args` grows
11
+ * by every `function_call_arguments.delta`; unlike the consumer's per-frame
12
+ * reassembly buffer (which shrinks once a frame is parsed), this accumulates
13
+ * ACROSS frames and is otherwise unbounded — a hostile stream of millions of
14
+ * small, individually-valid delta frames would grow it to OOM without ever
15
+ * tripping the frame-buffer cap. 16 MiB is far beyond any legitimate tool-call
16
+ * payload. Exceeding it is treated as a hostile stream (terminal error).
17
+ */
18
+ const MAX_TOOL_ARGS_CHARS = 16 * 1024 * 1024;
19
+
20
+ /**
21
+ * Hard cap on the number of concurrently-pending function calls. Each distinct
22
+ * `output_item.added` function_call seeds a `pending` entry that lives until its
23
+ * `.done` (or the post-stream flush); a hostile stream could emit unbounded
24
+ * distinct ids and never finish them, growing the Map without limit. No real
25
+ * turn fans out to anywhere near this many parallel tool calls.
26
+ */
27
+ const MAX_PENDING_TOOL_CALLS = 1024;
28
+
29
+ const TOOL_STREAM_LIMIT_ERROR = (what: string): SseStepResult => ({
30
+ events: [{ type: 'error', message: `Codex tool-call stream exceeded ${what} limit`, retryable: false }],
31
+ terminal: true,
32
+ });
33
+
34
+ /**
35
+ * Map a single Responses-API SSE event to zero or more moxxy ProviderEvents.
36
+ * Centralized here so the streaming loop stays a thin "read frame → call
37
+ * this → yield" structure and the event taxonomy is easy to test directly.
38
+ *
39
+ * Events we care about (subset of the full Responses API surface):
40
+ * response.output_text.delta → text_delta
41
+ * response.output_item.added → tool_use_start (if it's a function_call)
42
+ * response.function_call_arguments.delta → tool_use_delta
43
+ * response.function_call_arguments.done → finalize tool_use_end
44
+ * response.completed → message_end (sets stopReason)
45
+ * response.failed / response.error → error
46
+ */
47
+ export function handleSseEvent(
48
+ ev: ResponsesSseEvent,
49
+ pending: Map<string, PendingFunctionCall>,
50
+ emitReasoning = false,
51
+ ): SseStepResult {
52
+ const type = ev.type ?? '';
53
+
54
+ if (type === 'response.output_text.delta' && typeof ev.delta === 'string' && ev.delta) {
55
+ return { events: [{ type: 'text_delta', delta: ev.delta }] };
56
+ }
57
+
58
+ // Reasoning summary text (Codex requests `summary: 'auto'`) → reasoning_delta.
59
+ // The streamed summary is the visible "thinking" between tool calls. Gated on
60
+ // the per-provider reasoning toggle; off → discard as before.
61
+ if (emitReasoning && type === 'response.reasoning_summary_text.delta' && typeof ev.delta === 'string' && ev.delta) {
62
+ return { events: [{ type: 'reasoning_delta', delta: ev.delta }] };
63
+ }
64
+
65
+ // A `reasoning` output item carries the encrypted_content we must replay
66
+ // verbatim on the next request (Codex requests `include: ['reasoning.encrypted_content']`).
67
+ if (emitReasoning && type === 'response.output_item.added' && ev.item?.type === 'reasoning') {
68
+ return ev.item.encrypted_content
69
+ ? { events: [{ type: 'reasoning_signature', encrypted: ev.item.encrypted_content }] }
70
+ : {};
71
+ }
72
+
73
+ if (type === 'response.output_item.added' && ev.item?.type === 'function_call') {
74
+ const id = ev.item.id ?? ev.item.call_id ?? `call_${pending.size}`;
75
+ // Bound the number of in-flight tool calls: a hostile stream could otherwise
76
+ // seed unbounded distinct ids and never `.done` them, growing the Map to OOM.
77
+ // (A new id that would overflow is rejected; re-using an existing id is fine.)
78
+ if (!pending.has(id) && pending.size >= MAX_PENDING_TOOL_CALLS) {
79
+ return TOOL_STREAM_LIMIT_ERROR('pending-call count');
80
+ }
81
+ const callId = ev.item.call_id ?? id;
82
+ const name = ev.item.name ?? '';
83
+ const entry: PendingFunctionCall = {
84
+ id,
85
+ callId,
86
+ name,
87
+ args: ev.item.arguments ?? '',
88
+ emittedStart: false,
89
+ };
90
+ pending.set(id, entry);
91
+ if (name) {
92
+ entry.emittedStart = true;
93
+ return { events: [{ type: 'tool_use_start', id: callId, name }] };
94
+ }
95
+ return {};
96
+ }
97
+
98
+ if (type === 'response.function_call_arguments.delta') {
99
+ const id = ev.item_id ?? ev.call_id ?? '';
100
+ const entry = pending.get(id);
101
+ const delta = ev.delta ?? '';
102
+ if (entry && typeof delta === 'string') {
103
+ // Bound the per-call argument accumulation: this grows across frames and
104
+ // is not covered by the consumer's per-frame reassembly cap.
105
+ if (entry.args.length + delta.length > MAX_TOOL_ARGS_CHARS) {
106
+ return TOOL_STREAM_LIMIT_ERROR('argument size');
107
+ }
108
+ entry.args += delta;
109
+ const outId = entry.callId || entry.id;
110
+ // If we hadn't emitted tool_use_start yet (server sent the args
111
+ // before the item.added with a name), do so now using whatever
112
+ // name landed later. Defensive — opencode's pattern.
113
+ const startEvents: ProviderEvent[] = [];
114
+ if (!entry.emittedStart && entry.name) {
115
+ entry.emittedStart = true;
116
+ startEvents.push({ type: 'tool_use_start', id: outId, name: entry.name });
117
+ }
118
+ return {
119
+ events: [...startEvents, { type: 'tool_use_delta', id: outId, partialInput: delta }],
120
+ };
121
+ }
122
+ return {};
123
+ }
124
+
125
+ if (type === 'response.function_call_arguments.done') {
126
+ const id = ev.item_id ?? ev.call_id ?? '';
127
+ const entry = pending.get(id);
128
+ if (!entry) return {};
129
+ pending.delete(id);
130
+ if (typeof ev.arguments === 'string' && ev.arguments) entry.args = ev.arguments;
131
+ const input = parseToolArgs(entry.args);
132
+ const outId = entry.callId || entry.id;
133
+ const events: ProviderEvent[] = [];
134
+ if (!entry.emittedStart && entry.name) {
135
+ events.push({ type: 'tool_use_start', id: outId, name: entry.name });
136
+ }
137
+ events.push({ type: 'tool_use_end', id: outId, input });
138
+ return { events };
139
+ }
140
+
141
+ if (type === 'response.completed') {
142
+ const usage = ev.response?.usage;
143
+ const input = usage?.input_tokens ?? 0;
144
+ const cacheRead = usage?.input_tokens_details?.cached_tokens ?? 0;
145
+ const incomplete = ev.response?.incomplete_details?.reason;
146
+ let stopReason: StopReason = 'end_turn';
147
+ if (incomplete === 'max_output_tokens') stopReason = 'max_tokens';
148
+ else if (incomplete === 'stop_sequence') stopReason = 'stop_sequence';
149
+ // The presence of unflushed function calls would already get mapped to
150
+ // tool_use by the post-loop logic; the explicit "completed" event
151
+ // doesn't carry a tool_use stop reason on its own.
152
+ return {
153
+ stopReason,
154
+ ...(usage
155
+ ? {
156
+ usage: {
157
+ input: Math.max(0, input - cacheRead),
158
+ output: usage.output_tokens ?? 0,
159
+ ...(usage.input_tokens_details?.cached_tokens !== undefined ? { cacheRead } : {}),
160
+ },
161
+ }
162
+ : {}),
163
+ terminal: true,
164
+ };
165
+ }
166
+
167
+ if (type === 'response.failed' || type === 'response.error' || type === 'error') {
168
+ const msg = ev.error?.message ?? `Codex stream failed: ${type}`;
169
+ return { events: [{ type: 'error', message: msg, retryable: false }], terminal: true };
170
+ }
171
+
172
+ return {};
173
+ }
@@ -0,0 +1,280 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { ProviderEvent } from '@moxxy/sdk';
3
+ import { consumeResponsesSse } from './stream-consumer.js';
4
+
5
+ /**
6
+ * Build a ReadableStream<Uint8Array> from a list of string chunks. Each chunk
7
+ * is delivered as its own `read()` so we can exercise frame reassembly across
8
+ * chunk boundaries.
9
+ */
10
+ function streamOf(chunks: ReadonlyArray<string>): ReadableStream<Uint8Array> {
11
+ const enc = new TextEncoder();
12
+ let i = 0;
13
+ return new ReadableStream<Uint8Array>({
14
+ pull(controller) {
15
+ if (i < chunks.length) {
16
+ controller.enqueue(enc.encode(chunks[i]!));
17
+ i += 1;
18
+ } else {
19
+ controller.close();
20
+ }
21
+ },
22
+ });
23
+ }
24
+
25
+ /** An SSE `data:` frame for a single event object, separator included. */
26
+ const frame = (obj: unknown, sep = '\n\n'): string => `data: ${JSON.stringify(obj)}${sep}`;
27
+
28
+ async function drain(
29
+ body: ReadableStream<Uint8Array>,
30
+ signal?: AbortSignal,
31
+ ): Promise<ProviderEvent[]> {
32
+ const out: ProviderEvent[] = [];
33
+ for await (const ev of consumeResponsesSse(body, signal)) out.push(ev);
34
+ return out;
35
+ }
36
+
37
+ describe('consumeResponsesSse', () => {
38
+ it('assembles a tool call (added → arguments.delta → arguments.done) and upgrades stopReason to tool_use', async () => {
39
+ const events = await drain(
40
+ streamOf([
41
+ frame({
42
+ type: 'response.output_item.added',
43
+ item: { type: 'function_call', id: 'fc1', call_id: 'call_1', name: 'echo' },
44
+ }),
45
+ frame({ type: 'response.function_call_arguments.delta', item_id: 'fc1', delta: '{"msg":' }),
46
+ frame({ type: 'response.function_call_arguments.delta', item_id: 'fc1', delta: '"hi"}' }),
47
+ frame({
48
+ type: 'response.function_call_arguments.done',
49
+ item_id: 'fc1',
50
+ arguments: '{"msg":"hi"}',
51
+ }),
52
+ frame({ type: 'response.completed', response: {} }),
53
+ ]),
54
+ );
55
+
56
+ const start = events.find((e) => e.type === 'tool_use_start');
57
+ expect(start).toMatchObject({ type: 'tool_use_start', id: 'call_1', name: 'echo' });
58
+ const end = events.find((e) => e.type === 'tool_use_end');
59
+ expect(end).toMatchObject({ type: 'tool_use_end', id: 'call_1', input: { msg: 'hi' } });
60
+ const last = events.at(-1);
61
+ expect(last).toMatchObject({ type: 'message_end', stopReason: 'tool_use' });
62
+ });
63
+
64
+ it('flushes a tool call that never received a .done frame (truncated stream)', async () => {
65
+ const events = await drain(
66
+ streamOf([
67
+ frame({
68
+ type: 'response.output_item.added',
69
+ item: { type: 'function_call', id: 'fc1', call_id: 'call_1', name: 'echo' },
70
+ }),
71
+ frame({ type: 'response.function_call_arguments.delta', item_id: 'fc1', delta: '{"x":1}' }),
72
+ // stream ends with no .done and no response.completed
73
+ ]),
74
+ );
75
+ const end = events.find((e) => e.type === 'tool_use_end');
76
+ expect(end).toMatchObject({ type: 'tool_use_end', id: 'call_1', input: { x: 1 } });
77
+ const last = events.at(-1);
78
+ expect(last).toMatchObject({ type: 'message_end', stopReason: 'tool_use' });
79
+ });
80
+
81
+ it('does not emit a malformed tool_use for a truncated function_call that never carried a name', async () => {
82
+ // item.added with no `name`, some args, then the stream truncates before
83
+ // both the name and the .done arrive. The flush must NOT synthesize a
84
+ // nameless/invalid tool_use_start/end — it has no usable name.
85
+ const events = await drain(
86
+ streamOf([
87
+ frame({
88
+ type: 'response.output_item.added',
89
+ item: { type: 'function_call', id: 'fc1', call_id: 'call_1' }, // no name
90
+ }),
91
+ frame({ type: 'response.function_call_arguments.delta', item_id: 'fc1', delta: '{"x":1}' }),
92
+ // stream ends: no name ever arrived, no .done, no completed
93
+ ]),
94
+ );
95
+ expect(events.some((e) => e.type === 'tool_use_start')).toBe(false);
96
+ expect(events.some((e) => e.type === 'tool_use_end')).toBe(false);
97
+ // With no tool call emitted, the turn is not upgraded to tool_use.
98
+ const last = events.at(-1);
99
+ expect(last).toMatchObject({ type: 'message_end', stopReason: 'end_turn' });
100
+ });
101
+
102
+ it('flushes a tool_use_start+end when a name was buffered but its start frame never landed', async () => {
103
+ // Defensive flush path: emittedStart is false yet a name is present. We
104
+ // drive this by handing the consumer a pre-seeded pending entry via the
105
+ // documented event sequence the handler produces, then truncating. The
106
+ // flush must emit BOTH start and end so the call isn't dropped.
107
+ //
108
+ // The only handler path that leaves name set without emittedStart would be
109
+ // a future event type; until then this guards the flush's name-recovery
110
+ // branch against regressions by asserting a started call still flushes its
111
+ // end even with no .done (covered above) and that a named added+truncation
112
+ // yields a complete pair.
113
+ const events = await drain(
114
+ streamOf([
115
+ frame({
116
+ type: 'response.output_item.added',
117
+ item: { type: 'function_call', id: 'fc2', call_id: 'call_2', name: 'lookup' },
118
+ }),
119
+ // No args.delta, no .done — immediate truncation after the named start.
120
+ ]),
121
+ );
122
+ const start = events.find((e) => e.type === 'tool_use_start');
123
+ const end = events.find((e) => e.type === 'tool_use_end');
124
+ expect(start).toMatchObject({ id: 'call_2', name: 'lookup' });
125
+ expect(end).toMatchObject({ id: 'call_2' });
126
+ });
127
+
128
+ it('surfaces _rawPartial when the flushed args are not valid JSON', async () => {
129
+ const events = await drain(
130
+ streamOf([
131
+ frame({
132
+ type: 'response.output_item.added',
133
+ item: { type: 'function_call', id: 'fc1', call_id: 'call_1', name: 'echo' },
134
+ }),
135
+ frame({ type: 'response.function_call_arguments.delta', item_id: 'fc1', delta: '{"x":' }),
136
+ ]),
137
+ );
138
+ const end = events.find((e) => e.type === 'tool_use_end');
139
+ expect(end).toMatchObject({ type: 'tool_use_end', input: { _rawPartial: '{"x":' } });
140
+ });
141
+
142
+ it('reassembles a frame split across two chunk reads', async () => {
143
+ const f = frame({ type: 'response.output_text.delta', delta: 'hello' });
144
+ const cut = Math.floor(f.length / 2);
145
+ const events = await drain(
146
+ streamOf([f.slice(0, cut), f.slice(cut), frame({ type: 'response.completed', response: {} })]),
147
+ );
148
+ const text = events.find((e) => e.type === 'text_delta');
149
+ expect(text).toMatchObject({ type: 'text_delta', delta: 'hello' });
150
+ });
151
+
152
+ it('handles CRLF frame separators including a \\r split across reads', async () => {
153
+ // The \r and \n of a CRLF separator land in different read() calls.
154
+ const events = await drain(
155
+ streamOf([
156
+ `data: ${JSON.stringify({ type: 'response.output_text.delta', delta: 'crlf' })}\r`,
157
+ `\n\r\n`,
158
+ frame({ type: 'response.completed', response: {} }),
159
+ ]),
160
+ );
161
+ const text = events.find((e) => e.type === 'text_delta');
162
+ expect(text).toMatchObject({ type: 'text_delta', delta: 'crlf' });
163
+ });
164
+
165
+ it('emits a single error and no trailing message_end on response.failed', async () => {
166
+ const events = await drain(
167
+ streamOf([
168
+ frame({ type: 'response.failed', error: { message: 'boom' } }),
169
+ // a stray frame after the terminal one must be ignored
170
+ frame({ type: 'response.output_text.delta', delta: 'late' }),
171
+ ]),
172
+ );
173
+ expect(events).toHaveLength(1);
174
+ expect(events[0]).toMatchObject({ type: 'error', message: 'boom' });
175
+ expect(events.some((e) => e.type === 'message_end')).toBe(false);
176
+ });
177
+
178
+ it('emits the abort error and stops when the signal is already aborted', async () => {
179
+ const controller = new AbortController();
180
+ controller.abort();
181
+ const events = await drain(
182
+ streamOf([frame({ type: 'response.output_text.delta', delta: 'never' })]),
183
+ controller.signal,
184
+ );
185
+ expect(events).toEqual([{ type: 'error', message: 'aborted', retryable: false }]);
186
+ });
187
+
188
+ it('bounds the reassembly buffer: an endless body with no frame separator errors instead of OOMing', async () => {
189
+ // A misbehaving/MITM'd endpoint streams a continuous body that never emits a
190
+ // blank-line frame separator. Without the cap, `buffer` would grow until OOM.
191
+ const chunk = 'x'.repeat(1024 * 1024); // 1 MiB, no '\n\n' anywhere
192
+ let reads = 0;
193
+ const body = new ReadableStream<Uint8Array>({
194
+ pull(controller) {
195
+ // Far more than the 8 MiB cap if left unbounded; the consumer must bail
196
+ // long before this many reads complete.
197
+ if (reads >= 100) {
198
+ controller.close();
199
+ return;
200
+ }
201
+ reads += 1;
202
+ controller.enqueue(new TextEncoder().encode(chunk));
203
+ },
204
+ });
205
+ const events = await drain(body);
206
+ expect(events).toEqual([
207
+ { type: 'error', message: 'Codex stream frame exceeded size limit', retryable: false },
208
+ ]);
209
+ // It stopped reading well before draining all 100 MiB.
210
+ expect(reads).toBeLessThan(20);
211
+ });
212
+
213
+ it('does not flush a phantom tool call after a mid-stream response.failed (error supersedes pending)', async () => {
214
+ // A function_call starts (tool_use_start emitted) but the stream fails before
215
+ // its .done arrives. The failed turn must surface ONE error and must NOT flush
216
+ // a trailing tool_use_end on top of it.
217
+ const events = await drain(
218
+ streamOf([
219
+ frame({
220
+ type: 'response.output_item.added',
221
+ item: { type: 'function_call', id: 'fc1', call_id: 'call_1', name: 'echo' },
222
+ }),
223
+ frame({ type: 'response.function_call_arguments.delta', item_id: 'fc1', delta: '{"x":1' }),
224
+ frame({ type: 'response.failed', error: { message: 'boom' } }),
225
+ ]),
226
+ );
227
+ const errs = events.filter((e) => e.type === 'error');
228
+ expect(errs).toHaveLength(1);
229
+ expect(events.some((e) => e.type === 'tool_use_end')).toBe(false);
230
+ expect(events.some((e) => e.type === 'message_end')).toBe(false);
231
+ });
232
+
233
+ it('bounds cross-frame tool-arg accumulation: many valid delta frames error instead of OOMing', async () => {
234
+ // Each frame here IS well-formed and individually under the frame-buffer cap,
235
+ // so the consumer's per-frame reassembly cap never trips. The unbounded vector
236
+ // is `entry.args += delta` accumulating across frames — the handler must cap it
237
+ // and surface a single terminal error.
238
+ const bigDelta = 'a'.repeat(1024 * 1024); // 1 MiB per frame
239
+ const frames = [
240
+ frame({
241
+ type: 'response.output_item.added',
242
+ item: { type: 'function_call', id: 'fc1', call_id: 'call_1', name: 'echo' },
243
+ }),
244
+ // 30 MiB of cumulative args — far over the 16 MiB cap — across 30 frames.
245
+ ...Array.from({ length: 30 }, () =>
246
+ frame({ type: 'response.function_call_arguments.delta', item_id: 'fc1', delta: bigDelta }),
247
+ ),
248
+ ];
249
+ const events = await drain(streamOf(frames));
250
+ const errs = events.filter((e) => e.type === 'error');
251
+ expect(errs).toHaveLength(1);
252
+ expect(errs[0]).toMatchObject({ retryable: false });
253
+ // The failed turn must not also emit a trailing message_end.
254
+ expect(events.some((e) => e.type === 'message_end')).toBe(false);
255
+ });
256
+
257
+ it('reports cached usage from response.completed as cache reads plus fresh input', async () => {
258
+ const events = await drain(
259
+ streamOf([
260
+ frame({ type: 'response.output_text.delta', delta: 'hi' }),
261
+ frame({
262
+ type: 'response.completed',
263
+ response: {
264
+ usage: {
265
+ input_tokens: 10,
266
+ output_tokens: 3,
267
+ input_tokens_details: { cached_tokens: 4 },
268
+ },
269
+ },
270
+ }),
271
+ ]),
272
+ );
273
+ const last = events.at(-1);
274
+ expect(last).toMatchObject({
275
+ type: 'message_end',
276
+ stopReason: 'end_turn',
277
+ usage: { inputTokens: 6, outputTokens: 3, cacheReadTokens: 4 },
278
+ });
279
+ });
280
+ });