@pure01fx/dsh-openai-codex-auth 0.5.0 → 0.6.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.
- package/CHANGELOG.md +50 -0
- package/README.md +33 -5
- package/client.js +186 -16
- package/cordis.patch.yml +2 -6
- package/lib/catalog.d.ts +77 -0
- package/lib/catalog.js +383 -0
- package/lib/endpoint.d.ts +2 -0
- package/lib/endpoint.js +38 -0
- package/lib/index.d.ts +63 -17
- package/lib/index.js +535 -130
- package/lib/native-adapter.d.ts +43 -0
- package/lib/native-adapter.js +246 -0
- package/lib/native-http.d.ts +61 -0
- package/lib/native-http.js +602 -0
- package/lib/native-websocket-session.d.ts +14 -0
- package/lib/native-websocket-session.js +109 -0
- package/lib/native-websocket-socket.d.ts +28 -0
- package/lib/native-websocket-socket.js +215 -0
- package/lib/native-websocket.d.ts +46 -0
- package/lib/native-websocket.js +628 -0
- package/lib/rate-limits.d.ts +35 -0
- package/lib/rate-limits.js +189 -0
- package/lib/replay.d.ts +49 -0
- package/lib/replay.js +240 -0
- package/lib/response-usage.d.ts +14 -0
- package/lib/response-usage.js +35 -0
- package/lib/responses.d.ts +126 -0
- package/lib/responses.js +572 -0
- package/lib/sse.d.ts +13 -0
- package/lib/sse.js +81 -0
- package/lib/upstream.d.ts +6 -0
- package/lib/upstream.js +6 -0
- package/lib/usage.d.ts +29 -0
- package/lib/usage.js +160 -0
- package/package.json +50 -15
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
/** WebSocket v2 Responses transport with bounded session state and sticky HTTP fallback. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { LlmError, ProviderRequestId, attributionHeaders, } from '@deepseek-ai/dsh-llm';
|
|
4
|
+
import { nativeCodexAuthorityHash } from './catalog.js';
|
|
5
|
+
import { NATIVE_CODEX_CONNECTION_FAILED_CODE, NATIVE_CODEX_STREAM_INTERRUPTED_CODE, isNativeCodexConnectionFailure, } from './native-adapter.js';
|
|
6
|
+
import { NativeCodexHttpTransport, } from './native-http.js';
|
|
7
|
+
import { replayableItemId } from './replay.js';
|
|
8
|
+
import { parseCodexResponseUsageMetadata, publishCodexResponseUsage, } from './response-usage.js';
|
|
9
|
+
import { parseCodexRateLimitEvent, parseCodexRateLimitHeaders, publishCodexRateLimits, } from './rate-limits.js';
|
|
10
|
+
import { ResponsesStreamTranslator, codexResponseTurnState, } from './responses.js';
|
|
11
|
+
import { NodeNativeCodexWebSocketFactory, } from './native-websocket-socket.js';
|
|
12
|
+
import { NativeCodexWebSocketSessionState } from './native-websocket-session.js';
|
|
13
|
+
const WS_BETA = 'responses_websockets=2026-02-06';
|
|
14
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 30_000;
|
|
15
|
+
const DEFAULT_MAX_FRAME_BYTES = 1024 * 1024;
|
|
16
|
+
const DEFAULT_MAX_SESSIONS = 32;
|
|
17
|
+
const DEFAULT_SESSION_IDLE_MS = 30 * 60_000;
|
|
18
|
+
const DEFAULT_MAX_RECONNECTS = 5;
|
|
19
|
+
const DEFAULT_INITIAL_RETRY_DELAY_MS = 200;
|
|
20
|
+
const DEFAULT_MAX_RETRY_DELAY_MS = 10_000;
|
|
21
|
+
const INITIAL_CONNECTION_RETRY_DELAY_MS = 5_000;
|
|
22
|
+
const MAX_CONNECTION_RETRY_DELAY_MS = 60_000;
|
|
23
|
+
const MAX_TURN_STATE_BYTES = 4096;
|
|
24
|
+
const MAX_EVENTS_PER_RESPONSE = 4096;
|
|
25
|
+
const MAX_OUTPUT_ITEMS_PER_RESPONSE = 2048;
|
|
26
|
+
const MAX_RESPONSE_BYTES = 24 * 1024 * 1024;
|
|
27
|
+
function failure(message, code, cause) {
|
|
28
|
+
return new LlmError(message, code, cause === undefined ? undefined : { cause });
|
|
29
|
+
}
|
|
30
|
+
function reconnectable(code) {
|
|
31
|
+
return [
|
|
32
|
+
'WS_RETRYABLE', 'WS_RETRYABLE_RESET', 'WS_PROTOCOL_ERROR',
|
|
33
|
+
'WS_FRAME_TOO_LARGE', 'WS_RESPONSE_TOO_LARGE', 'TIMEOUT',
|
|
34
|
+
].includes(code);
|
|
35
|
+
}
|
|
36
|
+
function failedStepRetryable(code) {
|
|
37
|
+
return ['WS_RETRYABLE', 'WS_RETRYABLE_RESET', 'TIMEOUT'].includes(code);
|
|
38
|
+
}
|
|
39
|
+
function failedStepRetry(error) {
|
|
40
|
+
const facts = error.failure;
|
|
41
|
+
const providerRetryAfterMs = facts.providerRetryAfterMs === undefined
|
|
42
|
+
? undefined : Math.min(facts.providerRetryAfterMs, DEFAULT_MAX_RETRY_DELAY_MS);
|
|
43
|
+
return new LlmError(`native Codex response stream was interrupted: ${error.message}`, NATIVE_CODEX_STREAM_INTERRUPTED_CODE, {
|
|
44
|
+
cause: error,
|
|
45
|
+
...(facts.status === undefined ? {} : { status: facts.status }),
|
|
46
|
+
...(providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs }),
|
|
47
|
+
...(facts.requestId === undefined ? {} : { requestId: facts.requestId }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
function positive(value, fallback, label) {
|
|
51
|
+
const resolved = value ?? fallback;
|
|
52
|
+
if (!Number.isSafeInteger(resolved) || resolved <= 0) {
|
|
53
|
+
throw failure(`native Codex ${label} is invalid`, 'INVALID_ARGS');
|
|
54
|
+
}
|
|
55
|
+
return resolved;
|
|
56
|
+
}
|
|
57
|
+
function boundedPositive(value, fallback, maximum, label) {
|
|
58
|
+
const resolved = positive(value, fallback, label);
|
|
59
|
+
if (resolved > maximum)
|
|
60
|
+
throw failure(`native Codex ${label} exceeds its maximum`, 'INVALID_ARGS');
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
function retryCount(value) {
|
|
64
|
+
const resolved = value ?? DEFAULT_MAX_RECONNECTS;
|
|
65
|
+
if (!Number.isSafeInteger(resolved) || resolved < 0 || resolved > 100) {
|
|
66
|
+
throw failure('native Codex WebSocket reconnect count is invalid', 'INVALID_ARGS');
|
|
67
|
+
}
|
|
68
|
+
return resolved;
|
|
69
|
+
}
|
|
70
|
+
function sleep(delayMs, signal) {
|
|
71
|
+
if (signal?.aborted) {
|
|
72
|
+
return Promise.reject(failure('native Codex WebSocket retry wait was aborted', 'ABORTED'));
|
|
73
|
+
}
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const timer = setTimeout(done, delayMs);
|
|
76
|
+
const abort = () => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
signal?.removeEventListener('abort', abort);
|
|
79
|
+
const reason = signal?.reason;
|
|
80
|
+
reject(reason instanceof LlmError
|
|
81
|
+
? reason : failure('native Codex WebSocket retry wait was aborted', 'ABORTED'));
|
|
82
|
+
};
|
|
83
|
+
function done() {
|
|
84
|
+
signal?.removeEventListener('abort', abort);
|
|
85
|
+
resolve();
|
|
86
|
+
}
|
|
87
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function socketCredential(credential) {
|
|
91
|
+
return createHash('sha256')
|
|
92
|
+
.update(credential.accountId).update('\0').update(credential.accessToken)
|
|
93
|
+
.digest('base64url');
|
|
94
|
+
}
|
|
95
|
+
function sessionKey(generation, routingId) {
|
|
96
|
+
if (generation.sessionId === undefined)
|
|
97
|
+
return undefined;
|
|
98
|
+
return createHash('sha256').update(routingId).digest('base64url');
|
|
99
|
+
}
|
|
100
|
+
function turnKey(generation) {
|
|
101
|
+
for (let index = generation.messages.length - 1; index >= 0; index--) {
|
|
102
|
+
const message = generation.messages[index];
|
|
103
|
+
if (message?.source.kind === 'user') {
|
|
104
|
+
return createHash('sha256').update(String(message.id)).digest('base64url');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return createHash('sha256')
|
|
108
|
+
.update(`${generation.purpose ?? 'ordinary'}:handbuilt`).digest('base64url');
|
|
109
|
+
}
|
|
110
|
+
function withTurnState(request, turnState) {
|
|
111
|
+
if (turnState === undefined)
|
|
112
|
+
return request;
|
|
113
|
+
const metadata = typeof request.client_metadata === 'object'
|
|
114
|
+
&& request.client_metadata !== null && !Array.isArray(request.client_metadata)
|
|
115
|
+
? request.client_metadata
|
|
116
|
+
: {};
|
|
117
|
+
return {
|
|
118
|
+
...request,
|
|
119
|
+
client_metadata: { ...metadata, 'x-codex-turn-state': turnState },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
function normalizedOutputItem(event) {
|
|
123
|
+
if (event.type !== 'response.output_item.done' || event.item === undefined)
|
|
124
|
+
return undefined;
|
|
125
|
+
const item = event.item;
|
|
126
|
+
const id = replayableItemId(item.id);
|
|
127
|
+
if (item.type === 'message') {
|
|
128
|
+
return {
|
|
129
|
+
type: 'message', ...(id === undefined ? {} : { id }), role: 'assistant',
|
|
130
|
+
content: (item.content ?? []).map(part => ({ type: 'output_text', text: part.text })),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
if (item.type === 'reasoning') {
|
|
134
|
+
return {
|
|
135
|
+
type: 'reasoning', ...(id === undefined ? {} : { id }),
|
|
136
|
+
summary: item.summary ?? [],
|
|
137
|
+
...typeof item.encrypted_content === 'string'
|
|
138
|
+
? { encrypted_content: item.encrypted_content } : {},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (item.type === 'function_call') {
|
|
142
|
+
return {
|
|
143
|
+
type: 'function_call', ...(id === undefined ? {} : { id }),
|
|
144
|
+
call_id: item.call_id, name: item.name, arguments: item.arguments,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
function eventHeader(event, name) {
|
|
150
|
+
if (typeof event.headers !== 'object' || event.headers === null)
|
|
151
|
+
return undefined;
|
|
152
|
+
for (const [key, raw] of Object.entries(event.headers)) {
|
|
153
|
+
if (key.toLowerCase() !== name)
|
|
154
|
+
continue;
|
|
155
|
+
const value = Array.isArray(raw) ? raw[0] : raw;
|
|
156
|
+
return typeof value === 'string' ? value : undefined;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
function eventErrorFacts(event, status) {
|
|
161
|
+
const requestId = eventHeader(event, 'x-request-id');
|
|
162
|
+
const retryAfter = eventHeader(event, 'retry-after');
|
|
163
|
+
const seconds = retryAfter === undefined ? undefined : Number(retryAfter);
|
|
164
|
+
const retryMs = seconds !== undefined && Number.isFinite(seconds) && seconds > 0
|
|
165
|
+
? Math.min(seconds * 1_000, 120_000) : undefined;
|
|
166
|
+
return {
|
|
167
|
+
...(status === undefined ? {} : { status }),
|
|
168
|
+
...(retryMs === undefined ? {} : { providerRetryAfterMs: retryMs }),
|
|
169
|
+
...(requestId === undefined || requestId.length === 0 || requestId.length > 256
|
|
170
|
+
? {} : { requestId: ProviderRequestId(requestId) }),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function eventFailure(event) {
|
|
174
|
+
if (event.type !== 'error')
|
|
175
|
+
return undefined;
|
|
176
|
+
const error = typeof event.error === 'object' && event.error !== null
|
|
177
|
+
? event.error : event;
|
|
178
|
+
const providerCode = typeof error.code === 'string' ? error.code : undefined;
|
|
179
|
+
const rawStatus = event.status ?? event.status_code ?? error.status ?? error.status_code;
|
|
180
|
+
const status = typeof rawStatus === 'number' && Number.isSafeInteger(rawStatus)
|
|
181
|
+
? rawStatus : undefined;
|
|
182
|
+
const facts = eventErrorFacts(event, status);
|
|
183
|
+
if (providerCode === 'websocket_connection_limit_reached'
|
|
184
|
+
|| providerCode === 'previous_response_not_found') {
|
|
185
|
+
return new LlmError('native Codex WebSocket requested a fresh connection', 'WS_RETRYABLE_RESET', facts);
|
|
186
|
+
}
|
|
187
|
+
if (providerCode === 'unauthorized' || providerCode === 'invalid_api_key' || status === 401) {
|
|
188
|
+
return new LlmError('native Codex WebSocket authentication failed', 'WS_AUTH', facts);
|
|
189
|
+
}
|
|
190
|
+
if (status === 403)
|
|
191
|
+
return new LlmError('native Codex WebSocket authorization failed', 'AUTH', facts);
|
|
192
|
+
if (providerCode === 'rate_limit_exceeded' || providerCode === 'usage_limit_reached'
|
|
193
|
+
|| status === 429) {
|
|
194
|
+
return new LlmError('native Codex WebSocket rate limit was reached', 'RATE_LIMITED', facts);
|
|
195
|
+
}
|
|
196
|
+
if (status !== undefined && status >= 500) {
|
|
197
|
+
return new LlmError('native Codex WebSocket service failed', 'WS_RETRYABLE', facts);
|
|
198
|
+
}
|
|
199
|
+
return new LlmError('native Codex WebSocket request was rejected', 'INVALID_REQUEST', facts);
|
|
200
|
+
}
|
|
201
|
+
/** Prefer WebSocket v2; once safe retries exhaust, keep that DSH session on HTTP. */
|
|
202
|
+
export class NativeCodexWebSocketTransport {
|
|
203
|
+
options;
|
|
204
|
+
http;
|
|
205
|
+
factory;
|
|
206
|
+
sessions = new Map();
|
|
207
|
+
connectTimeoutMs;
|
|
208
|
+
idleTimeoutMs;
|
|
209
|
+
maxFrameBytes;
|
|
210
|
+
maxSessions;
|
|
211
|
+
sessionIdleMs;
|
|
212
|
+
maxReconnects;
|
|
213
|
+
initialRetryDelayMs;
|
|
214
|
+
maxRetryDelayMs;
|
|
215
|
+
preparing = new Set();
|
|
216
|
+
active = new Map();
|
|
217
|
+
disposed = false;
|
|
218
|
+
constructor(options) {
|
|
219
|
+
this.options = options;
|
|
220
|
+
this.http = new NativeCodexHttpTransport(options);
|
|
221
|
+
this.factory = options.webSocketFactory ?? new NodeNativeCodexWebSocketFactory();
|
|
222
|
+
this.connectTimeoutMs = boundedPositive(options.webSocketConnectTimeoutMs, 10_000, 120_000, 'WebSocket connect timeout');
|
|
223
|
+
this.idleTimeoutMs = boundedPositive(options.webSocketIdleTimeoutMs, DEFAULT_IDLE_TIMEOUT_MS, 60 * 60_000, 'WebSocket idle timeout');
|
|
224
|
+
this.maxFrameBytes = boundedPositive(options.maxWebSocketFrameBytes, DEFAULT_MAX_FRAME_BYTES, MAX_RESPONSE_BYTES, 'WebSocket frame limit');
|
|
225
|
+
this.maxSessions = boundedPositive(options.maxWebSocketSessions, DEFAULT_MAX_SESSIONS, 256, 'WebSocket session limit');
|
|
226
|
+
this.sessionIdleMs = boundedPositive(options.webSocketSessionIdleMs, DEFAULT_SESSION_IDLE_MS, 24 * 60 * 60_000, 'WebSocket session idle limit');
|
|
227
|
+
this.maxReconnects = retryCount(options.maxWebSocketReconnects);
|
|
228
|
+
this.initialRetryDelayMs = boundedPositive(options.initialRetryDelayMs, DEFAULT_INITIAL_RETRY_DELAY_MS, 120_000, 'WebSocket initial retry delay');
|
|
229
|
+
this.maxRetryDelayMs = boundedPositive(options.maxRetryDelayMs, DEFAULT_MAX_RETRY_DELAY_MS, 120_000, 'WebSocket maximum retry delay');
|
|
230
|
+
}
|
|
231
|
+
retryDelay(retry, error) {
|
|
232
|
+
const providerDelay = error.failure.providerRetryAfterMs;
|
|
233
|
+
if (providerDelay !== undefined)
|
|
234
|
+
return Math.min(providerDelay, this.maxRetryDelayMs);
|
|
235
|
+
const exponential = Math.min(this.initialRetryDelayMs * (2 ** retry), this.maxRetryDelayMs);
|
|
236
|
+
const random = this.options.random?.() ?? Math.random();
|
|
237
|
+
const jitter = 0.9 + Math.max(0, Math.min(1, random)) * 0.2;
|
|
238
|
+
return Math.max(1, Math.round(exponential * jitter));
|
|
239
|
+
}
|
|
240
|
+
async wait(retry, error, signal) {
|
|
241
|
+
const delay = this.retryDelay(retry, error);
|
|
242
|
+
await (this.options.sleep ?? sleep)(delay, signal);
|
|
243
|
+
}
|
|
244
|
+
async waitForConnection(delayMs, signal) {
|
|
245
|
+
this.options.warn?.(`native Codex network is unavailable; reconnecting in ${delayMs}ms`);
|
|
246
|
+
await (this.options.sleep ?? sleep)(delayMs, signal);
|
|
247
|
+
}
|
|
248
|
+
closeEntry(entry) {
|
|
249
|
+
entry.socket?.close();
|
|
250
|
+
entry.socket = undefined;
|
|
251
|
+
entry.socketCredential = undefined;
|
|
252
|
+
entry.protocol.reset();
|
|
253
|
+
}
|
|
254
|
+
prune(now) {
|
|
255
|
+
for (const [key, entry] of this.sessions) {
|
|
256
|
+
if (!entry.busy && now - entry.lastUsed > this.sessionIdleMs) {
|
|
257
|
+
this.closeEntry(entry);
|
|
258
|
+
this.sessions.delete(key);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
while (this.sessions.size >= this.maxSessions) {
|
|
262
|
+
const oldest = [...this.sessions].filter(([, entry]) => !entry.busy)
|
|
263
|
+
.sort((left, right) => left[1].lastUsed - right[1].lastUsed)[0];
|
|
264
|
+
if (oldest === undefined)
|
|
265
|
+
break;
|
|
266
|
+
this.closeEntry(oldest[1]);
|
|
267
|
+
this.sessions.delete(oldest[0]);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
entry(key) {
|
|
271
|
+
const now = Date.now();
|
|
272
|
+
if (key === undefined)
|
|
273
|
+
return {
|
|
274
|
+
socket: undefined, socketCredential: undefined,
|
|
275
|
+
protocol: new NativeCodexWebSocketSessionState(),
|
|
276
|
+
disabled: false, prewarmAttempted: false, prewarmSucceeded: false, busy: false,
|
|
277
|
+
turnKey: undefined, turnState: undefined, lastUsed: now, pooled: false,
|
|
278
|
+
};
|
|
279
|
+
const existing = this.sessions.get(key);
|
|
280
|
+
if (existing !== undefined && (existing.busy || now - existing.lastUsed <= this.sessionIdleMs)) {
|
|
281
|
+
return existing;
|
|
282
|
+
}
|
|
283
|
+
if (existing !== undefined) {
|
|
284
|
+
this.closeEntry(existing);
|
|
285
|
+
this.sessions.delete(key);
|
|
286
|
+
}
|
|
287
|
+
this.prune(now);
|
|
288
|
+
if (this.sessions.size >= this.maxSessions) {
|
|
289
|
+
return {
|
|
290
|
+
socket: undefined, socketCredential: undefined,
|
|
291
|
+
protocol: new NativeCodexWebSocketSessionState(),
|
|
292
|
+
disabled: false, prewarmAttempted: false, prewarmSucceeded: false, busy: false,
|
|
293
|
+
turnKey: undefined, turnState: undefined, lastUsed: now, pooled: false,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
const created = {
|
|
297
|
+
socket: undefined, socketCredential: undefined,
|
|
298
|
+
protocol: new NativeCodexWebSocketSessionState(),
|
|
299
|
+
disabled: false, prewarmAttempted: false, prewarmSucceeded: false, busy: false,
|
|
300
|
+
turnKey: undefined, turnState: undefined, lastUsed: now, pooled: true,
|
|
301
|
+
};
|
|
302
|
+
this.sessions.set(key, created);
|
|
303
|
+
return created;
|
|
304
|
+
}
|
|
305
|
+
assertFastAuthority(credential, mode) {
|
|
306
|
+
if (mode.serviceTier !== undefined
|
|
307
|
+
&& (mode.authorityHash === undefined
|
|
308
|
+
|| nativeCodexAuthorityHash(credential.accountId) !== mode.authorityHash)) {
|
|
309
|
+
throw failure('native Codex Fast capability authority changed before WebSocket request', 'FAST_CAPABILITY_UNAVAILABLE');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
headers(prepared, credential) {
|
|
313
|
+
return {
|
|
314
|
+
authorization: `Bearer ${credential.accessToken}`,
|
|
315
|
+
'chatgpt-account-id': credential.accountId,
|
|
316
|
+
originator: 'dsh',
|
|
317
|
+
'session-id': prepared.routingId,
|
|
318
|
+
'thread-id': prepared.routingId,
|
|
319
|
+
'x-client-request-id': prepared.routingId,
|
|
320
|
+
'x-codex-routing-hint': prepared.routingHint,
|
|
321
|
+
'openai-beta': WS_BETA,
|
|
322
|
+
...(prepared.generation.purpose === 'compaction' ? { 'x-openai-subagent': 'compact' } : {}),
|
|
323
|
+
...attributionHeaders(),
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
async ensureSocket(entry, prepared, credential, signal) {
|
|
327
|
+
const fingerprint = socketCredential(credential);
|
|
328
|
+
if (entry.socket !== undefined && entry.socketCredential === fingerprint)
|
|
329
|
+
return;
|
|
330
|
+
this.closeEntry(entry);
|
|
331
|
+
const socket = await this.factory.connect({
|
|
332
|
+
url: this.http.endpointUrl(),
|
|
333
|
+
headers: this.headers(prepared, credential),
|
|
334
|
+
signal,
|
|
335
|
+
connectTimeoutMs: this.connectTimeoutMs,
|
|
336
|
+
maxFrameBytes: this.maxFrameBytes,
|
|
337
|
+
});
|
|
338
|
+
if (this.disposed) {
|
|
339
|
+
socket.close();
|
|
340
|
+
throw failure('native Codex WebSocket transport was disposed', 'DISPOSED');
|
|
341
|
+
}
|
|
342
|
+
entry.socket = socket;
|
|
343
|
+
entry.socketCredential = fingerprint;
|
|
344
|
+
publishCodexRateLimits(credential.accountId, parseCodexRateLimitHeaders(socket.responseHeaders), this.options.onRateLimits, this.options.warn);
|
|
345
|
+
const handshakeState = socket.responseHeaders['x-codex-turn-state'];
|
|
346
|
+
if (entry.turnState === undefined && handshakeState !== undefined
|
|
347
|
+
&& Buffer.byteLength(handshakeState) <= MAX_TURN_STATE_BYTES) {
|
|
348
|
+
entry.turnState = handshakeState;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
async receive(entry, signal) {
|
|
352
|
+
if (entry.socket === undefined)
|
|
353
|
+
throw failure('native Codex WebSocket is unavailable', 'WS_RETRYABLE');
|
|
354
|
+
let timer;
|
|
355
|
+
try {
|
|
356
|
+
const frame = await Promise.race([
|
|
357
|
+
entry.socket.receive(signal),
|
|
358
|
+
new Promise((_resolve, reject) => {
|
|
359
|
+
timer = setTimeout(() => {
|
|
360
|
+
entry.socket?.close();
|
|
361
|
+
reject(failure('native Codex WebSocket response timed out', 'TIMEOUT'));
|
|
362
|
+
}, this.idleTimeoutMs);
|
|
363
|
+
}),
|
|
364
|
+
]);
|
|
365
|
+
if (frame.type === 'close') {
|
|
366
|
+
throw failure('native Codex WebSocket closed before completion', 'WS_RETRYABLE');
|
|
367
|
+
}
|
|
368
|
+
return frame.text;
|
|
369
|
+
}
|
|
370
|
+
finally {
|
|
371
|
+
if (timer !== undefined)
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async *exchange(entry, payload, generation, mode, accountId, prewarm, signal) {
|
|
376
|
+
if (entry.socket === undefined)
|
|
377
|
+
throw failure('native Codex WebSocket is unavailable', 'WS_RETRYABLE');
|
|
378
|
+
const encoded = JSON.stringify(payload);
|
|
379
|
+
if (Buffer.byteLength(encoded) > 24 * 1024 * 1024) {
|
|
380
|
+
throw failure('native Codex WebSocket request exceeded the size limit', 'REQUEST_TOO_LARGE');
|
|
381
|
+
}
|
|
382
|
+
await entry.socket.send(encoded, signal);
|
|
383
|
+
const translator = new ResponsesStreamTranslator(prewarm ? undefined : {
|
|
384
|
+
provider: generation.provider,
|
|
385
|
+
model: mode.publicModel ?? generation.model,
|
|
386
|
+
});
|
|
387
|
+
const outputItems = [];
|
|
388
|
+
let events = 0;
|
|
389
|
+
let responseBytes = 0;
|
|
390
|
+
while (events++ < MAX_EVENTS_PER_RESPONSE) {
|
|
391
|
+
const text = await this.receive(entry, signal);
|
|
392
|
+
responseBytes += Buffer.byteLength(text);
|
|
393
|
+
if (responseBytes > MAX_RESPONSE_BYTES) {
|
|
394
|
+
throw failure('native Codex WebSocket response exceeded the size limit', 'WS_RESPONSE_TOO_LARGE');
|
|
395
|
+
}
|
|
396
|
+
let event;
|
|
397
|
+
try {
|
|
398
|
+
event = JSON.parse(text);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
this.options.warn?.('native Codex ignored a malformed WebSocket event');
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (typeof event !== 'object' || event === null || typeof event.type !== 'string') {
|
|
405
|
+
this.options.warn?.('native Codex ignored a malformed WebSocket event');
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const rawEvent = event;
|
|
409
|
+
publishCodexResponseUsage(accountId, parseCodexResponseUsageMetadata(rawEvent), this.options.onResponseUsage, this.options.warn);
|
|
410
|
+
const eventRateLimits = parseCodexRateLimitEvent(rawEvent);
|
|
411
|
+
publishCodexRateLimits(accountId, eventRateLimits === undefined ? [] : [eventRateLimits], this.options.onRateLimits, this.options.warn);
|
|
412
|
+
publishCodexRateLimits(accountId, parseCodexRateLimitHeaders(typeof rawEvent.headers === 'object' && rawEvent.headers !== null
|
|
413
|
+
? rawEvent.headers : undefined), this.options.onRateLimits, this.options.warn);
|
|
414
|
+
const wrapped = eventFailure(rawEvent);
|
|
415
|
+
if (wrapped !== undefined)
|
|
416
|
+
throw wrapped;
|
|
417
|
+
if (entry.turnState === undefined) {
|
|
418
|
+
const nextTurnState = codexResponseTurnState(event);
|
|
419
|
+
if (nextTurnState !== undefined)
|
|
420
|
+
entry.turnState = nextTurnState;
|
|
421
|
+
}
|
|
422
|
+
const output = normalizedOutputItem(event);
|
|
423
|
+
if (output !== undefined) {
|
|
424
|
+
if (outputItems.length >= MAX_OUTPUT_ITEMS_PER_RESPONSE) {
|
|
425
|
+
throw failure('native Codex WebSocket response had too many output items', 'WS_RESPONSE_TOO_LARGE');
|
|
426
|
+
}
|
|
427
|
+
outputItems.push(output);
|
|
428
|
+
}
|
|
429
|
+
if (event.type === 'response.completed') {
|
|
430
|
+
const response = typeof event.response === 'object'
|
|
431
|
+
&& event.response !== null
|
|
432
|
+
? event.response
|
|
433
|
+
: undefined;
|
|
434
|
+
const responseId = typeof response?.id === 'string' ? response.id : '';
|
|
435
|
+
entry.protocol.complete(responseId, outputItems);
|
|
436
|
+
}
|
|
437
|
+
const chunks = translator.push(event);
|
|
438
|
+
if (!prewarm)
|
|
439
|
+
for (const chunk of chunks)
|
|
440
|
+
yield chunk;
|
|
441
|
+
if (translator.terminated) {
|
|
442
|
+
if (event.type !== 'response.completed')
|
|
443
|
+
entry.protocol.reset();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
throw failure('native Codex WebSocket response had too many events', 'WS_PROTOCOL_ERROR');
|
|
448
|
+
}
|
|
449
|
+
async *attempt(entry, prepared, credential, signal) {
|
|
450
|
+
await this.ensureSocket(entry, prepared, credential, signal);
|
|
451
|
+
let justPrewarmed = false;
|
|
452
|
+
if (!entry.prewarmAttempted) {
|
|
453
|
+
entry.prewarmAttempted = true;
|
|
454
|
+
const warm = entry.protocol.prewarm(withTurnState(prepared.request, entry.turnState));
|
|
455
|
+
for await (const _chunk of this.exchange(entry, warm.payload, prepared.generation, prepared.mode, credential.accountId, true, signal)) { /* prewarm is invisible */ }
|
|
456
|
+
entry.prewarmSucceeded = true;
|
|
457
|
+
justPrewarmed = true;
|
|
458
|
+
}
|
|
459
|
+
const plan = entry.protocol.plan(withTurnState(prepared.request, entry.turnState), justPrewarmed);
|
|
460
|
+
yield* this.exchange(entry, plan.payload, prepared.generation, prepared.mode, credential.accountId, false, signal);
|
|
461
|
+
if (this.options.onCompleted !== undefined) {
|
|
462
|
+
try {
|
|
463
|
+
this.options.onCompleted();
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
this.options.warn?.('native Codex usage refresh could not be scheduled');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
async *stream(generation, mode = {}) {
|
|
471
|
+
if (this.disposed)
|
|
472
|
+
throw failure('native Codex WebSocket transport was disposed', 'DISPOSED');
|
|
473
|
+
const lifecycle = new AbortController();
|
|
474
|
+
const signal = generation.signal === undefined
|
|
475
|
+
? lifecycle.signal : AbortSignal.any([generation.signal, lifecycle.signal]);
|
|
476
|
+
const activeGeneration = { ...generation, signal };
|
|
477
|
+
this.preparing.add(lifecycle);
|
|
478
|
+
let prepared;
|
|
479
|
+
try {
|
|
480
|
+
prepared = await this.http.prepare(activeGeneration, mode);
|
|
481
|
+
}
|
|
482
|
+
finally {
|
|
483
|
+
this.preparing.delete(lifecycle);
|
|
484
|
+
}
|
|
485
|
+
if (this.disposed)
|
|
486
|
+
throw failure('native Codex WebSocket transport was disposed', 'DISPOSED');
|
|
487
|
+
const key = sessionKey(generation, prepared.routingId);
|
|
488
|
+
const entry = this.entry(key);
|
|
489
|
+
if (entry.busy)
|
|
490
|
+
throw failure('native Codex WebSocket session already has an active request', 'CONCURRENT_REQUEST');
|
|
491
|
+
if (this.active.size >= this.maxSessions) {
|
|
492
|
+
throw failure('native Codex WebSocket active session limit was reached', 'WS_SESSION_LIMIT');
|
|
493
|
+
}
|
|
494
|
+
entry.busy = true;
|
|
495
|
+
this.active.set(entry, lifecycle);
|
|
496
|
+
const currentTurn = turnKey(generation);
|
|
497
|
+
if (entry.turnKey !== currentTurn) {
|
|
498
|
+
entry.turnKey = currentTurn;
|
|
499
|
+
entry.turnState = undefined;
|
|
500
|
+
}
|
|
501
|
+
const fallbackMode = () => ({
|
|
502
|
+
...mode,
|
|
503
|
+
...(entry.turnState === undefined ? {} : { turnState: entry.turnState }),
|
|
504
|
+
captureTurnState: (state) => {
|
|
505
|
+
if (entry.turnState === undefined)
|
|
506
|
+
entry.turnState = state;
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
let reconnects = 0;
|
|
510
|
+
let connectionRetryDelayMs = INITIAL_CONNECTION_RETRY_DELAY_MS;
|
|
511
|
+
let recovered = false;
|
|
512
|
+
let requestCompleted = false;
|
|
513
|
+
try {
|
|
514
|
+
if (entry.disabled) {
|
|
515
|
+
yield* this.http.stream(activeGeneration, fallbackMode());
|
|
516
|
+
requestCompleted = true;
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
while (true) {
|
|
520
|
+
let emitted = false;
|
|
521
|
+
let attemptedCredential;
|
|
522
|
+
const enteringPrewarm = !entry.prewarmAttempted;
|
|
523
|
+
try {
|
|
524
|
+
const credential = await this.options.resolveCredential(signal);
|
|
525
|
+
attemptedCredential = credential;
|
|
526
|
+
this.assertFastAuthority(credential, mode);
|
|
527
|
+
for await (const chunk of this.attempt(entry, prepared, credential, signal)) {
|
|
528
|
+
emitted = true;
|
|
529
|
+
yield chunk;
|
|
530
|
+
}
|
|
531
|
+
requestCompleted = true;
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
const failureValue = error instanceof LlmError
|
|
536
|
+
? error : failure('native Codex WebSocket transport failed', 'WS_RETRYABLE', error);
|
|
537
|
+
this.closeEntry(entry);
|
|
538
|
+
if (this.disposed)
|
|
539
|
+
throw failure('native Codex WebSocket transport was disposed', 'DISPOSED');
|
|
540
|
+
if (generation.signal?.aborted || failureValue.code === 'ABORTED')
|
|
541
|
+
throw failureValue;
|
|
542
|
+
if (emitted) {
|
|
543
|
+
if (failedStepRetryable(failureValue.code))
|
|
544
|
+
throw failedStepRetry(failureValue);
|
|
545
|
+
throw failureValue;
|
|
546
|
+
}
|
|
547
|
+
if (failureValue.code === NATIVE_CODEX_CONNECTION_FAILED_CODE
|
|
548
|
+
|| (attemptedCredential === undefined
|
|
549
|
+
&& isNativeCodexConnectionFailure(failureValue))) {
|
|
550
|
+
await this.waitForConnection(connectionRetryDelayMs, signal);
|
|
551
|
+
connectionRetryDelayMs = Math.min(connectionRetryDelayMs * 2, MAX_CONNECTION_RETRY_DELAY_MS);
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (failureValue.code === 'WS_AUTH' && !recovered
|
|
555
|
+
&& attemptedCredential !== undefined
|
|
556
|
+
&& this.options.recoverCredential !== undefined) {
|
|
557
|
+
if (enteringPrewarm && !entry.prewarmSucceeded)
|
|
558
|
+
entry.prewarmAttempted = false;
|
|
559
|
+
try {
|
|
560
|
+
const changed = await this.options.recoverCredential(attemptedCredential, signal);
|
|
561
|
+
recovered = true;
|
|
562
|
+
if (changed)
|
|
563
|
+
continue;
|
|
564
|
+
throw failure('native Codex rejected the configured credential', 'AUTH');
|
|
565
|
+
}
|
|
566
|
+
catch (recoveryError) {
|
|
567
|
+
if (this.disposed) {
|
|
568
|
+
throw failure('native Codex WebSocket transport was disposed', 'DISPOSED');
|
|
569
|
+
}
|
|
570
|
+
if (generation.signal?.aborted)
|
|
571
|
+
throw recoveryError;
|
|
572
|
+
if (isNativeCodexConnectionFailure(recoveryError)) {
|
|
573
|
+
await this.waitForConnection(connectionRetryDelayMs, signal);
|
|
574
|
+
connectionRetryDelayMs = Math.min(connectionRetryDelayMs * 2, MAX_CONNECTION_RETRY_DELAY_MS);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
throw recoveryError;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const retryable = reconnectable(failureValue.code);
|
|
581
|
+
if (failureValue.code !== 'WS_UPGRADE_REQUIRED' && retryable
|
|
582
|
+
&& enteringPrewarm && !entry.prewarmSucceeded) {
|
|
583
|
+
entry.prewarmAttempted = true;
|
|
584
|
+
await this.wait(0, failureValue, signal);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
if (failureValue.code !== 'WS_UPGRADE_REQUIRED' && retryable
|
|
588
|
+
&& reconnects < this.maxReconnects) {
|
|
589
|
+
await this.wait(reconnects, failureValue, signal);
|
|
590
|
+
reconnects++;
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (failureValue.code === 'WS_UPGRADE_REQUIRED' || retryable) {
|
|
594
|
+
entry.disabled = true;
|
|
595
|
+
yield* this.http.stream(activeGeneration, fallbackMode());
|
|
596
|
+
requestCompleted = true;
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
throw failureValue;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
finally {
|
|
604
|
+
this.active.delete(entry);
|
|
605
|
+
entry.busy = false;
|
|
606
|
+
entry.lastUsed = Date.now();
|
|
607
|
+
if (!requestCompleted || !entry.pooled)
|
|
608
|
+
this.closeEntry(entry);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
dispose() {
|
|
612
|
+
if (this.disposed)
|
|
613
|
+
return;
|
|
614
|
+
this.disposed = true;
|
|
615
|
+
for (const controller of this.preparing) {
|
|
616
|
+
controller.abort(failure('native Codex WebSocket transport was disposed', 'DISPOSED'));
|
|
617
|
+
}
|
|
618
|
+
this.preparing.clear();
|
|
619
|
+
for (const [entry, controller] of this.active) {
|
|
620
|
+
controller.abort(failure('native Codex WebSocket transport was disposed', 'DISPOSED'));
|
|
621
|
+
this.closeEntry(entry);
|
|
622
|
+
}
|
|
623
|
+
this.active.clear();
|
|
624
|
+
for (const entry of this.sessions.values())
|
|
625
|
+
this.closeEntry(entry);
|
|
626
|
+
this.sessions.clear();
|
|
627
|
+
}
|
|
628
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Bounded parsing for Codex subscription quota side channels. */
|
|
2
|
+
export interface CodexRateLimitWindow {
|
|
3
|
+
usedPercent: number;
|
|
4
|
+
windowSeconds?: number;
|
|
5
|
+
resetAt?: number;
|
|
6
|
+
}
|
|
7
|
+
export interface CodexRateLimitCredits {
|
|
8
|
+
hasCredits: boolean;
|
|
9
|
+
unlimited: boolean;
|
|
10
|
+
balance?: string;
|
|
11
|
+
}
|
|
12
|
+
/** One provider quota bucket observed on an HTTP response or WebSocket event. */
|
|
13
|
+
export interface CodexRateLimitUpdate {
|
|
14
|
+
limitId: string;
|
|
15
|
+
limitName?: string;
|
|
16
|
+
planType?: string;
|
|
17
|
+
primary?: CodexRateLimitWindow | null;
|
|
18
|
+
secondary?: CodexRateLimitWindow | null;
|
|
19
|
+
limitReached?: boolean;
|
|
20
|
+
credits?: CodexRateLimitCredits;
|
|
21
|
+
}
|
|
22
|
+
export interface CodexRateLimitObservation {
|
|
23
|
+
accountId: string;
|
|
24
|
+
updates: readonly CodexRateLimitUpdate[];
|
|
25
|
+
}
|
|
26
|
+
export type CodexRateLimitCallback = (observation: CodexRateLimitObservation) => void;
|
|
27
|
+
type HeaderSource = Headers | Readonly<Record<string, unknown>>;
|
|
28
|
+
export declare function parseCodexRateLimitCredits(value: unknown): CodexRateLimitCredits | undefined;
|
|
29
|
+
/** Parse a `codex.rate_limits` WebSocket v2 event without trusting provider text. */
|
|
30
|
+
export declare function parseCodexRateLimitEvent(value: unknown): CodexRateLimitUpdate | undefined;
|
|
31
|
+
/** Parse every `x-<limit>-primary-*` quota header family on an HTTP or wrapped WS response. */
|
|
32
|
+
export declare function parseCodexRateLimitHeaders(source: HeaderSource | undefined): CodexRateLimitUpdate[];
|
|
33
|
+
/** Publish optional quota metadata without letting diagnostics break a model stream. */
|
|
34
|
+
export declare function publishCodexRateLimits(accountId: string, updates: readonly CodexRateLimitUpdate[], callback: CodexRateLimitCallback | undefined, warn: ((message: string) => void) | undefined): void;
|
|
35
|
+
export {};
|