@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,360 @@
1
+ import { webcrypto } from 'node:crypto';
2
+ import type {
3
+ LLMProvider,
4
+ ProviderEvent,
5
+ ProviderRequest,
6
+ } from '@moxxy/sdk';
7
+ import { classifyHttpStatus, estimateTextTokens } from '@moxxy/sdk';
8
+ import { isAuthRejection, withCredentialLock } from '@moxxy/plugin-oauth';
9
+ import { CODEX_RESPONSES_URL, refreshTokens } from './oauth.js';
10
+ import { codexModels, DEFAULT_CODEX_MODEL } from './models.js';
11
+ import { toResponsesBody } from './translate.js';
12
+ import { buildCodexHeaders } from './codex/headers.js';
13
+ import { consumeResponsesSse, toErrorEvent } from './codex/stream-consumer.js';
14
+ import type { CodexTokens } from './types.js';
15
+
16
+ /**
17
+ * Internal idle timeout (ms). Guards the whole streaming path against a backend
18
+ * that accepts the POST but then stalls — pre-headers slow-loris, a dropped TCP
19
+ * with no RST, or a wedged proxy — when the caller supplied no AbortSignal. The
20
+ * watchdog is reset on every received byte, so a slow-but-alive reasoning stream
21
+ * is never killed; only a stream that goes silent for this long aborts.
22
+ */
23
+ const CODEX_IDLE_TIMEOUT_MS = 120_000;
24
+
25
+ /**
26
+ * Read the error-response body but cap how much we pull into memory: a hostile
27
+ * or broken backend (or MITM) could otherwise stream a multi-megabyte error body
28
+ * that we'd buffer in full and then interpolate into an error string.
29
+ */
30
+ const MAX_ERROR_BODY_CHARS = 2048;
31
+
32
+ /**
33
+ * Flat token estimate charged per non-text content block (image/document) in
34
+ * `countTokens`. A coarse stand-in for the provider's real multimodal token
35
+ * accounting — enough to keep pre-flight budgeting from treating a multimodal
36
+ * request as if it were nearly empty.
37
+ */
38
+ const NON_TEXT_BLOCK_TOKENS = 256;
39
+
40
+ export interface CodexProviderConfig {
41
+ readonly tokens?: CodexTokens;
42
+ /**
43
+ * Called with the new token bundle whenever an in-process refresh happens.
44
+ * The CLI's setup wires this to a vault writeback so the refreshed
45
+ * refresh_token (single-use, rotates on every refresh) is persisted
46
+ * before the next API call goes out.
47
+ */
48
+ readonly onTokensRefreshed?: (next: CodexTokens) => void | Promise<void>;
49
+ /**
50
+ * Re-reads the persisted token bundle (the vault) and returns it, or null
51
+ * when nothing is stored. The refresh token is SINGLE-USE and rotates on
52
+ * every refresh, so when another consumer/process refreshes first, this is
53
+ * how the provider picks up the rotated bundle instead of burning a dead
54
+ * token: it's consulted under the per-credential lock before refreshing,
55
+ * and once more to retry after an invalid_grant-style rejection.
56
+ */
57
+ readonly reloadTokens?: () => Promise<CodexTokens | null>;
58
+ readonly defaultModel?: string;
59
+ /**
60
+ * Reasoning effort sent with every request (`reasoning.effort`).
61
+ * Defaults to `'medium'`. Reaches here from `provider.config` in
62
+ * moxxy.config.ts (e.g. `{ reasoningEffort: 'high' }`); the CLI's
63
+ * credential resolution merges that config through to `createClient`.
64
+ */
65
+ readonly reasoningEffort?: 'low' | 'medium' | 'high';
66
+ /** Test seam — when omitted we use the global `fetch`. */
67
+ readonly fetch?: typeof fetch;
68
+ /** Test seam — when omitted we use crypto.randomUUID for the per-request session id. */
69
+ readonly sessionIdProvider?: () => string;
70
+ /**
71
+ * Idle timeout (ms) for the streaming request: the watchdog aborts the call if
72
+ * the backend goes silent for this long (stalled handshake or mid-stream
73
+ * silence). Reset on every received byte, so slow-but-alive streams survive.
74
+ * Defaults to {@link CODEX_IDLE_TIMEOUT_MS}; mainly a test seam.
75
+ */
76
+ readonly idleTimeoutMs?: number;
77
+ }
78
+
79
+ /**
80
+ * LLMProvider implementation against the ChatGPT-plan Codex backend. Auth is
81
+ * an OAuth bearer plus the optional ChatGPT-Account-Id header; the rest of
82
+ * the request body is the OpenAI Responses-API shape.
83
+ *
84
+ * Request-param support: `req.maxTokens` maps to the Responses
85
+ * `max_output_tokens` field; `req.temperature` is NOT forwarded — the Codex
86
+ * backend only serves gpt-5-family reasoning models, which reject sampling
87
+ * params with a 400, so it is dropped (with a one-shot MOXXY_DEBUG note)
88
+ * instead of breaking the request. Reasoning effort is configurable via
89
+ * `CodexProviderConfig.reasoningEffort` (default `'medium'`).
90
+ */
91
+ export class CodexProvider implements LLMProvider {
92
+ readonly name = 'openai-codex';
93
+ readonly models = codexModels;
94
+
95
+ private tokens: CodexTokens | undefined;
96
+ private readonly onTokensRefreshed?: (next: CodexTokens) => void | Promise<void>;
97
+ private readonly reloadTokens?: () => Promise<CodexTokens | null>;
98
+ private readonly defaultModel: string;
99
+ private readonly reasoningEffort?: 'low' | 'medium' | 'high';
100
+ private readonly fetchImpl: typeof fetch;
101
+ private readonly sessionIdProvider: () => string;
102
+ private readonly idleTimeoutMs: number;
103
+
104
+ constructor(config: CodexProviderConfig = {}) {
105
+ if (config.tokens) this.tokens = config.tokens;
106
+ if (config.onTokensRefreshed) this.onTokensRefreshed = config.onTokensRefreshed;
107
+ if (config.reloadTokens) this.reloadTokens = config.reloadTokens;
108
+ this.defaultModel = config.defaultModel ?? DEFAULT_CODEX_MODEL;
109
+ if (config.reasoningEffort) this.reasoningEffort = config.reasoningEffort;
110
+ this.fetchImpl = config.fetch ?? fetch;
111
+ // Default to ONE id for the instance's lifetime, not a fresh uuid per call.
112
+ // The session id becomes the `prompt_cache_key`, so it must be stable
113
+ // across a session's turns for the Responses prefix cache to hit (and the
114
+ // `session_id` header should be stable for one logical session too).
115
+ const defaultSessionId = webcrypto.randomUUID();
116
+ this.sessionIdProvider = config.sessionIdProvider ?? (() => defaultSessionId);
117
+ this.idleTimeoutMs =
118
+ config.idleTimeoutMs && config.idleTimeoutMs > 0
119
+ ? config.idleTimeoutMs
120
+ : CODEX_IDLE_TIMEOUT_MS;
121
+ }
122
+
123
+ async *stream(req: ProviderRequest): AsyncIterable<ProviderEvent> {
124
+ const model = req.model || this.defaultModel;
125
+ yield { type: 'message_start', model };
126
+
127
+ try {
128
+ await this.ensureFresh();
129
+ } catch (err) {
130
+ yield toErrorEvent(err);
131
+ return;
132
+ }
133
+
134
+ const sessionId = this.sessionIdProvider();
135
+ // Reasoning preview is gated by the per-provider toggle (`req.reasoning`):
136
+ // when off we behave exactly as before (discard reasoning). The per-call
137
+ // effort, when set, overrides the instance default from provider config.
138
+ const emitReasoning = req.reasoning != null && req.reasoning !== false;
139
+ const reqEffort = typeof req.reasoning === 'object' ? req.reasoning.effort : undefined;
140
+ const reasoningEffort = reqEffort ?? this.reasoningEffort;
141
+ // Pass the session id as the Responses prefix-cache key so repeated turns
142
+ // in a session reuse the cached prefix (cheaper + faster). Codex DOES
143
+ // support this even though the Chat-Completions providers ignore cacheHints.
144
+ const body = toResponsesBody(
145
+ { ...req, model },
146
+ {
147
+ sessionHint: sessionId,
148
+ ...(reasoningEffort ? { reasoningEffort } : {}),
149
+ },
150
+ );
151
+
152
+ // Internal idle watchdog: aborts the request if the backend stalls (accepts
153
+ // the POST but never sends headers/body, or goes silent mid-stream) so the
154
+ // turn can't hang forever when the caller supplied no AbortSignal. Composed
155
+ // with `req.signal` so a caller cancellation still wins, and reset on every
156
+ // received byte by `consumeResponsesSse`'s onActivity callback.
157
+ const idleController = new AbortController();
158
+ let idleTimer: ReturnType<typeof setTimeout> | undefined;
159
+ const armIdle = (): void => {
160
+ if (idleTimer) clearTimeout(idleTimer);
161
+ idleTimer = setTimeout(
162
+ () => idleController.abort(new Error('Codex request timed out (no response from server)')),
163
+ this.idleTimeoutMs,
164
+ );
165
+ // A bare timer must not keep the event loop alive on its own.
166
+ idleTimer.unref?.();
167
+ };
168
+ const disarmIdle = (): void => {
169
+ if (idleTimer) clearTimeout(idleTimer);
170
+ idleTimer = undefined;
171
+ };
172
+ const signal = req.signal
173
+ ? AbortSignal.any([req.signal, idleController.signal])
174
+ : idleController.signal;
175
+
176
+ try {
177
+ armIdle();
178
+ let response: Response;
179
+ try {
180
+ response = await this.postCodex(body, sessionId, signal);
181
+ } catch (err) {
182
+ yield toErrorEvent(err);
183
+ return;
184
+ }
185
+
186
+ if (response.status === 401) {
187
+ // Token might've been revoked between our pre-check and send; try one
188
+ // forced refresh and replay. A second 401 is fatal.
189
+ try {
190
+ await this.refreshNow();
191
+ armIdle();
192
+ response = await this.postCodex(body, sessionId, signal);
193
+ } catch (err) {
194
+ yield toErrorEvent(err);
195
+ return;
196
+ }
197
+ // A 401 that survives a forced refresh means the OAuth grant itself was
198
+ // rejected (expired/revoked), not a transient skew. Surface the same
199
+ // actionable re-auth guidance as ensureFresh rather than the generic
200
+ // "returned 401" HTTP message below.
201
+ if (response.status === 401) {
202
+ yield {
203
+ type: 'error',
204
+ message:
205
+ 'ChatGPT OAuth credentials were rejected after a token refresh. Run `moxxy login openai-codex` to re-authenticate.',
206
+ retryable: false,
207
+ };
208
+ return;
209
+ }
210
+ }
211
+
212
+ if (!response.ok || !response.body) {
213
+ // Cap the buffered error body: a hostile/broken backend could otherwise
214
+ // stream a huge body that we'd hold in full and then put into a string.
215
+ const text = await readCappedText(response, MAX_ERROR_BODY_CHARS);
216
+ // Derive the retryable verdict from the SDK's status classifier so it
217
+ // stays consistent with the rest of moxxy (429 + 5xx are retryable).
218
+ const classified = classifyHttpStatus(response.status);
219
+ const retryable =
220
+ classified?.code === 'PROVIDER_RATE_LIMITED' ||
221
+ classified?.code === 'PROVIDER_SERVER_ERROR';
222
+ yield {
223
+ type: 'error',
224
+ message: `Codex /responses returned ${response.status}: ${text || response.statusText}`,
225
+ retryable,
226
+ };
227
+ return;
228
+ }
229
+
230
+ yield* consumeResponsesSse(response.body, signal, emitReasoning, armIdle);
231
+ } finally {
232
+ disarmIdle();
233
+ }
234
+ }
235
+
236
+ async countTokens(
237
+ req: Pick<ProviderRequest, 'model' | 'messages' | 'system' | 'tools'>,
238
+ ): Promise<number> {
239
+ // Only sum text. For non-text blocks (image/document) we add a flat per-block
240
+ // estimate instead of JSON.stringify-ing the base64 `data`, which would be a
241
+ // multi-megabyte transient allocation AND a meaningless token count (base64
242
+ // char length, not visual tokens) that skews any pre-flight budgeting.
243
+ let blob = req.system ?? '';
244
+ let nonTextBlocks = 0;
245
+ for (const m of req.messages) {
246
+ for (const c of m.content) {
247
+ if ('text' in c && typeof c.text === 'string') blob += c.text;
248
+ else nonTextBlocks += 1;
249
+ }
250
+ }
251
+ blob += (req.tools ?? []).map((t) => t.name + t.description).join('');
252
+ return estimateTextTokens(blob) + nonTextBlocks * NON_TEXT_BLOCK_TOKENS;
253
+ }
254
+
255
+ private postCodex(body: unknown, sessionId: string, signal: AbortSignal | undefined): Promise<Response> {
256
+ if (!this.tokens) throw new Error('No tokens');
257
+ return this.fetchImpl(CODEX_RESPONSES_URL, {
258
+ method: 'POST',
259
+ headers: buildCodexHeaders(this.tokens, sessionId),
260
+ body: JSON.stringify(body),
261
+ ...(signal ? { signal } : {}),
262
+ });
263
+ }
264
+
265
+ private async ensureFresh(): Promise<void> {
266
+ if (!this.tokens) {
267
+ throw new Error(
268
+ 'No ChatGPT OAuth credentials available. Run `moxxy login openai-codex` to sign in.',
269
+ );
270
+ }
271
+ // 60s skew window — refresh proactively if the token will die very soon.
272
+ if (this.tokens.expires > Date.now() + 60_000) return;
273
+ await this.refreshNow();
274
+ }
275
+
276
+ /**
277
+ * Refresh + persist under the per-credential lock. The refresh token is
278
+ * SINGLE-USE (rotated + invalidated on every refresh), so concurrent
279
+ * refreshers — a second stream in this process, the whisper-stt
280
+ * transcriber sharing this credential, or another moxxy process — must
281
+ * serialize and coalesce: whoever holds the lock refreshes once, everyone
282
+ * queued behind it adopts the rotated bundle instead of burning it.
283
+ */
284
+ private async refreshNow(): Promise<void> {
285
+ const entry = this.tokens;
286
+ if (!entry) {
287
+ throw new Error('Cannot refresh — no stored tokens.');
288
+ }
289
+ await withCredentialLock(`oauth-${this.name}`, async () => {
290
+ // Coalesce in-process: another stream refreshed while we waited.
291
+ if (this.tokens && this.tokens.access !== entry.access) return;
292
+ // Coalesce cross-process/cross-consumer: adopt a fresher persisted
293
+ // bundle (and at minimum its rotated refresh token) when available.
294
+ let attempt = this.tokens ?? entry;
295
+ if (this.reloadTokens) {
296
+ const latest = await this.reloadTokens().catch(() => null);
297
+ if (latest && latest.access !== attempt.access && latest.expires > Date.now() + 60_000) {
298
+ this.tokens = withAccountId(latest, latest.accountId ?? attempt.accountId);
299
+ return;
300
+ }
301
+ if (latest?.refresh) attempt = { ...attempt, refresh: latest.refresh };
302
+ }
303
+ let next: CodexTokens;
304
+ try {
305
+ next = await refreshTokens(attempt.refresh, this.fetchImpl);
306
+ } catch (err) {
307
+ // invalid_grant-style rejection: someone rotated our refresh token
308
+ // away after the reload above. Re-read once and retry with the
309
+ // fresher token before surfacing the failure.
310
+ const latest = isAuthRejection(err) && this.reloadTokens
311
+ ? await this.reloadTokens().catch(() => null)
312
+ : null;
313
+ if (!latest?.refresh || latest.refresh === attempt.refresh) throw err;
314
+ next = await refreshTokens(latest.refresh, this.fetchImpl);
315
+ }
316
+ // Preserve a previously known accountId if the refresh response didn't
317
+ // re-issue an id_token. Without this we'd silently lose the
318
+ // ChatGPT-Account-Id header on every refresh.
319
+ const merged = withAccountId(next, next.accountId ?? attempt.accountId);
320
+ this.tokens = merged;
321
+ if (this.onTokensRefreshed) {
322
+ // Persist BEFORE the caller issues the API call so a crash here
323
+ // doesn't strand an unwritten refresh token in memory.
324
+ await this.onTokensRefreshed(merged);
325
+ }
326
+ });
327
+ }
328
+ }
329
+
330
+ function withAccountId(tokens: CodexTokens, accountId: string | undefined): CodexTokens {
331
+ return accountId
332
+ ? { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires, accountId }
333
+ : { access: tokens.access, refresh: tokens.refresh, expires: tokens.expires };
334
+ }
335
+
336
+ /**
337
+ * Read an error-response body but stop once `maxChars` is reached, then release
338
+ * the socket. Avoids pulling a hostile/broken multi-megabyte error body fully
339
+ * into memory just to put a prefix of it in an error message. Decode failures or
340
+ * a missing body yield `''` so the caller falls back to `response.statusText`.
341
+ */
342
+ async function readCappedText(response: Response, maxChars: number): Promise<string> {
343
+ const body = response.body;
344
+ if (!body) return '';
345
+ const reader = body.getReader();
346
+ const decoder = new TextDecoder('utf-8');
347
+ let out = '';
348
+ try {
349
+ while (out.length < maxChars) {
350
+ const { done, value } = await reader.read();
351
+ if (done) break;
352
+ out += decoder.decode(value, { stream: true });
353
+ }
354
+ return out.slice(0, maxChars);
355
+ } catch {
356
+ return out.slice(0, maxChars);
357
+ } finally {
358
+ reader.cancel().catch(() => {});
359
+ }
360
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { extractSystemText, toResponsesBody, toResponsesInput } from './translate.js';
3
+
4
+ describe('toResponsesInput', () => {
5
+ it('translates an image block to an input_image data URL', () => {
6
+ const input = toResponsesInput([
7
+ {
8
+ role: 'user',
9
+ content: [
10
+ { type: 'text', text: 'what is this?' },
11
+ { type: 'image', mediaType: 'image/png', data: 'AAAA' },
12
+ ],
13
+ },
14
+ ]);
15
+ expect(input[0]).toEqual({
16
+ type: 'message',
17
+ role: 'user',
18
+ content: [
19
+ { type: 'input_text', text: 'what is this?' },
20
+ { type: 'input_image', image_url: 'data:image/png;base64,AAAA' },
21
+ ],
22
+ });
23
+ });
24
+
25
+ it('preserves a user message whose only blocks are untranslatable instead of dropping it', () => {
26
+ // A user message carrying solely block types contentBlocksToInputText doesn't
27
+ // emit (e.g. audio) must not silently vanish from the request — keep the turn
28
+ // with an empty input_text so conversational context isn't lost.
29
+ const input = toResponsesInput([
30
+ { role: 'user', content: [{ type: 'audio', mediaType: 'audio/wav', data: 'AAAA' }] },
31
+ ]);
32
+ expect(input).toEqual([
33
+ { type: 'message', role: 'user', content: [{ type: 'input_text', text: '' }] },
34
+ ]);
35
+ });
36
+
37
+ it('still drops a user message with no blocks at all', () => {
38
+ const input = toResponsesInput([{ role: 'user', content: [] }]);
39
+ expect(input).toEqual([]);
40
+ });
41
+
42
+ it('translates a document block to an input_file with data URL + filename', () => {
43
+ const input = toResponsesInput([
44
+ {
45
+ role: 'user',
46
+ content: [
47
+ { type: 'text', text: 'summarize this' },
48
+ { type: 'document', mediaType: 'application/pdf', data: 'JVBERi0=', name: 'report.pdf' },
49
+ ],
50
+ },
51
+ ]);
52
+ expect(input[0]).toEqual({
53
+ type: 'message',
54
+ role: 'user',
55
+ content: [
56
+ { type: 'input_text', text: 'summarize this' },
57
+ { type: 'input_file', filename: 'report.pdf', file_data: 'data:application/pdf;base64,JVBERi0=' },
58
+ ],
59
+ });
60
+ });
61
+ });
62
+
63
+ describe('extractSystemText', () => {
64
+ it('appends explicitSystem (req.system) AFTER the message-derived system prompt', () => {
65
+ const text = extractSystemText(
66
+ [
67
+ { role: 'system', content: [{ type: 'text', text: 'BASE PROMPT' }] },
68
+ { role: 'user', content: [{ type: 'text', text: 'hi' }] },
69
+ ],
70
+ '[memory note] consider consolidating',
71
+ );
72
+ expect(text).toBe('BASE PROMPT\n\n[memory note] consider consolidating');
73
+ });
74
+ });
75
+
76
+ describe('toResponsesBody', () => {
77
+ const req = {
78
+ model: 'gpt-5.3-codex',
79
+ messages: [
80
+ { role: 'system' as const, content: [{ type: 'text' as const, text: 'BASE' }] },
81
+ { role: 'user' as const, content: [{ type: 'text' as const, text: 'hi' }] },
82
+ ],
83
+ };
84
+
85
+ it('delivers hook-injected req.system in instructions', () => {
86
+ const body = toResponsesBody({ ...req, system: 'NUDGE' });
87
+ expect(body.instructions).toBe('BASE\n\nNUDGE');
88
+ });
89
+
90
+ it('drops both maxTokens and temperature (ChatGPT Codex backend rejects them)', () => {
91
+ const body = toResponsesBody({ ...req, maxTokens: 999, temperature: 0.5 });
92
+ expect(body).not.toHaveProperty('max_output_tokens');
93
+ expect(body).not.toHaveProperty('temperature');
94
+ });
95
+ });
@@ -0,0 +1,224 @@
1
+ import { zodToJsonSchema, type ProviderMessage, type ProviderRequest, type ToolDef } from '@moxxy/sdk';
2
+
3
+ /**
4
+ * Responses-API "input" item shape. We only emit the subset codex uses:
5
+ * - `message` items with role + a string-or-rich content
6
+ * - `function_call` items (assistant tool invocations replayed back)
7
+ * - `function_call_output` items (tool results)
8
+ */
9
+ type ResponsesInputItem =
10
+ | { type: 'message'; role: 'user' | 'assistant' | 'system'; content: ReadonlyArray<ResponsesContent> }
11
+ | { type: 'function_call'; call_id: string; name: string; arguments: string }
12
+ | { type: 'function_call_output'; call_id: string; output: string };
13
+
14
+ type ResponsesContent =
15
+ | { type: 'input_text'; text: string }
16
+ | { type: 'output_text'; text: string }
17
+ | { type: 'input_image'; image_url: string }
18
+ | { type: 'input_file'; filename?: string; file_data: string };
19
+
20
+ interface ResponsesTool {
21
+ type: 'function';
22
+ name: string;
23
+ description: string;
24
+ parameters: unknown;
25
+ strict?: boolean;
26
+ }
27
+
28
+ export interface ResponsesBody {
29
+ model: string;
30
+ instructions?: string;
31
+ input: ResponsesInputItem[];
32
+ tools?: ResponsesTool[];
33
+ parallel_tool_calls?: boolean;
34
+ reasoning?: { effort?: 'low' | 'medium' | 'high'; summary?: 'auto' | 'detailed' };
35
+ store?: boolean;
36
+ stream: true;
37
+ prompt_cache_key?: string;
38
+ include?: string[];
39
+ // NOTE: no `temperature` and no `max_output_tokens` — every model the Codex
40
+ // backend serves is a gpt-5-family reasoning model, and the ChatGPT-plan
41
+ // `/responses` endpoint rejects sampling params (`temperature`/`top_p`) AND
42
+ // `max_output_tokens` with a 400 ("Unsupported parameter"). See
43
+ // `noteTemperatureUnsupported`/`noteMaxTokensUnsupported` below.
44
+ }
45
+
46
+ function contentBlocksToInputText(
47
+ role: 'user' | 'assistant',
48
+ blocks: ProviderMessage['content'],
49
+ ): ResponsesContent[] {
50
+ const out: ResponsesContent[] = [];
51
+ for (const block of blocks) {
52
+ if (block.type === 'text') {
53
+ out.push(role === 'assistant' ? { type: 'output_text', text: block.text } : { type: 'input_text', text: block.text });
54
+ } else if (block.type === 'image') {
55
+ out.push({ type: 'input_image', image_url: `data:${block.mediaType};base64,${block.data}` });
56
+ } else if (block.type === 'document') {
57
+ out.push({
58
+ type: 'input_file',
59
+ ...(block.name ? { filename: block.name } : {}),
60
+ file_data: `data:${block.mediaType};base64,${block.data}`,
61
+ });
62
+ }
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /**
68
+ * Extract any system-role text from a message array. Used to hoist the
69
+ * system prompt into the top-level `instructions` field — the Responses
70
+ * API rejects requests with a missing/empty `instructions`, and our
71
+ * upstream loop helpers push the system prompt in as a `role: 'system'`
72
+ * message. `explicitSystem` (`ProviderRequest.system`) is the
73
+ * hook-injection side channel — extra per-request system text appended
74
+ * AFTER the message-derived prompt, matching how the other providers
75
+ * deliver it.
76
+ */
77
+ export function extractSystemText(
78
+ messages: ReadonlyArray<ProviderMessage>,
79
+ explicitSystem?: string,
80
+ ): string {
81
+ const parts: string[] = [];
82
+ for (const msg of messages) {
83
+ if (msg.role !== 'system') continue;
84
+ for (const block of msg.content) {
85
+ if (block.type === 'text' && block.text) parts.push(block.text);
86
+ }
87
+ }
88
+ if (explicitSystem && explicitSystem.trim()) parts.push(explicitSystem);
89
+ return parts.join('\n\n');
90
+ }
91
+
92
+ export function toResponsesInput(messages: ReadonlyArray<ProviderMessage>): ResponsesInputItem[] {
93
+ const out: ResponsesInputItem[] = [];
94
+ for (const msg of messages) {
95
+ if (msg.role === 'system') {
96
+ // System prompt is hoisted to top-level `instructions` by
97
+ // `toResponsesBody`; don't duplicate it as an input message too.
98
+ continue;
99
+ }
100
+ if (msg.role === 'user') {
101
+ const content = contentBlocksToInputText('user', msg.content);
102
+ if (content.length > 0) {
103
+ out.push({ type: 'message', role: 'user', content });
104
+ } else if (msg.content.length > 0) {
105
+ // The message carried blocks but none translated (unhandled block types):
106
+ // keep the turn with an empty input_text so we don't silently drop it
107
+ // from the request and lose conversational context.
108
+ out.push({ type: 'message', role: 'user', content: [{ type: 'input_text', text: '' }] });
109
+ }
110
+ continue;
111
+ }
112
+ if (msg.role === 'assistant') {
113
+ const text = msg.content
114
+ .filter((c): c is { type: 'text'; text: string } => c.type === 'text')
115
+ .map((c) => c.text)
116
+ .join('');
117
+ if (text) out.push({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] });
118
+ for (const block of msg.content) {
119
+ if (block.type === 'tool_use') {
120
+ out.push({
121
+ type: 'function_call',
122
+ call_id: block.id,
123
+ name: block.name,
124
+ arguments: JSON.stringify(block.input ?? {}),
125
+ });
126
+ }
127
+ }
128
+ continue;
129
+ }
130
+ if (msg.role === 'tool_result') {
131
+ for (const block of msg.content) {
132
+ if (block.type === 'tool_result') {
133
+ out.push({
134
+ type: 'function_call_output',
135
+ call_id: block.toolUseId,
136
+ output: block.content,
137
+ });
138
+ }
139
+ }
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ export function toResponsesTools(tools: ReadonlyArray<ToolDef>): ResponsesTool[] {
146
+ return tools.map((t) => ({
147
+ type: 'function' as const,
148
+ name: t.name,
149
+ description: t.description,
150
+ parameters: (t.inputJsonSchema ?? zodToJsonSchema(t.inputSchema)) as unknown,
151
+ }));
152
+ }
153
+
154
+ export interface BuildBodyOptions {
155
+ readonly sessionHint?: string;
156
+ readonly reasoningEffort?: 'low' | 'medium' | 'high';
157
+ }
158
+
159
+ /**
160
+ * Default `instructions` value used when the caller supplies neither
161
+ * `req.system` nor a system-role message. The Codex backend rejects
162
+ * requests with an empty `instructions` field (`400: Instructions are
163
+ * required`), so we always send something — falling back to a minimal
164
+ * agent identity matches what codex-rs does for plain `codex exec` runs.
165
+ */
166
+ const DEFAULT_INSTRUCTIONS = 'You are a helpful coding assistant.';
167
+
168
+ /**
169
+ * One-shot debug note for the unsupported `temperature` param. The Codex
170
+ * backend only serves gpt-5-family reasoning models, which reject
171
+ * sampling params with `400: Unsupported parameter: 'temperature'` — so
172
+ * rather than forward it and break every request, we drop it and (once
173
+ * per process, only with MOXXY_DEBUG set) say so instead of silently
174
+ * eating the option.
175
+ */
176
+ let temperatureNoteShown = false;
177
+ function noteTemperatureUnsupported(): void {
178
+ if (temperatureNoteShown) return;
179
+ temperatureNoteShown = true;
180
+ if (process.env.MOXXY_DEBUG) {
181
+ console.debug(
182
+ '[openai-codex] ProviderRequest.temperature is not supported by the Codex ' +
183
+ 'Responses backend (gpt-5 reasoning models reject sampling params); ignoring it.',
184
+ );
185
+ }
186
+ }
187
+
188
+ /**
189
+ * One-shot debug note for the unsupported `maxTokens` param. Unlike the
190
+ * platform Responses API, the ChatGPT-plan Codex backend rejects
191
+ * `max_output_tokens` with `400: {"detail":"Unsupported parameter:
192
+ * max_output_tokens"}` — observed live when `workflow_create` passed a draft
193
+ * budget. Same policy as temperature: drop it instead of breaking the request.
194
+ */
195
+ let maxTokensNoteShown = false;
196
+ function noteMaxTokensUnsupported(): void {
197
+ if (maxTokensNoteShown) return;
198
+ maxTokensNoteShown = true;
199
+ if (process.env.MOXXY_DEBUG) {
200
+ console.debug(
201
+ '[openai-codex] ProviderRequest.maxTokens is not supported by the Codex ' +
202
+ 'Responses backend (400 Unsupported parameter: max_output_tokens); ignoring it.',
203
+ );
204
+ }
205
+ }
206
+
207
+ export function toResponsesBody(req: ProviderRequest, opts: BuildBodyOptions = {}): ResponsesBody {
208
+ const instructions = extractSystemText(req.messages, req.system) || DEFAULT_INSTRUCTIONS;
209
+ const body: ResponsesBody = {
210
+ model: req.model,
211
+ instructions,
212
+ input: toResponsesInput(req.messages),
213
+ stream: true,
214
+ store: false,
215
+ parallel_tool_calls: true,
216
+ reasoning: { effort: opts.reasoningEffort ?? 'medium', summary: 'auto' },
217
+ include: ['reasoning.encrypted_content'],
218
+ };
219
+ if (req.tools && req.tools.length > 0) body.tools = toResponsesTools(req.tools);
220
+ if (opts.sessionHint) body.prompt_cache_key = opts.sessionHint;
221
+ if (req.maxTokens !== undefined) noteMaxTokensUnsupported();
222
+ if (req.temperature !== undefined) noteTemperatureUnsupported();
223
+ return body;
224
+ }