@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
@@ -0,0 +1,507 @@
1
+ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
2
+ import { promises as fs } from 'node:fs';
3
+ import * as os from 'node:os';
4
+ import * as path from 'node:path';
5
+ import { CodexProvider } from './provider.js';
6
+ import { CODEX_RESPONSES_URL } from './oauth.js';
7
+ import type { CodexTokens } from './types.js';
8
+ import type { ProviderEvent, ProviderRequest } from '@moxxy/sdk';
9
+
10
+ // The refresh path takes a cross-process lockfile under `<moxxy home>/locks`;
11
+ // point MOXXY_HOME at a temp dir so tests never touch the real ~/.moxxy.
12
+ let moxxyHomeTmp: string;
13
+ const priorMoxxyHome = process.env.MOXXY_HOME;
14
+ beforeAll(async () => {
15
+ moxxyHomeTmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-codex-provider-'));
16
+ process.env.MOXXY_HOME = moxxyHomeTmp;
17
+ });
18
+ afterAll(async () => {
19
+ if (priorMoxxyHome === undefined) delete process.env.MOXXY_HOME;
20
+ else process.env.MOXXY_HOME = priorMoxxyHome;
21
+ await fs.rm(moxxyHomeTmp, { recursive: true, force: true });
22
+ });
23
+
24
+ function makeTokens(overrides: Partial<CodexTokens> = {}): CodexTokens {
25
+ return {
26
+ access: 'AT',
27
+ refresh: 'RT',
28
+ expires: Date.now() + 60 * 60 * 1000,
29
+ accountId: 'acct_test',
30
+ ...overrides,
31
+ };
32
+ }
33
+
34
+ function sseStream(frames: string[]): ReadableStream<Uint8Array> {
35
+ const enc = new TextEncoder();
36
+ return new ReadableStream<Uint8Array>({
37
+ start(controller) {
38
+ for (const f of frames) controller.enqueue(enc.encode(f));
39
+ controller.close();
40
+ },
41
+ });
42
+ }
43
+
44
+ async function collect<T>(it: AsyncIterable<T>): Promise<T[]> {
45
+ const out: T[] = [];
46
+ for await (const ev of it) out.push(ev);
47
+ return out;
48
+ }
49
+
50
+ function baseRequest(over: Partial<ProviderRequest> = {}): ProviderRequest {
51
+ return {
52
+ model: 'gpt-5.3-codex',
53
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
54
+ ...over,
55
+ };
56
+ }
57
+
58
+ describe('CodexProvider.stream', () => {
59
+ it('sends Bearer auth, ChatGPT-Account-Id, originator and User-Agent headers', async () => {
60
+ const captured: { url?: string; init?: RequestInit } = {};
61
+ const fakeFetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
62
+ captured.url = String(url);
63
+ captured.init = init;
64
+ return new Response(
65
+ sseStream([
66
+ 'data: {"type":"response.output_text.delta","delta":"hello"}\n\n',
67
+ 'data: {"type":"response.completed","response":{"usage":{"input_tokens":3,"output_tokens":5}}}\n\n',
68
+ ]),
69
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
70
+ );
71
+ });
72
+
73
+ const provider = new CodexProvider({
74
+ tokens: makeTokens(),
75
+ fetch: fakeFetch as unknown as typeof fetch,
76
+ sessionIdProvider: () => 'sess_fixed',
77
+ });
78
+
79
+ const events = await collect(provider.stream(baseRequest()));
80
+
81
+ expect(captured.url).toBe(CODEX_RESPONSES_URL);
82
+ expect(captured.init?.method).toBe('POST');
83
+ const h = captured.init?.headers as Record<string, string>;
84
+ expect(h['Authorization']).toBe('Bearer AT');
85
+ expect(h['ChatGPT-Account-Id']).toBe('acct_test');
86
+ expect(h['originator']).toBe('moxxy');
87
+ expect(h['session_id']).toBe('sess_fixed');
88
+ expect(h['User-Agent']).toMatch(/^moxxy\//);
89
+ expect(h['Accept']).toBe('text/event-stream');
90
+
91
+ // Event sequence: message_start, text_delta('hello'), message_end (with usage)
92
+ expect(events[0]).toMatchObject({ type: 'message_start', model: 'gpt-5.3-codex' });
93
+ expect(events.some((e) => e.type === 'text_delta' && e.delta === 'hello')).toBe(true);
94
+ const end = events.find((e): e is Extract<ProviderEvent, { type: 'message_end' }> => e.type === 'message_end');
95
+ expect(end?.usage).toEqual({ inputTokens: 3, outputTokens: 5 });
96
+ expect(end?.stopReason).toBe('end_turn');
97
+ });
98
+
99
+ it('sets prompt_cache_key to the session id and surfaces cached input tokens', async () => {
100
+ let body: Record<string, unknown> | undefined;
101
+ const fakeFetch = vi.fn(async (_u: RequestInfo | URL, init?: RequestInit) => {
102
+ body = JSON.parse(String(init?.body));
103
+ return new Response(
104
+ sseStream([
105
+ 'data: {"type":"response.completed","response":{"usage":{"input_tokens":100,"output_tokens":20,"input_tokens_details":{"cached_tokens":80}}}}\n\n',
106
+ ]),
107
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
108
+ );
109
+ });
110
+ const provider = new CodexProvider({
111
+ tokens: makeTokens(),
112
+ fetch: fakeFetch as unknown as typeof fetch,
113
+ sessionIdProvider: () => 'sess_fixed',
114
+ });
115
+ const events = await collect(provider.stream(baseRequest()));
116
+
117
+ expect(body?.prompt_cache_key).toBe('sess_fixed');
118
+ const end = events.find((e): e is Extract<ProviderEvent, { type: 'message_end' }> => e.type === 'message_end');
119
+ expect(end?.usage).toEqual({ inputTokens: 20, outputTokens: 20, cacheReadTokens: 80 });
120
+ });
121
+
122
+ it('never forwards req.maxTokens or req.temperature', async () => {
123
+ let body: Record<string, unknown> | undefined;
124
+ const fakeFetch = vi.fn(async (_u: RequestInfo | URL, init?: RequestInit) => {
125
+ body = JSON.parse(String(init?.body));
126
+ return new Response(sseStream(['data: {"type":"response.completed"}\n\n']), { status: 200 });
127
+ });
128
+ const provider = new CodexProvider({
129
+ tokens: makeTokens(),
130
+ fetch: fakeFetch as unknown as typeof fetch,
131
+ });
132
+ await collect(provider.stream(baseRequest({ maxTokens: 1234, temperature: 0.2 })));
133
+
134
+ // The ChatGPT Codex backend 400s on max_output_tokens ("Unsupported
135
+ // parameter") and gpt-5 reasoning models reject sampling params, so the
136
+ // provider must drop both instead of forwarding them.
137
+ expect(body).not.toHaveProperty('max_output_tokens');
138
+ expect(body).not.toHaveProperty('temperature');
139
+ });
140
+
141
+ it('omits max_output_tokens when req.maxTokens is unset', async () => {
142
+ let body: Record<string, unknown> | undefined;
143
+ const fakeFetch = vi.fn(async (_u: RequestInfo | URL, init?: RequestInit) => {
144
+ body = JSON.parse(String(init?.body));
145
+ return new Response(sseStream(['data: {"type":"response.completed"}\n\n']), { status: 200 });
146
+ });
147
+ const provider = new CodexProvider({
148
+ tokens: makeTokens(),
149
+ fetch: fakeFetch as unknown as typeof fetch,
150
+ });
151
+ await collect(provider.stream(baseRequest()));
152
+ expect(body).not.toHaveProperty('max_output_tokens');
153
+ });
154
+
155
+ it('defaults reasoning effort to medium and honors the reasoningEffort config option', async () => {
156
+ const bodies: Array<Record<string, unknown>> = [];
157
+ const fakeFetch = vi.fn(async (_u: RequestInfo | URL, init?: RequestInit) => {
158
+ bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
159
+ return new Response(sseStream(['data: {"type":"response.completed"}\n\n']), { status: 200 });
160
+ });
161
+
162
+ const defaulted = new CodexProvider({
163
+ tokens: makeTokens(),
164
+ fetch: fakeFetch as unknown as typeof fetch,
165
+ });
166
+ await collect(defaulted.stream(baseRequest()));
167
+
168
+ const high = new CodexProvider({
169
+ tokens: makeTokens(),
170
+ fetch: fakeFetch as unknown as typeof fetch,
171
+ reasoningEffort: 'high',
172
+ });
173
+ await collect(high.stream(baseRequest()));
174
+
175
+ expect(bodies[0]?.reasoning).toMatchObject({ effort: 'medium' });
176
+ expect(bodies[1]?.reasoning).toMatchObject({ effort: 'high' });
177
+ });
178
+
179
+ it('uses a stable default session id across turns so the prefix cache can hit', async () => {
180
+ const keys: unknown[] = [];
181
+ const fakeFetch = vi.fn(async (_u: RequestInfo | URL, init?: RequestInit) => {
182
+ keys.push((JSON.parse(String(init?.body)) as { prompt_cache_key?: unknown }).prompt_cache_key);
183
+ return new Response(sseStream(['data: {"type":"response.completed"}\n\n']), { status: 200 });
184
+ });
185
+ // No sessionIdProvider → exercises the default, which must be stable per instance.
186
+ const provider = new CodexProvider({
187
+ tokens: makeTokens(),
188
+ fetch: fakeFetch as unknown as typeof fetch,
189
+ });
190
+ await collect(provider.stream(baseRequest()));
191
+ await collect(provider.stream(baseRequest()));
192
+ expect(keys).toHaveLength(2);
193
+ expect(keys[0]).toBeTruthy();
194
+ expect(keys[0]).toBe(keys[1]);
195
+ });
196
+
197
+ it('omits ChatGPT-Account-Id when no accountId is set', async () => {
198
+ const captured: { init?: RequestInit } = {};
199
+ const fakeFetch = vi.fn(async (_u, init?: RequestInit) => {
200
+ captured.init = init;
201
+ return new Response(sseStream(['data: {"type":"response.completed"}\n\n']), {
202
+ status: 200,
203
+ });
204
+ });
205
+ const provider = new CodexProvider({
206
+ tokens: makeTokens({ accountId: undefined }),
207
+ fetch: fakeFetch as unknown as typeof fetch,
208
+ });
209
+ await collect(provider.stream(baseRequest()));
210
+ const h = captured.init?.headers as Record<string, string>;
211
+ expect(h['ChatGPT-Account-Id']).toBeUndefined();
212
+ });
213
+
214
+ it('refreshes tokens proactively when expiry is within the 60s skew window, persists, then sends', async () => {
215
+ const persisted: CodexTokens[] = [];
216
+ const calls: Array<{ url: string; init?: RequestInit }> = [];
217
+ const fakeFetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
218
+ const u = String(url);
219
+ calls.push({ url: u, init });
220
+ if (u.endsWith('/oauth/token')) {
221
+ return new Response(
222
+ JSON.stringify({ access_token: 'NEW_AT', refresh_token: 'NEW_RT', expires_in: 3600 }),
223
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
224
+ );
225
+ }
226
+ return new Response(sseStream(['data: {"type":"response.completed"}\n\n']), { status: 200 });
227
+ });
228
+
229
+ const provider = new CodexProvider({
230
+ tokens: makeTokens({ expires: Date.now() + 10_000 }), // expires in 10s — within skew window
231
+ onTokensRefreshed: async (next) => {
232
+ persisted.push(next);
233
+ },
234
+ fetch: fakeFetch as unknown as typeof fetch,
235
+ });
236
+
237
+ await collect(provider.stream(baseRequest()));
238
+
239
+ // Token endpoint must be hit before the Codex API call.
240
+ expect(calls[0]?.url).toContain('/oauth/token');
241
+ expect(calls[1]?.url).toBe(CODEX_RESPONSES_URL);
242
+ expect((calls[1]?.init?.headers as Record<string, string>)['Authorization']).toBe('Bearer NEW_AT');
243
+
244
+ // onTokensRefreshed was called BEFORE the codex call went out.
245
+ expect(persisted).toHaveLength(1);
246
+ expect(persisted[0]!.access).toBe('NEW_AT');
247
+ expect(persisted[0]!.refresh).toBe('NEW_RT');
248
+ // accountId is preserved from the prior token bundle even though the
249
+ // refresh response doesn't re-issue an id_token.
250
+ expect(persisted[0]!.accountId).toBe('acct_test');
251
+ });
252
+
253
+ it('coalesces concurrent in-process refreshes — one token-endpoint call for two streams', async () => {
254
+ let refreshHits = 0;
255
+ const fakeFetch = vi.fn(async (url: RequestInfo | URL) => {
256
+ const u = String(url);
257
+ if (u.endsWith('/oauth/token')) {
258
+ refreshHits++;
259
+ return new Response(
260
+ JSON.stringify({ access_token: 'NEW_AT', refresh_token: 'NEW_RT', expires_in: 3600 }),
261
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
262
+ );
263
+ }
264
+ return new Response(sseStream(['data: {"type":"response.completed"}\n\n']), { status: 200 });
265
+ });
266
+ const provider = new CodexProvider({
267
+ tokens: makeTokens({ expires: Date.now() + 10_000 }), // both streams want a refresh
268
+ fetch: fakeFetch as unknown as typeof fetch,
269
+ });
270
+
271
+ await Promise.all([collect(provider.stream(baseRequest())), collect(provider.stream(baseRequest()))]);
272
+
273
+ // The single-use refresh token must be burned exactly once; the second
274
+ // stream adopts the first one's rotated bundle under the lock.
275
+ expect(refreshHits).toBe(1);
276
+ });
277
+
278
+ it('adopts a fresher persisted bundle via reloadTokens instead of burning its refresh token', async () => {
279
+ const calls: string[] = [];
280
+ const fakeFetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
281
+ calls.push(String(url));
282
+ if (String(url).endsWith('/oauth/token')) {
283
+ return new Response(JSON.stringify({ access_token: 'X', refresh_token: 'Y', expires_in: 3600 }), { status: 200 });
284
+ }
285
+ const h = init?.headers as Record<string, string>;
286
+ expect(h['Authorization']).toBe('Bearer RELOADED_AT');
287
+ return new Response(sseStream(['data: {"type":"response.completed"}\n\n']), { status: 200 });
288
+ });
289
+ const provider = new CodexProvider({
290
+ tokens: makeTokens({ expires: Date.now() + 10_000 }),
291
+ // Another process already refreshed and persisted a fresh bundle.
292
+ reloadTokens: async () => ({
293
+ access: 'RELOADED_AT',
294
+ refresh: 'RELOADED_RT',
295
+ expires: Date.now() + 3_600_000,
296
+ }),
297
+ fetch: fakeFetch as unknown as typeof fetch,
298
+ });
299
+
300
+ await collect(provider.stream(baseRequest()));
301
+
302
+ // No token-endpoint hit at all — the reloaded bundle satisfied the refresh.
303
+ expect(calls.some((u) => u.endsWith('/oauth/token'))).toBe(false);
304
+ });
305
+
306
+ it('recovers from invalid_grant by reloading the rotated refresh token and retrying once', async () => {
307
+ const persisted: CodexTokens[] = [];
308
+ const attempted: string[] = [];
309
+ let reloads = 0;
310
+ const fakeFetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => {
311
+ if (String(url).endsWith('/oauth/token')) {
312
+ const rt = new URLSearchParams(String(init?.body ?? '')).get('refresh_token') ?? '';
313
+ attempted.push(rt);
314
+ if (rt === 'RT') return new Response('{"error":"invalid_grant"}', { status: 400 });
315
+ return new Response(
316
+ JSON.stringify({ access_token: 'NEW_AT', refresh_token: 'NEW_RT', expires_in: 3600 }),
317
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
318
+ );
319
+ }
320
+ return new Response(sseStream(['data: {"type":"response.completed"}\n\n']), { status: 200 });
321
+ });
322
+ const provider = new CodexProvider({
323
+ tokens: makeTokens({ expires: Date.now() + 10_000 }), // holds the now-dead RT
324
+ reloadTokens: async () => {
325
+ reloads++;
326
+ // First consult (pre-refresh): vault still has our (dead) bundle.
327
+ // Second consult (post-invalid_grant): another process rotated it.
328
+ return reloads === 1
329
+ ? makeTokens({ expires: Date.now() + 10_000 })
330
+ : makeTokens({ refresh: 'ROTATED_RT', expires: Date.now() + 10_000 });
331
+ },
332
+ onTokensRefreshed: async (next) => {
333
+ persisted.push(next);
334
+ },
335
+ fetch: fakeFetch as unknown as typeof fetch,
336
+ });
337
+
338
+ await collect(provider.stream(baseRequest()));
339
+
340
+ expect(attempted).toEqual(['RT', 'ROTATED_RT']);
341
+ expect(persisted).toHaveLength(1);
342
+ expect(persisted[0]!.access).toBe('NEW_AT');
343
+ expect(persisted[0]!.refresh).toBe('NEW_RT');
344
+ });
345
+
346
+ it('refreshes and replays once on a 401, then surfaces error on a second 401', async () => {
347
+ let codexHits = 0;
348
+ let refreshHits = 0;
349
+ const fakeFetch = vi.fn(async (url: RequestInfo | URL) => {
350
+ const u = String(url);
351
+ if (u.endsWith('/oauth/token')) {
352
+ refreshHits++;
353
+ return new Response(
354
+ JSON.stringify({ access_token: 'AT2', refresh_token: 'RT2', expires_in: 3600 }),
355
+ { status: 200, headers: { 'Content-Type': 'application/json' } },
356
+ );
357
+ }
358
+ codexHits++;
359
+ return new Response('unauthorized', { status: 401 });
360
+ });
361
+
362
+ const provider = new CodexProvider({
363
+ tokens: makeTokens(),
364
+ fetch: fakeFetch as unknown as typeof fetch,
365
+ });
366
+
367
+ const events = await collect(provider.stream(baseRequest()));
368
+ expect(refreshHits).toBe(1);
369
+ expect(codexHits).toBe(2);
370
+ // A 401 surviving the forced refresh surfaces actionable re-auth guidance
371
+ // (not a generic "returned 401" HTTP error), and is non-retryable.
372
+ const err = events.find((e) => e.type === 'error') as
373
+ | { type: 'error'; message: string; retryable?: boolean }
374
+ | undefined;
375
+ expect(err).toBeDefined();
376
+ expect(err!.message).toMatch(/moxxy login openai-codex/);
377
+ expect(err!.message).toMatch(/re-authenticate/i);
378
+ expect(err!.retryable).toBe(false);
379
+ });
380
+
381
+ it('aborts a stalled request via the internal idle watchdog (no caller signal needed)', async () => {
382
+ // Backend accepts the POST but never sends headers/body. With no caller
383
+ // AbortSignal, the internal idle watchdog must fire and abort the fetch so
384
+ // the turn surfaces an error instead of hanging forever.
385
+ const fakeFetch = vi.fn((_u: RequestInfo | URL, init?: RequestInit) => {
386
+ return new Promise<Response>((_resolve, reject) => {
387
+ const sig = init?.signal;
388
+ if (sig) {
389
+ sig.addEventListener('abort', () => reject(sig.reason ?? new Error('aborted')), {
390
+ once: true,
391
+ });
392
+ }
393
+ });
394
+ });
395
+ const provider = new CodexProvider({
396
+ tokens: makeTokens(),
397
+ fetch: fakeFetch as unknown as typeof fetch,
398
+ idleTimeoutMs: 20,
399
+ });
400
+
401
+ const events = await collect(provider.stream(baseRequest()));
402
+ const err = events.find((e) => e.type === 'error');
403
+ expect(err).toBeDefined();
404
+ // It did not hang — the watchdog aborted and we got a terminal error event.
405
+ expect(events.some((e) => e.type === 'message_end')).toBe(false);
406
+ });
407
+
408
+ it('caps the buffered error-response body instead of holding a hostile multi-MB body', async () => {
409
+ const huge = 'E'.repeat(5 * 1024 * 1024); // 5 MiB error body
410
+ const fakeFetch = vi.fn(async () => new Response(huge, { status: 400 }));
411
+ const provider = new CodexProvider({
412
+ tokens: makeTokens(),
413
+ fetch: fakeFetch as unknown as typeof fetch,
414
+ });
415
+ const events = await collect(provider.stream(baseRequest()));
416
+ const err = events.find((e) => e.type === 'error') as { message: string } | undefined;
417
+ expect(err).toBeDefined();
418
+ // The message includes only a bounded slice of the body, not all 5 MiB.
419
+ expect(err!.message.length).toBeLessThan(4096);
420
+ expect(err!.message).toMatch(/returned 400/);
421
+ });
422
+
423
+ it('countTokens does not stringify base64 payloads and stays bounded for a large image', async () => {
424
+ const provider = new CodexProvider({ tokens: makeTokens() });
425
+ const bigImage = 'A'.repeat(4 * 1024 * 1024); // 4 MiB of base64
426
+ const withImage = await provider.countTokens({
427
+ model: 'gpt-5.3-codex',
428
+ messages: [
429
+ { role: 'user', content: [{ type: 'image', mediaType: 'image/png', data: bigImage }] },
430
+ ],
431
+ });
432
+ const textOnly = await provider.countTokens({
433
+ model: 'gpt-5.3-codex',
434
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
435
+ });
436
+ // A 4 MiB base64 image must NOT be counted as ~1M tokens (4MiB/4) — the
437
+ // estimate stays a small flat per-block charge.
438
+ expect(withImage).toBeLessThan(1000);
439
+ expect(withImage).toBeGreaterThan(textOnly);
440
+ });
441
+
442
+ it('errors clearly when no tokens are configured', async () => {
443
+ const provider = new CodexProvider({}); // no tokens
444
+ const events = await collect(provider.stream(baseRequest()));
445
+ const err = events.find((e) => e.type === 'error');
446
+ expect(err).toBeDefined();
447
+ expect((err as { message: string }).message).toMatch(/moxxy login openai-codex/);
448
+ });
449
+
450
+ it('emits a single error and no trailing message_end when the stream reports response.failed', async () => {
451
+ const fakeFetch = vi.fn(async () => {
452
+ return new Response(
453
+ sseStream([
454
+ 'data: {"type":"response.output_text.delta","delta":"partial"}\n\n',
455
+ 'data: {"type":"response.failed","error":{"message":"model overloaded"}}\n\n',
456
+ // A stray frame after the terminal error must be ignored, not turned
457
+ // into a second terminal event.
458
+ 'data: {"type":"response.completed","response":{"usage":{"input_tokens":1,"output_tokens":1}}}\n\n',
459
+ ]),
460
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
461
+ );
462
+ });
463
+ const provider = new CodexProvider({
464
+ tokens: makeTokens(),
465
+ fetch: fakeFetch as unknown as typeof fetch,
466
+ });
467
+ const events = await collect(provider.stream(baseRequest()));
468
+
469
+ const errs = events.filter((e) => e.type === 'error');
470
+ expect(errs).toHaveLength(1);
471
+ expect((errs[0] as { message: string }).message).toMatch(/overloaded/);
472
+ // A failed turn must NOT also produce a message_end (the old code ignored
473
+ // the terminal flag and fell through to one).
474
+ expect(events.some((e) => e.type === 'message_end')).toBe(false);
475
+ });
476
+
477
+ it('parses tool_use_start/delta/end from function_call SSE events', async () => {
478
+ const fakeFetch = vi.fn(async () => {
479
+ return new Response(
480
+ sseStream([
481
+ 'data: {"type":"response.output_item.added","item":{"type":"function_call","id":"fc_1","call_id":"call_abc","name":"Read"}}\n\n',
482
+ 'data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"{\\"path\\":"}\n\n',
483
+ 'data: {"type":"response.function_call_arguments.delta","item_id":"fc_1","delta":"\\"/tmp/x\\"}"}\n\n',
484
+ 'data: {"type":"response.function_call_arguments.done","item_id":"fc_1","arguments":"{\\"path\\":\\"/tmp/x\\"}"}\n\n',
485
+ 'data: {"type":"response.completed"}\n\n',
486
+ ]),
487
+ { status: 200, headers: { 'Content-Type': 'text/event-stream' } },
488
+ );
489
+ });
490
+ const provider = new CodexProvider({
491
+ tokens: makeTokens(),
492
+ fetch: fakeFetch as unknown as typeof fetch,
493
+ });
494
+ const events = await collect(provider.stream(baseRequest()));
495
+
496
+ const start = events.find((e) => e.type === 'tool_use_start');
497
+ const end = events.find((e) => e.type === 'tool_use_end');
498
+ expect(start).toMatchObject({ type: 'tool_use_start', id: 'call_abc', name: 'Read' });
499
+ expect(end).toMatchObject({ type: 'tool_use_end', id: 'call_abc', input: { path: '/tmp/x' } });
500
+ // Crucial regression guard: the Responses API's `response.completed`
501
+ // doesn't carry a stop_reason, so the provider must infer 'tool_use'
502
+ // from the emitted tool_use_end events. Without this, the upstream
503
+ // tool-use loop drops the call without executing it.
504
+ const messageEnd = events.find((e): e is Extract<ProviderEvent, { type: 'message_end' }> => e.type === 'message_end');
505
+ expect(messageEnd?.stopReason).toBe('tool_use');
506
+ });
507
+ });