@bitkyc08/opencodex 2.44.0 → 2.45.0
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/gui/dist/assets/index-CCfD72yq.js +115 -0
- package/gui/dist/assets/index-J96sug5C.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/openai-responses.ts +14 -0
- package/src/chat/outbound.ts +286 -35
- package/src/claude/compatibility.ts +192 -0
- package/src/claude/model-info.ts +14 -2
- package/src/cli/account-extended.ts +24 -1
- package/src/cli/init.ts +70 -13
- package/src/codex/catalog/provider-fetch.ts +38 -9
- package/src/codex/catalog/sync.ts +34 -2
- package/src/codex/catalog.ts +1 -1
- package/src/config/initialize.ts +132 -0
- package/src/config/rebase-provenance.ts +26 -0
- package/src/config.ts +50 -1
- package/src/generated/compatibility-version.json +41 -29
- package/src/lib/windows-secret-acl.ts +8 -4
- package/src/providers/quota.ts +26 -15
- package/src/responses/state.ts +48 -3
- package/src/server/chat-completions.ts +32 -36
- package/src/server/chat-native-sse.ts +23 -3
- package/src/server/chat-native.ts +15 -8
- package/src/server/claude-messages.ts +27 -0
- package/src/server/index.ts +5 -1
- package/src/server/management/agent-settings-routes.ts +101 -14
- package/src/server/management/logs-usage-routes.ts +9 -2
- package/src/server/request-log-cursor.ts +84 -0
- package/src/server/request-log.ts +46 -3
- package/src/server/responses/agent-task-recovery.ts +50 -14
- package/src/server/responses/codex-ws-exchange.ts +79 -0
- package/src/server/responses/compact.ts +39 -33
- package/src/server/responses/core.ts +43 -27
- package/src/storage/cleanup.ts +49 -35
- package/src/types/config.ts +4 -0
- package/src/usage/log.ts +22 -0
- package/gui/dist/assets/index-B7_K1Hsj.js +0 -115
- package/gui/dist/assets/index-ltx3L-WS.css +0 -1
package/src/chat/outbound.ts
CHANGED
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
type Rec = Record<string, unknown>;
|
|
9
9
|
|
|
10
10
|
import { decodeServerSentEvents, sseFieldValue } from "../lib/sse-decoder";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
isTranslatorBudgetExceededError,
|
|
13
|
+
type TranslatorBudget,
|
|
14
|
+
type TranslatorTransientReservation,
|
|
15
|
+
} from "../lib/translator-budget";
|
|
12
16
|
import {
|
|
13
17
|
classifyError,
|
|
14
18
|
cyberPolicyErrorType,
|
|
@@ -156,6 +160,14 @@ function appendedUtf8Bytes(previous: string, previousBytes: number, fragment: st
|
|
|
156
160
|
return nextBytes;
|
|
157
161
|
}
|
|
158
162
|
|
|
163
|
+
function refusalTranslationError(): ChatCompletionsStreamError {
|
|
164
|
+
// Never include provider-controlled refusal text or correlation IDs in diagnostics.
|
|
165
|
+
return new ChatCompletionsStreamError("upstream refusal representations are inconsistent", {
|
|
166
|
+
type: "upstream_error",
|
|
167
|
+
code: "invalid_refusal",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
159
171
|
/**
|
|
160
172
|
* Streaming: Responses SSE bytes -> Chat Completions SSE bytes.
|
|
161
173
|
*/
|
|
@@ -188,6 +200,125 @@ export function responsesSseToChatCompletionsSse(
|
|
|
188
200
|
let emittedFrames = 0;
|
|
189
201
|
let stepping = false;
|
|
190
202
|
let decoderStarted = false;
|
|
203
|
+
// Raw output/content positions are the ordering authority; IDs only constrain identity.
|
|
204
|
+
// Charge a fixed entry allowance as well as keys/IDs so empty parts remain bounded.
|
|
205
|
+
const refusalEntryBytes = 64;
|
|
206
|
+
const refusalItems = new Map<number, {
|
|
207
|
+
id?: string;
|
|
208
|
+
parts: Map<number, { text: string; bytes: number; present: boolean }>;
|
|
209
|
+
}>();
|
|
210
|
+
const refusalIndexById = new Map<string, number>();
|
|
211
|
+
let refusalMetadataBytes = 0;
|
|
212
|
+
let refusalTextBytes = 0;
|
|
213
|
+
const releaseRefusals = () => {
|
|
214
|
+
refusalItems.clear();
|
|
215
|
+
refusalIndexById.clear();
|
|
216
|
+
translatorBudget.releaseRetained(refusalMetadataBytes, { kind: "item_ids" });
|
|
217
|
+
translatorBudget.releaseRetained(refusalTextBytes, { kind: "retained_collectors" });
|
|
218
|
+
refusalMetadataBytes = 0;
|
|
219
|
+
refusalTextBytes = 0;
|
|
220
|
+
};
|
|
221
|
+
const position = (value: unknown): number => {
|
|
222
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
|
|
223
|
+
throw refusalTranslationError();
|
|
224
|
+
}
|
|
225
|
+
return value;
|
|
226
|
+
};
|
|
227
|
+
const chargeRefusalMetadata = (bytes: number) => {
|
|
228
|
+
translatorBudget.chargeRetained(bytes, { kind: "item_ids" });
|
|
229
|
+
refusalMetadataBytes += bytes;
|
|
230
|
+
};
|
|
231
|
+
const refusalItem = (outputIndex: unknown, source: Rec, idField: string) => {
|
|
232
|
+
const index = position(outputIndex);
|
|
233
|
+
const hasId = Object.hasOwn(source, idField);
|
|
234
|
+
const candidate = source[idField];
|
|
235
|
+
if (hasId && typeof candidate !== "string") throw refusalTranslationError();
|
|
236
|
+
let item = refusalItems.get(index);
|
|
237
|
+
if (!item) {
|
|
238
|
+
chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(String(index)));
|
|
239
|
+
item = { parts: new Map() };
|
|
240
|
+
refusalItems.set(index, item);
|
|
241
|
+
}
|
|
242
|
+
if (hasId && typeof candidate === "string") {
|
|
243
|
+
const knownIndex = refusalIndexById.get(candidate);
|
|
244
|
+
if (knownIndex !== undefined && knownIndex !== index) throw refusalTranslationError();
|
|
245
|
+
if (item.id !== undefined && item.id !== candidate) throw refusalTranslationError();
|
|
246
|
+
if (item.id === undefined) {
|
|
247
|
+
chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(candidate));
|
|
248
|
+
item.id = candidate;
|
|
249
|
+
refusalIndexById.set(candidate, index);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return item;
|
|
253
|
+
};
|
|
254
|
+
const retainRefusal = (outputIndex: unknown, contentIndex: unknown, source: Rec,
|
|
255
|
+
idField: string, evidence: Rec, field: string, delta = false) => {
|
|
256
|
+
const item = refusalItem(outputIndex, source, idField);
|
|
257
|
+
const index = position(contentIndex);
|
|
258
|
+
let part = item.parts.get(index);
|
|
259
|
+
if (!part) {
|
|
260
|
+
chargeRefusalMetadata(refusalEntryBytes + Buffer.byteLength(String(index)));
|
|
261
|
+
part = { text: "", bytes: 0, present: false };
|
|
262
|
+
item.parts.set(index, part);
|
|
263
|
+
}
|
|
264
|
+
if (!Object.hasOwn(evidence, field)) {
|
|
265
|
+
if (delta) throw refusalTranslationError();
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const candidate = evidence[field];
|
|
269
|
+
if (typeof candidate !== "string") throw refusalTranslationError();
|
|
270
|
+
part.present = true;
|
|
271
|
+
if (!delta) {
|
|
272
|
+
// Equal, empty, and stale-prefix snapshots add no evidence; never erase deltas.
|
|
273
|
+
if (part.text.startsWith(candidate)) return;
|
|
274
|
+
if (!candidate.startsWith(part.text)) throw refusalTranslationError();
|
|
275
|
+
}
|
|
276
|
+
const nextBytes = delta ? appendedUtf8Bytes(part.text, part.bytes, candidate) : Buffer.byteLength(candidate);
|
|
277
|
+
const reservation = translatorBudget.reserveTransient(nextBytes, { kind: "retained_collectors" });
|
|
278
|
+
try {
|
|
279
|
+
const next = delta ? part.text + candidate : candidate;
|
|
280
|
+
reservation.commitRetained();
|
|
281
|
+
translatorBudget.releaseRetained(part.bytes, { kind: "retained_collectors" });
|
|
282
|
+
refusalTextBytes += nextBytes - part.bytes;
|
|
283
|
+
part.text = next;
|
|
284
|
+
part.bytes = nextBytes;
|
|
285
|
+
} catch (error) {
|
|
286
|
+
reservation.release();
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
const snapshotRefusalItem = (outputIndex: unknown, item: Rec) => {
|
|
291
|
+
const existing = typeof outputIndex === "number" ? refusalItems.get(outputIndex) : undefined;
|
|
292
|
+
// Sparse final snapshots may omit type/content but cannot change a known ID.
|
|
293
|
+
if (Object.hasOwn(item, "id")
|
|
294
|
+
&& (existing || (typeof item.id === "string" && refusalIndexById.has(item.id)))) {
|
|
295
|
+
refusalItem(outputIndex, item, "id");
|
|
296
|
+
}
|
|
297
|
+
if (item.type !== "message") {
|
|
298
|
+
if (existing && existing.parts.size > 0 && item.type !== undefined) throw refusalTranslationError();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Unrelated sparse text messages historically need no position metadata.
|
|
302
|
+
if (outputIndex === undefined && (!Array.isArray(item.content)
|
|
303
|
+
|| !item.content.some(part => isRec(part) && part.type === "refusal"))) return;
|
|
304
|
+
const known = refusalItem(outputIndex, item, "id");
|
|
305
|
+
if (!Array.isArray(item.content)) return;
|
|
306
|
+
item.content.forEach((part: unknown, contentIndex: number) => {
|
|
307
|
+
if (!isRec(part)) return;
|
|
308
|
+
if (part.type === "refusal") {
|
|
309
|
+
retainRefusal(outputIndex, contentIndex, item, "id", part, "refusal");
|
|
310
|
+
} else if (part.type !== undefined && known.parts.has(contentIndex)) {
|
|
311
|
+
throw refusalTranslationError();
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
const snapshotRefusals = (response: Rec) => {
|
|
316
|
+
if (!Array.isArray(response.output)) return;
|
|
317
|
+
response.output.forEach((item: unknown, outputIndex: number) => {
|
|
318
|
+
if (isRec(item)) snapshotRefusalItem(outputIndex, item);
|
|
319
|
+
});
|
|
320
|
+
};
|
|
321
|
+
let terminalBatch: Array<{ frame: Uint8Array; reservation: TranslatorTransientReservation }> | undefined;
|
|
191
322
|
const queuedLiveFrameBytes: number[] = [];
|
|
192
323
|
const enqueueLiveFrame = (frame: Uint8Array) => {
|
|
193
324
|
const reservation = translatorBudget.reserveTransient(frame.byteLength, { kind: "live_transient" });
|
|
@@ -235,8 +366,20 @@ export function responsesSseToChatCompletionsSse(
|
|
|
235
366
|
};
|
|
236
367
|
const emit = (payload: Rec | "[DONE]") => {
|
|
237
368
|
if (failed) return;
|
|
238
|
-
|
|
239
|
-
|
|
369
|
+
if (terminalBatch) {
|
|
370
|
+
const serialized = dataFrame(payload);
|
|
371
|
+
const stringReservation = translatorBudget.reserveTransient(Buffer.byteLength(serialized), { kind: "live_transient" });
|
|
372
|
+
try {
|
|
373
|
+
const frame = encoder.encode(serialized);
|
|
374
|
+
const reservation = translatorBudget.reserveTransient(frame.byteLength, { kind: "live_transient" });
|
|
375
|
+
terminalBatch.push({ frame, reservation });
|
|
376
|
+
} finally {
|
|
377
|
+
stringReservation.release();
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
enqueueLiveFrame(encoder.encode(dataFrame(payload)));
|
|
381
|
+
emittedFrames++;
|
|
382
|
+
}
|
|
240
383
|
};
|
|
241
384
|
const ensureRole = () => {
|
|
242
385
|
if (started) return;
|
|
@@ -298,38 +441,68 @@ export function responsesSseToChatCompletionsSse(
|
|
|
298
441
|
};
|
|
299
442
|
const finish = (finishReason: string, usage: unknown) => {
|
|
300
443
|
if (terminated) return;
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
|
|
444
|
+
// Admit every pending role/tool/refusal/finish/DONE frame before exposing any
|
|
445
|
+
// of this terminal batch. Serialization and encoded bytes coexist and both count.
|
|
446
|
+
const batch: NonNullable<typeof terminalBatch> = [];
|
|
447
|
+
terminalBatch = batch;
|
|
448
|
+
try {
|
|
449
|
+
flushPendingToolCalls();
|
|
450
|
+
ensureRole();
|
|
451
|
+
for (const [, item] of [...refusalItems.entries()].sort(([a], [b]) => a - b)) {
|
|
452
|
+
for (const [, part] of [...item.parts.entries()].sort(([a], [b]) => a - b)) {
|
|
453
|
+
if (!part.present) continue;
|
|
454
|
+
const refusal = chunkBase(id, model, created);
|
|
455
|
+
refusal.choices = [{ index: 0, delta: { refusal: part.text }, finish_reason: null }];
|
|
456
|
+
emit(refusal);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const frame = chunkBase(id, model, created);
|
|
460
|
+
frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }];
|
|
461
|
+
if (usage) frame.usage = chatCompletionsUsage(usage);
|
|
462
|
+
emit(frame);
|
|
463
|
+
emit("[DONE]");
|
|
464
|
+
} catch (error) {
|
|
465
|
+
for (const staged of batch) staged.reservation.release();
|
|
466
|
+
throw error;
|
|
467
|
+
} finally {
|
|
468
|
+
terminalBatch = undefined;
|
|
469
|
+
}
|
|
470
|
+
for (const staged of batch) {
|
|
471
|
+
controller.enqueue(staged.frame);
|
|
472
|
+
staged.reservation.commitRetained();
|
|
473
|
+
queuedLiveFrameBytes.push(staged.frame.byteLength);
|
|
474
|
+
emittedFrames++;
|
|
475
|
+
}
|
|
304
476
|
terminated = true;
|
|
305
|
-
|
|
306
|
-
const frame = chunkBase(id, model, created);
|
|
307
|
-
frame.choices = [{ index: 0, delta: {}, finish_reason: finishReason }];
|
|
308
|
-
if (usage) frame.usage = chatCompletionsUsage(usage);
|
|
309
|
-
emit(frame);
|
|
310
|
-
emit("[DONE]");
|
|
477
|
+
releaseRefusals();
|
|
311
478
|
};
|
|
312
479
|
const fail = (message: string, details?: { code?: string | null; type?: string; status?: number }) => {
|
|
313
480
|
if (terminated) return;
|
|
314
481
|
terminated = true;
|
|
315
482
|
failed = true;
|
|
483
|
+
releaseRefusals();
|
|
484
|
+
closeToolCalls();
|
|
485
|
+
upstreamAbort.abort(new Error("upstream chat translation failed"));
|
|
486
|
+
try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ }
|
|
316
487
|
// OpenAI-compatible clients need a real error event, not a success completion
|
|
317
488
|
// that embeds `[error] ...` text followed by a clean [DONE].
|
|
318
489
|
// Deliver the error frame then close the stream abnormally (no [DONE]).
|
|
319
490
|
// Do not controller.error() — that can drop already-enqueued bytes from consumers
|
|
320
491
|
// like response.text().
|
|
321
|
-
const
|
|
492
|
+
const translatorOverflow = details?.code === "translation_buffer_limit";
|
|
493
|
+
const safeMessage = translatorOverflow ? "upstream translation buffer exceeded the safe limit"
|
|
494
|
+
: details?.code === "invalid_refusal" ? "upstream refusal representations are inconsistent"
|
|
495
|
+
: redactSecretString(message);
|
|
322
496
|
const statusHint = details?.status ?? streamErrorStatus(safeMessage);
|
|
323
497
|
const classified = classifyError(statusHint, details?.type ?? "upstream_error", safeMessage);
|
|
324
|
-
const translatorOverflow = details?.code === "translation_buffer_limit";
|
|
325
498
|
if (translatorOverflow) {
|
|
326
|
-
upstreamAbort.abort(new Error("upstream translation buffer exceeded the safe limit"));
|
|
327
|
-
closeToolCalls();
|
|
328
|
-
try { void sseIterator?.return(undefined).catch(() => {}); } catch { /* already closed */ }
|
|
329
499
|
classified.code = "translation_buffer_limit";
|
|
330
500
|
// Provider-controlled overflow is an upstream failure on every path:
|
|
331
501
|
// streaming frame, collector, and defensive JSON agree on 502.
|
|
332
502
|
classified.type = "upstream_error";
|
|
503
|
+
} else if (details?.code === "invalid_refusal") {
|
|
504
|
+
classified.code = details.code;
|
|
505
|
+
classified.type = "upstream_error";
|
|
333
506
|
} else if (isCyberPolicyCode(details?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
|
|
334
507
|
classified.code = CYBER_POLICY_ERROR_CODE;
|
|
335
508
|
classified.type = cyberPolicyErrorType(details?.type);
|
|
@@ -345,9 +518,9 @@ export function responsesSseToChatCompletionsSse(
|
|
|
345
518
|
code: classified.code,
|
|
346
519
|
},
|
|
347
520
|
}));
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
if (translatorOverflow) controller.enqueue(frame);
|
|
521
|
+
// These fixed, bounded failures must survive even when decoder-owned input
|
|
522
|
+
// still fills the budget. They contain no provider text or IDs.
|
|
523
|
+
if (translatorOverflow || details?.code === "invalid_refusal") controller.enqueue(frame);
|
|
351
524
|
else enqueueLiveFrame(frame);
|
|
352
525
|
emittedFrames++;
|
|
353
526
|
} catch {
|
|
@@ -371,8 +544,29 @@ export function responsesSseToChatCompletionsSse(
|
|
|
371
544
|
if (typeof data.delta === "string") emitReasoning(data.delta);
|
|
372
545
|
break;
|
|
373
546
|
}
|
|
547
|
+
case "response.refusal.delta":
|
|
548
|
+
case "response.refusal.done": {
|
|
549
|
+
const delta = eventName === "response.refusal.delta";
|
|
550
|
+
retainRefusal(data.output_index, data.content_index, data, "item_id", data, delta ? "delta" : "refusal", delta);
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
case "response.content_part.added":
|
|
554
|
+
case "response.content_part.done": {
|
|
555
|
+
const part = isRec(data.part) ? data.part : null;
|
|
556
|
+
if (part?.type === "refusal") {
|
|
557
|
+
retainRefusal(data.output_index, data.content_index, data, "item_id", part, "refusal");
|
|
558
|
+
} else if (typeof data.output_index === "number" && refusalItems.has(data.output_index)) {
|
|
559
|
+
const item = refusalItem(data.output_index, data, "item_id");
|
|
560
|
+
if (part?.type !== undefined && item.parts.has(position(data.content_index))) throw refusalTranslationError();
|
|
561
|
+
}
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
374
564
|
case "response.output_item.added": {
|
|
375
565
|
const item = isRec(data.item) ? data.item : null;
|
|
566
|
+
if (item?.type === "message") {
|
|
567
|
+
snapshotRefusalItem(data.output_index, item);
|
|
568
|
+
if (Object.hasOwn(data, "item_id")) refusalItem(data.output_index, data, "item_id");
|
|
569
|
+
}
|
|
376
570
|
if (!item || item.type !== "function_call") break;
|
|
377
571
|
ensureRole();
|
|
378
572
|
sawToolUse = true;
|
|
@@ -416,6 +610,8 @@ export function responsesSseToChatCompletionsSse(
|
|
|
416
610
|
case "response.output_item.done": {
|
|
417
611
|
const item = isRec(data.item) ? data.item : null;
|
|
418
612
|
if (!item) break;
|
|
613
|
+
snapshotRefusalItem(data.output_index, item);
|
|
614
|
+
if (item.type === "message" && Object.hasOwn(data, "item_id")) refusalItem(data.output_index, data, "item_id");
|
|
419
615
|
if (item.type === "function_call") {
|
|
420
616
|
sawToolUse = true;
|
|
421
617
|
const callId = typeof item.call_id === "string" ? item.call_id : "";
|
|
@@ -445,6 +641,7 @@ export function responsesSseToChatCompletionsSse(
|
|
|
445
641
|
}
|
|
446
642
|
case "response.completed": {
|
|
447
643
|
const response = isRec(data.response) ? data.response : {};
|
|
644
|
+
snapshotRefusals(response);
|
|
448
645
|
finish(sawToolUse ? "tool_calls" : "stop", response.usage);
|
|
449
646
|
break;
|
|
450
647
|
}
|
|
@@ -456,6 +653,7 @@ export function responsesSseToChatCompletionsSse(
|
|
|
456
653
|
: undefined;
|
|
457
654
|
if (reason !== undefined) {
|
|
458
655
|
// Truthful OpenAI-compatible finish reasons: the turn ended, just early.
|
|
656
|
+
snapshotRefusals(response);
|
|
459
657
|
finish(reason, response.usage);
|
|
460
658
|
} else {
|
|
461
659
|
// upstream_stall_timeout / adapter_eof / proxy-synthesized incompletes are
|
|
@@ -500,6 +698,7 @@ export function responsesSseToChatCompletionsSse(
|
|
|
500
698
|
while (!cancelled && emittedFrames === emittedAtStart) {
|
|
501
699
|
decoderStarted = true;
|
|
502
700
|
const next = await sseIterator!.next();
|
|
701
|
+
if (cancelled) break;
|
|
503
702
|
if (next.done) {
|
|
504
703
|
if (!cancelled && !terminated) {
|
|
505
704
|
fail("upstream stream ended before a terminal frame (truncated response)");
|
|
@@ -525,6 +724,8 @@ export function responsesSseToChatCompletionsSse(
|
|
|
525
724
|
upstreamAbort.abort(err);
|
|
526
725
|
closeToolCalls();
|
|
527
726
|
fail(err.message, { status: 502, type: "upstream_error", code: err.code });
|
|
727
|
+
} else if (isChatCompletionsStreamError(err)) {
|
|
728
|
+
fail(err.message, { status: err.status, type: err.type, code: err.code });
|
|
528
729
|
} else {
|
|
529
730
|
fail(err instanceof Error ? err.message : String(err));
|
|
530
731
|
}
|
|
@@ -545,6 +746,7 @@ export function responsesSseToChatCompletionsSse(
|
|
|
545
746
|
},
|
|
546
747
|
cancel(reason) {
|
|
547
748
|
cancelled = true;
|
|
749
|
+
releaseRefusals();
|
|
548
750
|
while (queuedLiveFrameBytes.length > 0) releaseDeliveredFrame();
|
|
549
751
|
closeToolCalls();
|
|
550
752
|
// Abort first: it cancels the decoder's underlying reader, settling any in-flight
|
|
@@ -560,55 +762,99 @@ export function responsesSseToChatCompletionsSse(
|
|
|
560
762
|
}
|
|
561
763
|
|
|
562
764
|
/** Non-streaming: /v1/responses JSON -> Chat Completions message JSON. */
|
|
563
|
-
export function responsesJsonToChatCompletion(json: unknown, model: string): Rec {
|
|
765
|
+
export function responsesJsonToChatCompletion(json: unknown, model: string, translatorBudget?: TranslatorBudget): Rec {
|
|
564
766
|
const body = isRec(json) ? json : {};
|
|
767
|
+
const incomplete = isRec(body.incomplete_details) ? body.incomplete_details : {};
|
|
768
|
+
let incompleteFinish: "length" | "content_filter" | undefined;
|
|
769
|
+
if (body.status === "incomplete") {
|
|
770
|
+
if (incomplete.reason === "max_output_tokens") incompleteFinish = "length";
|
|
771
|
+
else if (incomplete.reason === "content_filter") incompleteFinish = "content_filter";
|
|
772
|
+
else throw new ChatCompletionsStreamError("upstream response ended without a supported completion boundary", {
|
|
773
|
+
code: "upstream_incomplete", type: "upstream_error",
|
|
774
|
+
});
|
|
775
|
+
}
|
|
565
776
|
const output = Array.isArray(body.output) ? body.output : [];
|
|
566
777
|
let content = "";
|
|
778
|
+
let refusal: string | null = null;
|
|
779
|
+
let refusalBytes = 0;
|
|
567
780
|
let reasoning = "";
|
|
781
|
+
let contentBytes = 0;
|
|
782
|
+
let reasoningBytes = 0;
|
|
568
783
|
const toolCalls: Rec[] = [];
|
|
784
|
+
const append = (previous: string, previousBytes: number, fragment: string): { text: string; bytes: number } => {
|
|
785
|
+
if (!fragment) return { text: previous, bytes: previousBytes };
|
|
786
|
+
const scope = { kind: "retained_collectors" as const };
|
|
787
|
+
const nextBytes = appendedUtf8Bytes(previous, previousBytes, fragment);
|
|
788
|
+
const reservation = translatorBudget?.reserveTransient(nextBytes, scope);
|
|
789
|
+
try {
|
|
790
|
+
const next = previous + fragment;
|
|
791
|
+
reservation?.commitRetained();
|
|
792
|
+
translatorBudget?.releaseRetained(previousBytes, scope);
|
|
793
|
+
return { text: next, bytes: nextBytes };
|
|
794
|
+
} catch (error) {
|
|
795
|
+
reservation?.release();
|
|
796
|
+
throw error;
|
|
797
|
+
}
|
|
798
|
+
};
|
|
569
799
|
|
|
570
800
|
for (const raw of output) {
|
|
571
801
|
if (!isRec(raw)) continue;
|
|
572
802
|
if (raw.type === "message" && Array.isArray(raw.content)) {
|
|
573
803
|
for (const part of raw.content) {
|
|
574
804
|
if (isRec(part) && part.type === "output_text" && typeof part.text === "string") {
|
|
575
|
-
content
|
|
805
|
+
({ text: content, bytes: contentBytes } = append(content, contentBytes, part.text));
|
|
806
|
+
} else if (isRec(part) && part.type === "refusal" && Object.hasOwn(part, "refusal")) {
|
|
807
|
+
if (typeof part.refusal !== "string") throw refusalTranslationError();
|
|
808
|
+
const next = append(refusal ?? "", refusalBytes, part.refusal);
|
|
809
|
+
refusal = next.text;
|
|
810
|
+
refusalBytes = next.bytes;
|
|
576
811
|
}
|
|
577
812
|
}
|
|
578
813
|
} else if (raw.type === "reasoning") {
|
|
579
814
|
if (Array.isArray(raw.summary)) {
|
|
580
815
|
for (const part of raw.summary) {
|
|
581
816
|
if (isRec(part) && part.type === "summary_text" && typeof part.text === "string") {
|
|
582
|
-
reasoning
|
|
817
|
+
({ text: reasoning, bytes: reasoningBytes } = append(reasoning, reasoningBytes, part.text));
|
|
583
818
|
}
|
|
584
819
|
}
|
|
585
820
|
}
|
|
586
821
|
if (Array.isArray(raw.content)) {
|
|
587
822
|
for (const part of raw.content) {
|
|
588
823
|
if (isRec(part) && part.type === "reasoning_text" && typeof part.text === "string") {
|
|
589
|
-
reasoning
|
|
824
|
+
({ text: reasoning, bytes: reasoningBytes } = append(reasoning, reasoningBytes, part.text));
|
|
590
825
|
}
|
|
591
826
|
}
|
|
592
827
|
}
|
|
593
828
|
} else if (raw.type === "function_call") {
|
|
594
|
-
|
|
829
|
+
const call = {
|
|
595
830
|
id: typeof raw.call_id === "string" ? raw.call_id : `call_${uuid().slice(0, 16)}`,
|
|
596
831
|
type: "function",
|
|
597
832
|
function: {
|
|
598
833
|
name: typeof raw.name === "string" ? raw.name : "",
|
|
599
834
|
arguments: typeof raw.arguments === "string" ? raw.arguments : "{}",
|
|
600
835
|
},
|
|
836
|
+
};
|
|
837
|
+
// A complete buffered call still obeys the same per-call cap as live deltas.
|
|
838
|
+
// Reserve before serializing, then transfer ownership to the complete call.
|
|
839
|
+
// The internal scope stays nonempty even when an upstream call_id is empty.
|
|
840
|
+
const argumentsReservation = translatorBudget?.reserveTransient(Buffer.byteLength(call.function.arguments), {
|
|
841
|
+
kind: "tool_args", callId: `chat_json_${toolCalls.length}`,
|
|
601
842
|
});
|
|
843
|
+
try {
|
|
844
|
+
translatorBudget?.chargeRetained(Buffer.byteLength(JSON.stringify(call)), { kind: "retained_collectors" });
|
|
845
|
+
toolCalls.push(call);
|
|
846
|
+
} finally {
|
|
847
|
+
argumentsReservation?.release();
|
|
848
|
+
}
|
|
602
849
|
}
|
|
603
850
|
}
|
|
604
851
|
|
|
605
|
-
const finishReason = toolCalls.length > 0 ? "tool_calls"
|
|
606
|
-
: body.status === "incomplete" ? "length"
|
|
607
|
-
: "stop";
|
|
852
|
+
const finishReason = incompleteFinish ?? (toolCalls.length > 0 ? "tool_calls" : "stop");
|
|
608
853
|
|
|
609
854
|
const message: Rec = {
|
|
610
855
|
role: "assistant",
|
|
611
856
|
content: content || null,
|
|
857
|
+
refusal,
|
|
612
858
|
};
|
|
613
859
|
if (reasoning) message.reasoning_content = reasoning;
|
|
614
860
|
if (toolCalls.length > 0) message.tool_calls = toolCalls;
|
|
@@ -637,6 +883,7 @@ export async function collectChatCompletion(
|
|
|
637
883
|
const decoder = new TextDecoder();
|
|
638
884
|
let buffer = "";
|
|
639
885
|
let content = "";
|
|
886
|
+
let refusal: string | null = null;
|
|
640
887
|
let reasoning = "";
|
|
641
888
|
const toolCalls = new Map<number, { id: string; name: string; arguments: string; argumentBytes: number }>();
|
|
642
889
|
// Per-call budget scopes (2 MiB/call enforced by the budget): the map key is the
|
|
@@ -644,7 +891,6 @@ export async function collectChatCompletion(
|
|
|
644
891
|
const callScope = (index: number) => `chat_collect_${index}`;
|
|
645
892
|
let finishReason = "stop";
|
|
646
893
|
let usage: unknown;
|
|
647
|
-
let streamError: ChatCompletionsStreamError | null = null;
|
|
648
894
|
const replaceRetained = (previous: string, next: string, kind: "live_transient" | "retained_collectors") => {
|
|
649
895
|
const reservation = translatorBudget.reserveTransient(Buffer.byteLength(next), { kind });
|
|
650
896
|
reservation.commitRetained();
|
|
@@ -697,12 +943,12 @@ export async function collectChatCompletion(
|
|
|
697
943
|
: code === CYBER_POLICY_ERROR_CODE || isCyberPolicyMessage(message)
|
|
698
944
|
? 400
|
|
699
945
|
: streamErrorStatus(message);
|
|
700
|
-
streamError = new ChatCompletionsStreamError(message, {
|
|
946
|
+
const streamError = new ChatCompletionsStreamError(message, {
|
|
701
947
|
status,
|
|
702
948
|
type: code === "translation_buffer_limit" ? "upstream_error" : type,
|
|
703
949
|
code,
|
|
704
950
|
});
|
|
705
|
-
|
|
951
|
+
throw streamError;
|
|
706
952
|
}
|
|
707
953
|
if (parsed.usage) usage = parsed.usage;
|
|
708
954
|
const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
|
|
@@ -712,6 +958,10 @@ export async function collectChatCompletion(
|
|
|
712
958
|
const delta = isRec(choice.delta) ? choice.delta : null;
|
|
713
959
|
if (!delta) continue;
|
|
714
960
|
if (typeof delta.content === "string") content = replaceRetained(content, content + delta.content, "retained_collectors");
|
|
961
|
+
if (delta.refusal !== undefined && delta.refusal !== null) {
|
|
962
|
+
if (typeof delta.refusal !== "string") throw refusalTranslationError();
|
|
963
|
+
refusal = replaceRetained(refusal ?? "", (refusal ?? "") + delta.refusal, "retained_collectors");
|
|
964
|
+
}
|
|
715
965
|
if (typeof delta.reasoning_content === "string") reasoning = replaceRetained(reasoning, reasoning + delta.reasoning_content, "retained_collectors");
|
|
716
966
|
if (Array.isArray(delta.tool_calls)) {
|
|
717
967
|
for (const tc of delta.tool_calls) {
|
|
@@ -749,6 +999,10 @@ export async function collectChatCompletion(
|
|
|
749
999
|
}
|
|
750
1000
|
}
|
|
751
1001
|
} catch (error) {
|
|
1002
|
+
// Processing may fail between reads; cancel while we still own the lock so the
|
|
1003
|
+
// upstream translator releases its maps and stops any pending provider read.
|
|
1004
|
+
try { await reader.cancel(error); } catch { /* preserve the original failure */ }
|
|
1005
|
+
translatorBudget.releaseRetained(Buffer.byteLength(refusal ?? ""), { kind: "retained_collectors" });
|
|
752
1006
|
// Never leak an open call scope on the error path; the turn budget's
|
|
753
1007
|
// dispose is a backstop, not the owner of this transfer.
|
|
754
1008
|
for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index));
|
|
@@ -765,14 +1019,11 @@ export async function collectChatCompletion(
|
|
|
765
1019
|
} finally {
|
|
766
1020
|
reader.releaseLock();
|
|
767
1021
|
}
|
|
768
|
-
if (streamError) {
|
|
769
|
-
for (const index of toolCalls.keys()) translatorBudget.closeCall(callScope(index));
|
|
770
|
-
throw streamError;
|
|
771
|
-
}
|
|
772
1022
|
|
|
773
1023
|
const message: Rec = {
|
|
774
1024
|
role: "assistant",
|
|
775
1025
|
content: content || null,
|
|
1026
|
+
refusal,
|
|
776
1027
|
};
|
|
777
1028
|
if (reasoning) message.reasoning_content = reasoning;
|
|
778
1029
|
if (toolCalls.size > 0) {
|