@animalabs/connectome-host 0.3.8 → 0.3.10
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.
- package/.env.example +7 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +26 -0
- package/.github/workflows/changelog.yml +32 -0
- package/.github/workflows/publish.yml +46 -0
- package/CHANGELOG.md +59 -0
- package/CONTRIBUTING.md +126 -0
- package/README.md +52 -5
- package/bun.lock +6 -10
- package/package.json +5 -4
- package/scripts/release-changelog.ts +32 -0
- package/src/codex-subscription-adapter.ts +938 -0
- package/src/commands.ts +45 -0
- package/src/extensions.ts +201 -0
- package/src/framework-agent-config.ts +17 -9
- package/src/framework-strategy.ts +21 -0
- package/src/index.ts +102 -19
- package/src/logging-bedrock-adapter.ts +145 -0
- package/src/modules/web-ui-module.ts +45 -1
- package/src/recipe.ts +140 -43
- package/src/tui.ts +165 -34
- package/src/web/protocol.ts +35 -0
- package/test/codex-subscription-adapter.test.ts +226 -0
- package/test/fast-command.test.ts +39 -0
- package/test/framework-fkm-composition.test.ts +4 -13
- package/test/recipe-extensions.test.ts +295 -0
- package/test/recipe-primary-summary-fallback-rejection.test.ts +24 -0
- package/test/recipe-provider.test.ts +14 -1
- package/test/web-ui-observers.test.ts +14 -0
- package/test/web-ui-protocol.test.ts +0 -0
- package/web/src/App.tsx +235 -20
- package/web/src/Branches.tsx +150 -0
- package/web/src/Context.tsx +21 -13
- package/web/src/Health.tsx +274 -0
- package/web/src/Lessons.tsx +2 -1
- package/test/recipe-primary-summary-fallback.test.ts +0 -40
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT-subscription-backed OpenAI Responses adapter.
|
|
3
|
+
*
|
|
4
|
+
* Authentication is deliberately delegated to `codex app-server`: it owns
|
|
5
|
+
* ChatGPT OAuth, refresh-token rotation, and the device-code login ceremony.
|
|
6
|
+
* The resulting access token and account id are read from CODEX_HOME/auth.json
|
|
7
|
+
* and used only for the Codex subscription Responses transport.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
11
|
+
import { readFile } from 'node:fs/promises';
|
|
12
|
+
import { homedir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { createInterface, type Interface as ReadLineInterface } from 'node:readline';
|
|
15
|
+
import {
|
|
16
|
+
MembraneError,
|
|
17
|
+
authError,
|
|
18
|
+
contextLengthError,
|
|
19
|
+
networkError,
|
|
20
|
+
rateLimitError,
|
|
21
|
+
serverError,
|
|
22
|
+
type ProviderAdapter,
|
|
23
|
+
type ProviderRequest,
|
|
24
|
+
type ProviderRequestOptions,
|
|
25
|
+
type ProviderResponse,
|
|
26
|
+
type StreamCallbacks,
|
|
27
|
+
} from '@animalabs/membrane';
|
|
28
|
+
|
|
29
|
+
type JsonObject = Record<string, unknown>;
|
|
30
|
+
|
|
31
|
+
interface CodexOutputItem extends JsonObject {
|
|
32
|
+
type: string;
|
|
33
|
+
id?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface CodexResponse extends JsonObject {
|
|
37
|
+
model?: string;
|
|
38
|
+
status?: string;
|
|
39
|
+
output?: CodexOutputItem[];
|
|
40
|
+
incomplete_details?: { reason?: string | null } | null;
|
|
41
|
+
error?: { code?: string | null; message?: string | null } | null;
|
|
42
|
+
usage?: {
|
|
43
|
+
input_tokens?: number;
|
|
44
|
+
output_tokens?: number;
|
|
45
|
+
input_tokens_details?: { cached_tokens?: number } | null;
|
|
46
|
+
} | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface RpcResponse {
|
|
50
|
+
id?: number;
|
|
51
|
+
method?: string;
|
|
52
|
+
params?: JsonObject;
|
|
53
|
+
result?: JsonObject;
|
|
54
|
+
error?: { code?: number; message?: string; data?: unknown };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface PendingRequest {
|
|
58
|
+
resolve(value: JsonObject): void;
|
|
59
|
+
reject(error: Error): void;
|
|
60
|
+
timer: ReturnType<typeof setTimeout>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface NotificationWaiter {
|
|
64
|
+
method: string;
|
|
65
|
+
predicate?: (params: JsonObject) => boolean;
|
|
66
|
+
resolve(params: JsonObject): void;
|
|
67
|
+
reject(error: Error): void;
|
|
68
|
+
timer: ReturnType<typeof setTimeout>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CodexAuthProvider {
|
|
72
|
+
getAccessToken(forceRefresh?: boolean): Promise<string>;
|
|
73
|
+
getAccountId?(): string | undefined;
|
|
74
|
+
dispose?(): void;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface CodexAppServerAuthConfig {
|
|
78
|
+
codexBinary?: string;
|
|
79
|
+
codexHome?: string;
|
|
80
|
+
loginTimeoutMs?: number;
|
|
81
|
+
onLoginRequired?: (details: { verificationUrl: string; userCode: string }) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Minimal JSON-RPC client for the documented Codex app-server auth surface.
|
|
86
|
+
* It forces file credential storage because the inference adapter needs to
|
|
87
|
+
* read the refreshed access token; permissions on auth.json remain Codex's.
|
|
88
|
+
*/
|
|
89
|
+
export class CodexAppServerAuth implements CodexAuthProvider {
|
|
90
|
+
private readonly codexBinary: string;
|
|
91
|
+
private readonly codexHome: string;
|
|
92
|
+
private readonly loginTimeoutMs: number;
|
|
93
|
+
private readonly onLoginRequired: (details: { verificationUrl: string; userCode: string }) => void;
|
|
94
|
+
private child: ChildProcessWithoutNullStreams | null = null;
|
|
95
|
+
private lines: ReadLineInterface | null = null;
|
|
96
|
+
private nextId = 1;
|
|
97
|
+
private pending = new Map<number, PendingRequest>();
|
|
98
|
+
private waiters = new Set<NotificationWaiter>();
|
|
99
|
+
private startPromise: Promise<void> | null = null;
|
|
100
|
+
private authPromise: Promise<string> | null = null;
|
|
101
|
+
private stderrTail = '';
|
|
102
|
+
private accountId: string | undefined;
|
|
103
|
+
|
|
104
|
+
constructor(config: CodexAppServerAuthConfig = {}) {
|
|
105
|
+
this.codexBinary = config.codexBinary ?? process.env.CODEX_BINARY ?? 'codex';
|
|
106
|
+
this.codexHome = expandHome(config.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), '.codex'));
|
|
107
|
+
this.loginTimeoutMs = config.loginTimeoutMs ?? 10 * 60_000;
|
|
108
|
+
this.onLoginRequired = config.onLoginRequired ?? (({ verificationUrl, userCode }) => {
|
|
109
|
+
console.error('\nOpenAI Codex subscription login required.');
|
|
110
|
+
console.error(`Open ${verificationUrl} and enter code: ${userCode}\n`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async getAccessToken(forceRefresh = false): Promise<string> {
|
|
115
|
+
if (this.authPromise) return this.authPromise;
|
|
116
|
+
this.authPromise = this.authenticate(forceRefresh).finally(() => {
|
|
117
|
+
this.authPromise = null;
|
|
118
|
+
});
|
|
119
|
+
return this.authPromise;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
getAccountId(): string | undefined {
|
|
123
|
+
return this.accountId;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
dispose(): void {
|
|
127
|
+
this.lines?.close();
|
|
128
|
+
this.lines = null;
|
|
129
|
+
this.child?.kill();
|
|
130
|
+
this.child = null;
|
|
131
|
+
const error = new Error('Codex app-server stopped');
|
|
132
|
+
for (const request of this.pending.values()) {
|
|
133
|
+
clearTimeout(request.timer);
|
|
134
|
+
request.reject(error);
|
|
135
|
+
}
|
|
136
|
+
this.pending.clear();
|
|
137
|
+
for (const waiter of this.waiters) {
|
|
138
|
+
clearTimeout(waiter.timer);
|
|
139
|
+
waiter.reject(error);
|
|
140
|
+
}
|
|
141
|
+
this.waiters.clear();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private async authenticate(forceRefresh: boolean): Promise<string> {
|
|
145
|
+
await this.ensureStarted();
|
|
146
|
+
let account = await this.readAccount(forceRefresh);
|
|
147
|
+
|
|
148
|
+
if (account?.type !== 'chatgpt') {
|
|
149
|
+
const login = await this.request('account/login/start', { type: 'chatgptDeviceCode' });
|
|
150
|
+
const loginId = asString(login.loginId);
|
|
151
|
+
const verificationUrl = asString(login.verificationUrl);
|
|
152
|
+
const userCode = asString(login.userCode);
|
|
153
|
+
if (!loginId || !verificationUrl || !userCode) {
|
|
154
|
+
throw new Error('Codex app-server returned an incomplete device-code login response');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.onLoginRequired({ verificationUrl, userCode });
|
|
158
|
+
const completed = await this.waitForNotification(
|
|
159
|
+
'account/login/completed',
|
|
160
|
+
(params) => params.loginId === loginId,
|
|
161
|
+
this.loginTimeoutMs,
|
|
162
|
+
);
|
|
163
|
+
if (completed.success !== true) {
|
|
164
|
+
throw new Error(`OpenAI Codex login failed: ${asString(completed.error) || 'unknown error'}`);
|
|
165
|
+
}
|
|
166
|
+
account = await this.readAccount(true);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (account?.type !== 'chatgpt') {
|
|
170
|
+
throw new Error('OpenAI Codex subscription login did not produce a ChatGPT account');
|
|
171
|
+
}
|
|
172
|
+
return this.readAccessToken();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async readAccount(refreshToken: boolean): Promise<JsonObject | null> {
|
|
176
|
+
const result = await this.request('account/read', { refreshToken });
|
|
177
|
+
const account = result.account;
|
|
178
|
+
return account && typeof account === 'object' && !Array.isArray(account)
|
|
179
|
+
? account as JsonObject
|
|
180
|
+
: null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private async readAccessToken(): Promise<string> {
|
|
184
|
+
const path = join(this.codexHome, 'auth.json');
|
|
185
|
+
let parsed: unknown;
|
|
186
|
+
try {
|
|
187
|
+
parsed = JSON.parse(await readFile(path, 'utf8'));
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`Codex login succeeded but ${path} could not be read. ` +
|
|
191
|
+
`Ensure cli_auth_credentials_store is set to \"file\".`,
|
|
192
|
+
{ cause: error },
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
const root = parsed as { tokens?: { access_token?: unknown; account_id?: unknown } };
|
|
196
|
+
const token = root?.tokens?.access_token;
|
|
197
|
+
if (typeof token !== 'string' || token.length === 0) {
|
|
198
|
+
throw new Error(`Codex credential file ${path} does not contain tokens.access_token`);
|
|
199
|
+
}
|
|
200
|
+
this.accountId = typeof root.tokens?.account_id === 'string'
|
|
201
|
+
? root.tokens.account_id
|
|
202
|
+
: undefined;
|
|
203
|
+
return token;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private async ensureStarted(): Promise<void> {
|
|
207
|
+
if (this.child && !this.child.killed) return;
|
|
208
|
+
if (this.startPromise) return this.startPromise;
|
|
209
|
+
this.startPromise = this.start().finally(() => {
|
|
210
|
+
this.startPromise = null;
|
|
211
|
+
});
|
|
212
|
+
return this.startPromise;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private async start(): Promise<void> {
|
|
216
|
+
const child = spawn(this.codexBinary, [
|
|
217
|
+
'app-server',
|
|
218
|
+
'--listen',
|
|
219
|
+
'stdio://',
|
|
220
|
+
'-c',
|
|
221
|
+
'cli_auth_credentials_store="file"',
|
|
222
|
+
], {
|
|
223
|
+
env: { ...process.env, CODEX_HOME: this.codexHome },
|
|
224
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
225
|
+
});
|
|
226
|
+
this.child = child;
|
|
227
|
+
this.stderrTail = '';
|
|
228
|
+
child.stderr.setEncoding('utf8');
|
|
229
|
+
child.stderr.on('data', (chunk: string) => {
|
|
230
|
+
this.stderrTail = (this.stderrTail + chunk).slice(-4000);
|
|
231
|
+
});
|
|
232
|
+
child.once('error', (error) => this.failAll(error));
|
|
233
|
+
child.once('exit', (code, signal) => {
|
|
234
|
+
const suffix = this.stderrTail.trim() ? `: ${this.stderrTail.trim()}` : '';
|
|
235
|
+
this.failAll(new Error(`Codex app-server exited (${signal ?? code ?? 'unknown'})${suffix}`));
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
this.lines = createInterface({ input: child.stdout });
|
|
239
|
+
this.lines.on('line', (line) => this.handleLine(line));
|
|
240
|
+
|
|
241
|
+
await this.request('initialize', {
|
|
242
|
+
clientInfo: {
|
|
243
|
+
name: 'connectome_host',
|
|
244
|
+
title: 'Connectome Host',
|
|
245
|
+
version: '0.3.7',
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
this.notify('initialized', {});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private request(method: string, params: JsonObject, timeoutMs = 30_000): Promise<JsonObject> {
|
|
252
|
+
if (!this.child?.stdin.writable) {
|
|
253
|
+
return Promise.reject(new Error('Codex app-server stdin is unavailable'));
|
|
254
|
+
}
|
|
255
|
+
const id = this.nextId++;
|
|
256
|
+
return new Promise<JsonObject>((resolve, reject) => {
|
|
257
|
+
const timer = setTimeout(() => {
|
|
258
|
+
this.pending.delete(id);
|
|
259
|
+
reject(new Error(`Codex app-server request timed out: ${method}`));
|
|
260
|
+
}, timeoutMs);
|
|
261
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
262
|
+
this.child!.stdin.write(`${JSON.stringify({ method, id, params })}\n`);
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private notify(method: string, params: JsonObject): void {
|
|
267
|
+
this.child?.stdin.write(`${JSON.stringify({ method, params })}\n`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private waitForNotification(
|
|
271
|
+
method: string,
|
|
272
|
+
predicate?: (params: JsonObject) => boolean,
|
|
273
|
+
timeoutMs = 30_000,
|
|
274
|
+
): Promise<JsonObject> {
|
|
275
|
+
return new Promise<JsonObject>((resolve, reject) => {
|
|
276
|
+
const waiter: NotificationWaiter = {
|
|
277
|
+
method,
|
|
278
|
+
predicate,
|
|
279
|
+
resolve: (params) => {
|
|
280
|
+
clearTimeout(waiter.timer);
|
|
281
|
+
this.waiters.delete(waiter);
|
|
282
|
+
resolve(params);
|
|
283
|
+
},
|
|
284
|
+
reject: (error) => {
|
|
285
|
+
clearTimeout(waiter.timer);
|
|
286
|
+
this.waiters.delete(waiter);
|
|
287
|
+
reject(error);
|
|
288
|
+
},
|
|
289
|
+
timer: setTimeout(() => {
|
|
290
|
+
this.waiters.delete(waiter);
|
|
291
|
+
reject(new Error(`Timed out waiting for Codex app-server notification: ${method}`));
|
|
292
|
+
}, timeoutMs),
|
|
293
|
+
};
|
|
294
|
+
this.waiters.add(waiter);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private handleLine(line: string): void {
|
|
299
|
+
let message: RpcResponse;
|
|
300
|
+
try {
|
|
301
|
+
message = JSON.parse(line) as RpcResponse;
|
|
302
|
+
} catch {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (typeof message.id === 'number') {
|
|
307
|
+
const pending = this.pending.get(message.id);
|
|
308
|
+
if (!pending) return;
|
|
309
|
+
this.pending.delete(message.id);
|
|
310
|
+
clearTimeout(pending.timer);
|
|
311
|
+
if (message.error) {
|
|
312
|
+
pending.reject(new Error(
|
|
313
|
+
`Codex app-server error ${message.error.code ?? ''}: ${message.error.message ?? 'unknown error'}`,
|
|
314
|
+
));
|
|
315
|
+
} else {
|
|
316
|
+
pending.resolve(message.result ?? {});
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (!message.method) return;
|
|
322
|
+
const params = message.params ?? {};
|
|
323
|
+
for (const waiter of [...this.waiters]) {
|
|
324
|
+
if (waiter.method === message.method && (!waiter.predicate || waiter.predicate(params))) {
|
|
325
|
+
waiter.resolve(params);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private failAll(error: Error): void {
|
|
331
|
+
this.child = null;
|
|
332
|
+
this.lines?.close();
|
|
333
|
+
this.lines = null;
|
|
334
|
+
for (const request of this.pending.values()) {
|
|
335
|
+
clearTimeout(request.timer);
|
|
336
|
+
request.reject(error);
|
|
337
|
+
}
|
|
338
|
+
this.pending.clear();
|
|
339
|
+
for (const waiter of this.waiters) waiter.reject(error);
|
|
340
|
+
this.waiters.clear();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface CodexSubscriptionAdapterConfig extends CodexAppServerAuthConfig {
|
|
345
|
+
authProvider?: CodexAuthProvider;
|
|
346
|
+
baseURL?: string;
|
|
347
|
+
fastMode?: boolean;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export class CodexSubscriptionAdapter implements ProviderAdapter {
|
|
351
|
+
readonly name = 'openai-codex';
|
|
352
|
+
private readonly auth: CodexAuthProvider;
|
|
353
|
+
private readonly baseURL: string;
|
|
354
|
+
private fastMode: boolean;
|
|
355
|
+
private warnedFastFallback = false;
|
|
356
|
+
|
|
357
|
+
constructor(config: CodexSubscriptionAdapterConfig = {}) {
|
|
358
|
+
this.auth = config.authProvider ?? new CodexAppServerAuth(config);
|
|
359
|
+
this.baseURL = (config.baseURL ?? process.env.CODEX_BASE_URL ?? 'https://chatgpt.com/backend-api/codex')
|
|
360
|
+
.replace(/\/$/, '');
|
|
361
|
+
this.fastMode = config.fastMode ?? false;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
supportsModel(modelId: string): boolean {
|
|
365
|
+
return modelId.startsWith('gpt-') || modelId.includes('codex');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
isFastMode(): boolean {
|
|
369
|
+
return this.fastMode;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
setFastMode(enabled: boolean): void {
|
|
373
|
+
this.fastMode = enabled;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
dispose(): void {
|
|
377
|
+
this.auth.dispose?.();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async complete(
|
|
381
|
+
request: ProviderRequest,
|
|
382
|
+
options?: ProviderRequestOptions,
|
|
383
|
+
): Promise<ProviderResponse> {
|
|
384
|
+
// The subscription endpoint requires SSE even for callers that want one
|
|
385
|
+
// final response, so consume the stream silently and return its result.
|
|
386
|
+
return this.stream(request, { onChunk: () => {} }, options);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async stream(
|
|
390
|
+
request: ProviderRequest,
|
|
391
|
+
callbacks: StreamCallbacks,
|
|
392
|
+
options?: ProviderRequestOptions,
|
|
393
|
+
): Promise<ProviderResponse> {
|
|
394
|
+
let emitted = false;
|
|
395
|
+
const wrappedCallbacks: StreamCallbacks = {
|
|
396
|
+
...callbacks,
|
|
397
|
+
onChunk: (chunk) => {
|
|
398
|
+
emitted = true;
|
|
399
|
+
callbacks.onChunk(chunk);
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
return this.withAuthRetry(
|
|
403
|
+
(token) => this.streamWithToken(token, this.prepareRequest(request), wrappedCallbacks, options),
|
|
404
|
+
() => !emitted,
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Codex subscription transport rejects API-only sampling/output controls. */
|
|
409
|
+
private prepareRequest(request: ProviderRequest): ProviderRequest {
|
|
410
|
+
return {
|
|
411
|
+
...request,
|
|
412
|
+
temperature: undefined,
|
|
413
|
+
topP: undefined,
|
|
414
|
+
topK: undefined,
|
|
415
|
+
extra: {
|
|
416
|
+
...request.extra,
|
|
417
|
+
max_output_tokens: undefined,
|
|
418
|
+
...(this.fastMode ? { service_tier: 'priority' } : { service_tier: undefined }),
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private async streamWithToken(
|
|
424
|
+
accessToken: string,
|
|
425
|
+
request: ProviderRequest,
|
|
426
|
+
callbacks: StreamCallbacks,
|
|
427
|
+
options?: ProviderRequestOptions,
|
|
428
|
+
): Promise<ProviderResponse> {
|
|
429
|
+
const rawRequest = this.buildRequest(request);
|
|
430
|
+
options?.onRequest?.(rawRequest);
|
|
431
|
+
const { signal, cleanup } = combinedSignal(options?.signal, options?.timeoutMs);
|
|
432
|
+
|
|
433
|
+
try {
|
|
434
|
+
const response = await fetch(`${this.baseURL}/responses`, {
|
|
435
|
+
method: 'POST',
|
|
436
|
+
headers: {
|
|
437
|
+
Authorization: `Bearer ${accessToken}`,
|
|
438
|
+
'Content-Type': 'application/json',
|
|
439
|
+
...(this.auth.getAccountId?.() ? {
|
|
440
|
+
'ChatGPT-Account-Id': this.auth.getAccountId!(),
|
|
441
|
+
} : {}),
|
|
442
|
+
},
|
|
443
|
+
body: JSON.stringify(rawRequest),
|
|
444
|
+
signal,
|
|
445
|
+
});
|
|
446
|
+
if (!response.ok) {
|
|
447
|
+
const detail = await response.text();
|
|
448
|
+
throw this.httpError(response.status, detail, rawRequest);
|
|
449
|
+
}
|
|
450
|
+
if (!response.body) throw networkError('Codex subscription response had no body', undefined, rawRequest);
|
|
451
|
+
|
|
452
|
+
const output: CodexOutputItem[] = [];
|
|
453
|
+
let terminal: CodexResponse | undefined;
|
|
454
|
+
const parser = new CodexSseParser((event) => {
|
|
455
|
+
const type = asString(event.type);
|
|
456
|
+
const outputIndex = Number(event.output_index);
|
|
457
|
+
if (type === 'response.output_text.delta') {
|
|
458
|
+
const delta = asString(event.delta);
|
|
459
|
+
if (delta) callbacks.onChunk(delta);
|
|
460
|
+
applyTextDelta(output, outputIndex, event, delta);
|
|
461
|
+
} else if (type === 'response.function_call_arguments.delta') {
|
|
462
|
+
applyArgumentsDelta(output, outputIndex, asString(event.delta));
|
|
463
|
+
} else if (type === 'response.output_item.added' || type === 'response.output_item.done') {
|
|
464
|
+
if (Number.isInteger(outputIndex) && isObject(event.item)) {
|
|
465
|
+
output[outputIndex] = event.item as CodexOutputItem;
|
|
466
|
+
}
|
|
467
|
+
} else if (type === 'response.completed' || type === 'response.incomplete') {
|
|
468
|
+
if (isObject(event.response)) terminal = event.response as CodexResponse;
|
|
469
|
+
} else if (type === 'response.failed') {
|
|
470
|
+
const failed = isObject(event.response) ? event.response as CodexResponse : undefined;
|
|
471
|
+
throw new Error(
|
|
472
|
+
`Codex subscription response failed: ${failed?.error?.code ?? 'response_failed'} ` +
|
|
473
|
+
`${failed?.error?.message ?? 'unknown error'}`,
|
|
474
|
+
);
|
|
475
|
+
} else if (type === 'error') {
|
|
476
|
+
// The Codex backend uses both top-level `{ message, code }` errors
|
|
477
|
+
// and Responses-style `{ error: { message, code, type } }` events.
|
|
478
|
+
// Preserve the nested form so production failures remain actionable.
|
|
479
|
+
const nested = isObject(event.error) ? event.error : undefined;
|
|
480
|
+
const code = asString(event.code) || asString(nested?.code) || asString(nested?.type);
|
|
481
|
+
const message = asString(event.message) || asString(nested?.message) || 'unknown error';
|
|
482
|
+
throw new Error(`Codex subscription stream error${code ? ` (${code})` : ''}: ${message}`);
|
|
483
|
+
}
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
const reader = response.body.getReader();
|
|
487
|
+
const decoder = new TextDecoder();
|
|
488
|
+
while (true) {
|
|
489
|
+
const { done, value } = await reader.read();
|
|
490
|
+
if (done) break;
|
|
491
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
492
|
+
}
|
|
493
|
+
parser.push(decoder.decode());
|
|
494
|
+
parser.finish();
|
|
495
|
+
|
|
496
|
+
if (!terminal) {
|
|
497
|
+
throw networkError('Codex subscription stream ended before a terminal event', undefined, rawRequest);
|
|
498
|
+
}
|
|
499
|
+
if (terminal.error) {
|
|
500
|
+
throw new Error(
|
|
501
|
+
`Codex subscription response error: ${terminal.error.code ?? 'api_error'} ` +
|
|
502
|
+
`${terminal.error.message ?? 'unknown error'}`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Codex commonly sends the authoritative items through
|
|
507
|
+
// response.output_item.done and leaves response.completed.output empty.
|
|
508
|
+
const terminalOutput = Array.isArray(terminal.output) && terminal.output.length > 0
|
|
509
|
+
? terminal.output
|
|
510
|
+
: output.filter(Boolean);
|
|
511
|
+
const content = outputToContent(terminalOutput);
|
|
512
|
+
content.forEach((block, index) => callbacks.onContentBlock?.(index, block));
|
|
513
|
+
const cachedTokens = terminal.usage?.input_tokens_details?.cached_tokens ?? 0;
|
|
514
|
+
const returnedTier = asString(terminal.service_tier);
|
|
515
|
+
if (rawRequest.service_tier === 'priority' && returnedTier && returnedTier !== 'priority' &&
|
|
516
|
+
!this.warnedFastFallback) {
|
|
517
|
+
this.warnedFastFallback = true;
|
|
518
|
+
console.warn(
|
|
519
|
+
`[openai-codex] Fast mode requested, but the service returned tier "${returnedTier}". ` +
|
|
520
|
+
'The current account or backend did not apply Fast mode.',
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
const stopReason = terminalOutput.some((item) => item.type === 'function_call')
|
|
524
|
+
? 'tool_use'
|
|
525
|
+
: terminal.status === 'incomplete' && terminal.incomplete_details?.reason?.includes('max_output_tokens')
|
|
526
|
+
? 'max_tokens'
|
|
527
|
+
: 'end_turn';
|
|
528
|
+
const raw = { ...terminal, output: terminalOutput };
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
content,
|
|
532
|
+
stopReason,
|
|
533
|
+
stopSequence: undefined,
|
|
534
|
+
usage: {
|
|
535
|
+
inputTokens: terminal.usage?.input_tokens ?? 0,
|
|
536
|
+
outputTokens: terminal.usage?.output_tokens ?? 0,
|
|
537
|
+
cacheReadTokens: cachedTokens > 0 ? cachedTokens : undefined,
|
|
538
|
+
},
|
|
539
|
+
model: terminal.model ?? request.model,
|
|
540
|
+
rawRequest,
|
|
541
|
+
raw,
|
|
542
|
+
};
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (error instanceof MembraneError) throw error;
|
|
545
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
546
|
+
throw networkError('Codex subscription request aborted', error, rawRequest);
|
|
547
|
+
}
|
|
548
|
+
throw networkError(
|
|
549
|
+
error instanceof Error ? error.message : 'Codex subscription request failed',
|
|
550
|
+
error,
|
|
551
|
+
rawRequest,
|
|
552
|
+
);
|
|
553
|
+
} finally {
|
|
554
|
+
cleanup();
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
private buildRequest(request: ProviderRequest): JsonObject {
|
|
559
|
+
const include = Array.isArray(request.extra?.include)
|
|
560
|
+
? request.extra.include.filter((item): item is string => typeof item === 'string')
|
|
561
|
+
: [];
|
|
562
|
+
const raw: JsonObject = {
|
|
563
|
+
model: request.model,
|
|
564
|
+
input: request.messages,
|
|
565
|
+
store: false,
|
|
566
|
+
stream: true,
|
|
567
|
+
include: include.includes('reasoning.encrypted_content')
|
|
568
|
+
? include
|
|
569
|
+
: [...include, 'reasoning.encrypted_content'],
|
|
570
|
+
};
|
|
571
|
+
const instructions = flattenInstructions(request.system);
|
|
572
|
+
if (instructions) raw.instructions = instructions;
|
|
573
|
+
if (request.tools?.length) raw.tools = request.tools.map(convertTool);
|
|
574
|
+
if (request.extra) {
|
|
575
|
+
const {
|
|
576
|
+
normalizedMessages,
|
|
577
|
+
prompt,
|
|
578
|
+
messages,
|
|
579
|
+
input,
|
|
580
|
+
store,
|
|
581
|
+
stream,
|
|
582
|
+
include: ignoredInclude,
|
|
583
|
+
...extra
|
|
584
|
+
} = request.extra;
|
|
585
|
+
void normalizedMessages;
|
|
586
|
+
void prompt;
|
|
587
|
+
void messages;
|
|
588
|
+
void input;
|
|
589
|
+
void store;
|
|
590
|
+
void stream;
|
|
591
|
+
void ignoredInclude;
|
|
592
|
+
Object.assign(raw, extra);
|
|
593
|
+
}
|
|
594
|
+
raw.input = normalizeResponsesInput(request.messages);
|
|
595
|
+
raw.store = false;
|
|
596
|
+
raw.stream = true;
|
|
597
|
+
raw.include = include.includes('reasoning.encrypted_content')
|
|
598
|
+
? include
|
|
599
|
+
: [...include, 'reasoning.encrypted_content'];
|
|
600
|
+
return raw;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
private httpError(status: number, detail: string, rawRequest: unknown): MembraneError {
|
|
604
|
+
const message = `Codex subscription API error: ${status} ${detail}`;
|
|
605
|
+
if (status === 401 || status === 403) return authError(message, undefined, rawRequest);
|
|
606
|
+
if (status === 429) return rateLimitError(message, undefined, undefined, rawRequest);
|
|
607
|
+
if (status === 400 && /context|token limit|too long/i.test(detail)) {
|
|
608
|
+
return contextLengthError(message, undefined, rawRequest);
|
|
609
|
+
}
|
|
610
|
+
if (status >= 500) return serverError(message, status, undefined, rawRequest);
|
|
611
|
+
return new MembraneError({
|
|
612
|
+
type: 'invalid_request',
|
|
613
|
+
message,
|
|
614
|
+
retryable: false,
|
|
615
|
+
httpStatus: status,
|
|
616
|
+
rawError: detail,
|
|
617
|
+
rawRequest,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
private async withAuthRetry<T>(
|
|
622
|
+
operation: (token: string) => Promise<T>,
|
|
623
|
+
mayRetry: () => boolean = () => true,
|
|
624
|
+
): Promise<T> {
|
|
625
|
+
const token = await this.auth.getAccessToken(false);
|
|
626
|
+
try {
|
|
627
|
+
return await operation(token);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
if (!(error instanceof MembraneError) || error.type !== 'auth' || !mayRetry()) throw error;
|
|
630
|
+
const refreshed = await this.auth.getAccessToken(true);
|
|
631
|
+
return operation(refreshed);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Most agent turns arrive already formatted as provider-native Responses
|
|
638
|
+
* items. Internal maintenance calls, however, can bypass that formatter and
|
|
639
|
+
* carry Membrane's normalized `text`/`image`/tool blocks. Normalize at the
|
|
640
|
+
* final transport boundary so every call shape accepted by ProviderAdapter is
|
|
641
|
+
* valid on the Codex Responses endpoint.
|
|
642
|
+
*/
|
|
643
|
+
function normalizeResponsesInput(messages: ProviderRequest['messages']): unknown[] {
|
|
644
|
+
const output: unknown[] = [];
|
|
645
|
+
|
|
646
|
+
for (const rawMessage of messages as unknown[]) {
|
|
647
|
+
if (!isObject(rawMessage)) {
|
|
648
|
+
output.push(rawMessage);
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (rawMessage.type !== 'message' && rawMessage.role === undefined) {
|
|
652
|
+
output.push(normalizeStandaloneItem(rawMessage));
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const role = rawMessage.role === 'assistant' ? 'assistant' : 'user';
|
|
657
|
+
const blocks = Array.isArray(rawMessage.content)
|
|
658
|
+
? rawMessage.content
|
|
659
|
+
: typeof rawMessage.content === 'string'
|
|
660
|
+
? [{ type: 'text', text: rawMessage.content }]
|
|
661
|
+
: [];
|
|
662
|
+
let parts: unknown[] = [];
|
|
663
|
+
const flush = () => {
|
|
664
|
+
if (parts.length === 0) return;
|
|
665
|
+
output.push({
|
|
666
|
+
type: 'message',
|
|
667
|
+
...(typeof rawMessage.id === 'string' ? { id: rawMessage.id } : {}),
|
|
668
|
+
role,
|
|
669
|
+
content: parts,
|
|
670
|
+
});
|
|
671
|
+
parts = [];
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
for (const rawBlock of blocks) {
|
|
675
|
+
if (!isObject(rawBlock)) continue;
|
|
676
|
+
if (rawBlock.type === 'text') {
|
|
677
|
+
parts.push({ type: role === 'assistant' ? 'output_text' : 'input_text', text: asString(rawBlock.text) });
|
|
678
|
+
} else if (rawBlock.type === 'image') {
|
|
679
|
+
const imageUrl = responsesImageUrl(rawBlock);
|
|
680
|
+
if (imageUrl && role !== 'assistant') parts.push({ type: 'input_image', image_url: imageUrl });
|
|
681
|
+
} else if (rawBlock.type === 'tool_use') {
|
|
682
|
+
flush();
|
|
683
|
+
output.push(normalizeStandaloneItem(rawBlock));
|
|
684
|
+
} else if (rawBlock.type === 'tool_result') {
|
|
685
|
+
flush();
|
|
686
|
+
output.push(normalizeStandaloneItem(rawBlock));
|
|
687
|
+
} else if (rawBlock.type === 'redacted_thinking') {
|
|
688
|
+
flush();
|
|
689
|
+
output.push(reasoningInputItem(rawBlock));
|
|
690
|
+
} else {
|
|
691
|
+
// Already-native input_text/output_text/input_image/refusal parts.
|
|
692
|
+
parts.push(rawBlock);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
flush();
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return output;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function normalizeStandaloneItem(item: JsonObject): unknown {
|
|
702
|
+
if (item.type === 'tool_use') {
|
|
703
|
+
return {
|
|
704
|
+
type: 'function_call',
|
|
705
|
+
call_id: asString(item.id),
|
|
706
|
+
name: asString(item.name),
|
|
707
|
+
arguments: JSON.stringify(isObject(item.input) ? item.input : {}),
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (item.type === 'tool_result') {
|
|
711
|
+
const content = item.content;
|
|
712
|
+
return {
|
|
713
|
+
type: 'function_call_output',
|
|
714
|
+
call_id: asString(item.toolUseId) || asString(item.tool_use_id),
|
|
715
|
+
output: typeof content === 'string' ? content : JSON.stringify(content ?? null),
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
if (item.type === 'redacted_thinking') {
|
|
719
|
+
return reasoningInputItem(item);
|
|
720
|
+
}
|
|
721
|
+
return item;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/** Replay a captured reasoning carrier as a Responses input item.
|
|
725
|
+
*
|
|
726
|
+
* Prefer the provider-native item verbatim when the block still carries it
|
|
727
|
+
* (`rawItem` from response parsing). Otherwise reconstruct the minimum the
|
|
728
|
+
* Responses API accepts: `summary` is a REQUIRED field on reasoning input
|
|
729
|
+
* items (empty array = "no summaries") — omitting it 400s with
|
|
730
|
+
* "Missing required parameter: 'input[N].summary'". */
|
|
731
|
+
function reasoningInputItem(block: JsonObject): unknown {
|
|
732
|
+
const raw = block.rawItem;
|
|
733
|
+
if (isObject(raw) && raw.type === 'reasoning') return raw;
|
|
734
|
+
return { type: 'reasoning', summary: [], encrypted_content: asString(block.data) };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function responsesImageUrl(block: JsonObject): string | undefined {
|
|
738
|
+
const source = isObject(block.source) ? block.source : undefined;
|
|
739
|
+
if (!source) return typeof block.image_url === 'string' ? block.image_url : undefined;
|
|
740
|
+
if (source.type === 'url') return asString(source.url) || undefined;
|
|
741
|
+
if (source.type !== 'base64') return undefined;
|
|
742
|
+
const mediaType = asString(source.mediaType) || asString(source.media_type) || 'image/png';
|
|
743
|
+
const data = asString(source.data);
|
|
744
|
+
return data ? `data:${mediaType};base64,${data}` : undefined;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function asString(value: unknown): string {
|
|
748
|
+
return typeof value === 'string' ? value : '';
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function expandHome(path: string): string {
|
|
752
|
+
if (path === '~') return homedir();
|
|
753
|
+
return path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function isObject(value: unknown): value is JsonObject {
|
|
757
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function flattenInstructions(system: ProviderRequest['system']): string | undefined {
|
|
761
|
+
if (typeof system === 'string') return system || undefined;
|
|
762
|
+
if (!Array.isArray(system)) return undefined;
|
|
763
|
+
const text = system
|
|
764
|
+
.map((block) => isObject(block) &&
|
|
765
|
+
(block.type === 'text' || block.type === 'input_text') ? asString(block.text) : '')
|
|
766
|
+
.filter(Boolean)
|
|
767
|
+
.join('\n');
|
|
768
|
+
return text || undefined;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function convertTool(raw: unknown): unknown {
|
|
772
|
+
if (!isObject(raw)) return raw;
|
|
773
|
+
if (raw.type === 'function' && typeof raw.name === 'string') return raw;
|
|
774
|
+
if (raw.type === 'function' && isObject(raw.function)) {
|
|
775
|
+
return { type: 'function', ...raw.function };
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
type: 'function',
|
|
779
|
+
name: raw.name,
|
|
780
|
+
description: raw.description,
|
|
781
|
+
parameters: raw.parameters ?? raw.inputSchema ?? raw.input_schema ?? {
|
|
782
|
+
type: 'object',
|
|
783
|
+
properties: {},
|
|
784
|
+
},
|
|
785
|
+
...(raw.strict !== undefined ? { strict: raw.strict } : {}),
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function applyTextDelta(
|
|
790
|
+
output: CodexOutputItem[],
|
|
791
|
+
outputIndex: number,
|
|
792
|
+
event: JsonObject,
|
|
793
|
+
delta: string,
|
|
794
|
+
): void {
|
|
795
|
+
if (!Number.isInteger(outputIndex) || !delta) return;
|
|
796
|
+
const contentIndex = Number.isInteger(Number(event.content_index)) ? Number(event.content_index) : 0;
|
|
797
|
+
const existing = output[outputIndex];
|
|
798
|
+
const message: CodexOutputItem = existing?.type === 'message'
|
|
799
|
+
? existing
|
|
800
|
+
: {
|
|
801
|
+
type: 'message',
|
|
802
|
+
id: asString(event.item_id) || undefined,
|
|
803
|
+
role: 'assistant',
|
|
804
|
+
status: 'in_progress',
|
|
805
|
+
content: [],
|
|
806
|
+
};
|
|
807
|
+
const content = Array.isArray(message.content) ? message.content as JsonObject[] : [];
|
|
808
|
+
const part = isObject(content[contentIndex])
|
|
809
|
+
? content[contentIndex]
|
|
810
|
+
: { type: 'output_text', text: '', annotations: [] };
|
|
811
|
+
part.text = `${asString(part.text)}${delta}`;
|
|
812
|
+
content[contentIndex] = part;
|
|
813
|
+
message.content = content;
|
|
814
|
+
output[outputIndex] = message;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function applyArgumentsDelta(output: CodexOutputItem[], outputIndex: number, delta: string): void {
|
|
818
|
+
if (!Number.isInteger(outputIndex) || !delta) return;
|
|
819
|
+
const item = output[outputIndex];
|
|
820
|
+
if (item?.type === 'function_call') item.arguments = `${asString(item.arguments)}${delta}`;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function outputToContent(items: CodexOutputItem[]): Array<Record<string, unknown>> {
|
|
824
|
+
const content: Array<Record<string, unknown>> = [];
|
|
825
|
+
items.forEach((item, outputIndex) => {
|
|
826
|
+
if (item.type === 'message') {
|
|
827
|
+
const parts = Array.isArray(item.content) ? item.content : [];
|
|
828
|
+
parts.forEach((part, contentIndex) => {
|
|
829
|
+
if (!isObject(part)) return;
|
|
830
|
+
if (part.type === 'output_text' && typeof part.text === 'string') {
|
|
831
|
+
content.push({
|
|
832
|
+
type: 'text', text: part.text, itemId: item.id, outputIndex, contentIndex, rawItem: item,
|
|
833
|
+
});
|
|
834
|
+
} else if (part.type === 'refusal' && typeof part.refusal === 'string') {
|
|
835
|
+
content.push({
|
|
836
|
+
type: 'text', text: part.refusal, itemId: item.id, outputIndex, contentIndex, rawItem: item,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
} else if (item.type === 'reasoning') {
|
|
841
|
+
if (typeof item.encrypted_content === 'string') {
|
|
842
|
+
content.push({
|
|
843
|
+
type: 'redacted_thinking', data: item.encrypted_content, itemId: item.id, outputIndex, rawItem: item,
|
|
844
|
+
});
|
|
845
|
+
} else {
|
|
846
|
+
content.push({ type: 'thinking', thinking: '', itemId: item.id, outputIndex, rawItem: item });
|
|
847
|
+
}
|
|
848
|
+
} else if (item.type === 'function_call') {
|
|
849
|
+
content.push({
|
|
850
|
+
type: 'tool_use',
|
|
851
|
+
id: typeof item.call_id === 'string' ? item.call_id : item.id ?? '',
|
|
852
|
+
name: asString(item.name),
|
|
853
|
+
input: safeJson(asString(item.arguments) || '{}'),
|
|
854
|
+
itemId: item.id,
|
|
855
|
+
outputIndex,
|
|
856
|
+
rawItem: item,
|
|
857
|
+
});
|
|
858
|
+
} else if (item.type === 'function_call_output') {
|
|
859
|
+
content.push({
|
|
860
|
+
type: 'tool_result',
|
|
861
|
+
toolUseId: asString(item.call_id),
|
|
862
|
+
content: typeof item.output === 'string' ? item.output : JSON.stringify(item.output ?? null),
|
|
863
|
+
itemId: item.id,
|
|
864
|
+
outputIndex,
|
|
865
|
+
rawItem: item,
|
|
866
|
+
});
|
|
867
|
+
} else {
|
|
868
|
+
content.push({
|
|
869
|
+
type: 'openai_response_item', itemId: item.id, itemType: item.type, outputIndex, rawItem: item,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
return content;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function safeJson(value: string): unknown {
|
|
877
|
+
try {
|
|
878
|
+
return JSON.parse(value);
|
|
879
|
+
} catch {
|
|
880
|
+
return {};
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function combinedSignal(
|
|
885
|
+
external?: AbortSignal,
|
|
886
|
+
timeoutMs?: number,
|
|
887
|
+
): { signal: AbortSignal | undefined; cleanup(): void } {
|
|
888
|
+
const controller = timeoutMs ? new AbortController() : undefined;
|
|
889
|
+
const timer = controller && timeoutMs
|
|
890
|
+
? setTimeout(() => controller.abort(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)
|
|
891
|
+
: undefined;
|
|
892
|
+
const signals = [external, controller?.signal].filter((signal): signal is AbortSignal => Boolean(signal));
|
|
893
|
+
return {
|
|
894
|
+
signal: signals.length === 0 ? undefined : signals.length === 1 ? signals[0] : AbortSignal.any(signals),
|
|
895
|
+
cleanup: () => { if (timer) clearTimeout(timer); },
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
class CodexSseParser {
|
|
900
|
+
private buffer = '';
|
|
901
|
+
private data: string[] = [];
|
|
902
|
+
|
|
903
|
+
constructor(private readonly onEvent: (event: JsonObject) => void) {}
|
|
904
|
+
|
|
905
|
+
push(chunk: string): void {
|
|
906
|
+
this.buffer += chunk;
|
|
907
|
+
while (true) {
|
|
908
|
+
const newline = this.buffer.indexOf('\n');
|
|
909
|
+
if (newline < 0) return;
|
|
910
|
+
const line = this.buffer.slice(0, newline).replace(/\r$/, '');
|
|
911
|
+
this.buffer = this.buffer.slice(newline + 1);
|
|
912
|
+
this.processLine(line);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
finish(): void {
|
|
917
|
+
if (this.buffer) this.processLine(this.buffer.replace(/\r$/, ''));
|
|
918
|
+
this.buffer = '';
|
|
919
|
+
this.emit();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
private processLine(line: string): void {
|
|
923
|
+
if (line === '') {
|
|
924
|
+
this.emit();
|
|
925
|
+
} else if (line.startsWith('data:')) {
|
|
926
|
+
this.data.push(line.slice(5).replace(/^ /, ''));
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
private emit(): void {
|
|
931
|
+
if (this.data.length === 0) return;
|
|
932
|
+
const raw = this.data.join('\n');
|
|
933
|
+
this.data = [];
|
|
934
|
+
if (raw === '[DONE]') return;
|
|
935
|
+
const parsed = safeJson(raw);
|
|
936
|
+
if (isObject(parsed)) this.onEvent(parsed);
|
|
937
|
+
}
|
|
938
|
+
}
|