@bitkyc08/opencodex 2.7.23 → 2.7.24
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/README.ko.md +37 -7
- package/README.md +52 -11
- package/README.zh-CN.md +36 -7
- package/bin/ocx.mjs +5 -3
- package/gui/dist/assets/index-BzhyTAco.js +40 -0
- package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opencode.svg +1 -1
- package/package.json +5 -2
- package/src/adapters/anthropic-image-normalize.ts +70 -29
- package/src/adapters/cursor/transport-retry.ts +20 -1
- package/src/adapters/run-turn-queue.ts +40 -0
- package/src/codex/auth-api.ts +10 -1
- package/src/codex/auth-context.ts +33 -7
- package/src/codex/catalog.ts +357 -23
- package/src/codex/routing.ts +10 -4
- package/src/combos/failover.ts +102 -0
- package/src/combos/index.ts +37 -0
- package/src/combos/request.ts +31 -0
- package/src/combos/resolve.ts +171 -0
- package/src/combos/types.ts +203 -0
- package/src/config.ts +280 -11
- package/src/lib/errors.ts +86 -24
- package/src/lib/upstream-retry.ts +8 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/key-providers.ts +2 -32
- package/src/oauth/login-cli.ts +4 -3
- package/src/oauth/token-guardian.ts +38 -3
- package/src/providers/derive.ts +27 -2
- package/src/providers/kiro-models.ts +8 -3
- package/src/providers/label.ts +3 -1
- package/src/providers/openai-sidecar.ts +94 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +283 -0
- package/src/providers/openai-virtual-models.ts +82 -0
- package/src/providers/quota.ts +344 -24
- package/src/providers/registry.ts +112 -20
- package/src/reasoning-effort.ts +12 -11
- package/src/router.ts +80 -36
- package/src/server/auth-cors.ts +85 -9
- package/src/server/images.ts +31 -75
- package/src/server/index.ts +45 -86
- package/src/server/management-api.ts +273 -21
- package/src/server/request-log.ts +221 -20
- package/src/server/responses.ts +594 -75
- package/src/server/search.ts +22 -37
- package/src/types.ts +49 -1
- package/src/update/index.ts +50 -6
- package/src/update/job.ts +21 -4
- package/src/usage/log.ts +124 -1
- package/src/usage/summary.ts +147 -56
- package/src/vision/index.ts +20 -19
- package/src/web-search/index.ts +15 -17
- package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
- package/gui/dist/assets/index-DQjt6Hly.js +0 -40
package/src/server/responses.ts
CHANGED
|
@@ -9,10 +9,24 @@ import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractC
|
|
|
9
9
|
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
10
10
|
import { expandPreviousResponseInput, previousResponseConversationId, rememberResponseState } from "../responses/state";
|
|
11
11
|
import { routeModel } from "../router";
|
|
12
|
+
import {
|
|
13
|
+
advanceComboAfterFailure,
|
|
14
|
+
comboDefaultEffort,
|
|
15
|
+
comboFailureDecision,
|
|
16
|
+
comboIdFromRawBody,
|
|
17
|
+
concreteComboRequestBody,
|
|
18
|
+
getCombo,
|
|
19
|
+
isComboTargetInCooldown,
|
|
20
|
+
NoAvailableComboTargetsError,
|
|
21
|
+
noteComboSuccess,
|
|
22
|
+
parseRetryAfterMs,
|
|
23
|
+
pickComboTarget,
|
|
24
|
+
targetKey,
|
|
25
|
+
} from "../combos";
|
|
12
26
|
import { isInjectionDebugEnabled } from "../lib/debug-settings";
|
|
13
27
|
import { injectionDebugLog } from "../lib/injection-debug-log";
|
|
14
28
|
import { modelInList, namespacedToolName } from "../types";
|
|
15
|
-
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types";
|
|
29
|
+
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../types";
|
|
16
30
|
import {
|
|
17
31
|
forceRefreshOAuthAccessSnapshot,
|
|
18
32
|
getOAuthCredentialProjectId,
|
|
@@ -20,13 +34,15 @@ import {
|
|
|
20
34
|
type OAuthAccessSnapshot,
|
|
21
35
|
UnsupportedOAuthProviderError,
|
|
22
36
|
} from "../oauth";
|
|
23
|
-
import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "../web-search";
|
|
24
|
-
import { describeImagesInPlace, planVisionSidecar, stripImagesInPlace } from "../vision";
|
|
25
|
-
import { createAdapterEventQueue } from "../adapters/run-turn-queue";
|
|
37
|
+
import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../web-search";
|
|
38
|
+
import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../vision";
|
|
39
|
+
import { createAdapterEventQueue, preflightAdapterEvents } from "../adapters/run-turn-queue";
|
|
26
40
|
import {
|
|
27
41
|
applyCodexAuthContextToProvider,
|
|
28
42
|
CodexAccountCooldownError,
|
|
29
43
|
CodexAuthContextError,
|
|
44
|
+
CodexDirectAuthenticationError,
|
|
45
|
+
CodexPoolAuthenticationError,
|
|
30
46
|
CodexThreadAffinityExpiredError,
|
|
31
47
|
headersForCodexAuthContext,
|
|
32
48
|
isCodexAuthContextUsable,
|
|
@@ -39,6 +55,9 @@ import {
|
|
|
39
55
|
type CodexUpstreamOutcome,
|
|
40
56
|
} from "../codex/routing";
|
|
41
57
|
import { fetchWithResetRetry, fetchWithTransientRetry } from "../lib/upstream-retry";
|
|
58
|
+
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
59
|
+
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
|
|
60
|
+
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../providers/openai-virtual-models";
|
|
42
61
|
import { isUsageDebugEnabled } from "../usage/debug";
|
|
43
62
|
import { readJsonRequestBody, DecompressedBodyTooLargeError, UnsupportedContentEncodingError } from "./request-decompress";
|
|
44
63
|
import { resolveAdapter, resolveWireProtocolOverride } from "./adapter-resolve";
|
|
@@ -48,13 +67,20 @@ import { resolveProviderTransport } from "../providers/xai-transport";
|
|
|
48
67
|
import type { WsData } from "./ws-bridge";
|
|
49
68
|
import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
|
|
50
69
|
import { redactSecretString } from "../lib/redact";
|
|
70
|
+
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
51
71
|
import {
|
|
72
|
+
beginRequestAttempt,
|
|
52
73
|
catalogModelSupportsServiceTier,
|
|
74
|
+
finishRequestAttempt,
|
|
53
75
|
inspectResponseLogJson,
|
|
76
|
+
noteAttemptSend,
|
|
54
77
|
readConfiguredCodexServiceTier,
|
|
55
78
|
requestLogSpeedLabel,
|
|
79
|
+
sealRequestAttemptIdentity,
|
|
80
|
+
usageFromResponsesPayload,
|
|
56
81
|
type RequestLogContext,
|
|
57
82
|
} from "./request-log";
|
|
83
|
+
import type { AttemptRecoveryKind } from "../usage/log";
|
|
58
84
|
import {
|
|
59
85
|
consumeForInspection,
|
|
60
86
|
consumeForResponseLogMetadata,
|
|
@@ -414,20 +440,305 @@ export function decodeRequestErrorResponse(err: unknown, label: string): Respons
|
|
|
414
440
|
return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
|
|
415
441
|
}
|
|
416
442
|
|
|
443
|
+
function comboUnavailableResponse(message: string): Response {
|
|
444
|
+
return new Response(
|
|
445
|
+
JSON.stringify({
|
|
446
|
+
error: { message, type: "server_error", code: "combo_unavailable" },
|
|
447
|
+
}),
|
|
448
|
+
{ status: 503, headers: { "Content-Type": "application/json" } },
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
interface ConsumedComboFailure {
|
|
453
|
+
response: Response;
|
|
454
|
+
classificationText: string;
|
|
455
|
+
/** Valid numeric/date value used only for cooldown calculation. */
|
|
456
|
+
retryAfter?: string;
|
|
457
|
+
/** Reserved for 040 usage attribution without adding another body read. */
|
|
458
|
+
usage?: OcxUsage;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
interface HandleResponsesOptions {
|
|
462
|
+
forceEmptyResponseId?: boolean;
|
|
463
|
+
abortSignal?: AbortSignal;
|
|
464
|
+
onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
|
|
465
|
+
recordTerminalOutcomes?: boolean;
|
|
466
|
+
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
|
|
467
|
+
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
468
|
+
onNativePassthroughCancel?: () => void;
|
|
469
|
+
/** Internal recursion guard; callers outside this module must not set it. */
|
|
470
|
+
comboAttempt?: boolean;
|
|
471
|
+
/** 030-owned handoff when a child consumed the original failure under bounds. */
|
|
472
|
+
onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function clientCancelledResponse(): Response {
|
|
476
|
+
return formatErrorResponse(499, "client_cancelled", "Client cancelled request");
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function sanitizedRetryAfter(value: string | null, now: number): string | undefined {
|
|
480
|
+
const trimmed = value?.trim();
|
|
481
|
+
if (!trimmed || trimmed.length > 128) return undefined;
|
|
482
|
+
return parseRetryAfterMs(trimmed, now) !== undefined ? trimmed : undefined;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
async function consumeComboFailure(
|
|
486
|
+
response: Response,
|
|
487
|
+
signal?: AbortSignal,
|
|
488
|
+
now = Date.now(),
|
|
489
|
+
): Promise<ConsumedComboFailure> {
|
|
490
|
+
const fallback = `Provider error ${response.status}`;
|
|
491
|
+
let classificationText = fallback;
|
|
492
|
+
let usage: OcxUsage | undefined;
|
|
493
|
+
try {
|
|
494
|
+
const body = await readBoundedResponseBody(response, { signal });
|
|
495
|
+
usage = usageFromComboFailureText(body.text);
|
|
496
|
+
if (body.displaySafe) {
|
|
497
|
+
const safeText = redactSecretString(body.text).slice(0, 500);
|
|
498
|
+
if (safeText) classificationText = safeText;
|
|
499
|
+
}
|
|
500
|
+
} catch (error) {
|
|
501
|
+
if (signal?.aborted) throw error;
|
|
502
|
+
classificationText = fallback;
|
|
503
|
+
}
|
|
504
|
+
const message = classificationText === fallback
|
|
505
|
+
? fallback
|
|
506
|
+
: `${fallback}: ${classificationText}`;
|
|
507
|
+
const retryAfter = sanitizedRetryAfter(response.headers.get("retry-after"), now);
|
|
508
|
+
return {
|
|
509
|
+
response: formatErrorResponse(response.status, "upstream_error", message),
|
|
510
|
+
classificationText,
|
|
511
|
+
...(retryAfter !== undefined ? { retryAfter } : {}),
|
|
512
|
+
...(usage ? { usage } : {}),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function usageFromComboFailureText(text: string): OcxUsage | undefined {
|
|
517
|
+
try {
|
|
518
|
+
const payload = JSON.parse(text) as Record<string, unknown>;
|
|
519
|
+
const nested = payload.response;
|
|
520
|
+
const source = nested && typeof nested === "object" && !Array.isArray(nested)
|
|
521
|
+
? nested as Record<string, unknown>
|
|
522
|
+
: payload;
|
|
523
|
+
return usageFromResponsesPayload(source.usage);
|
|
524
|
+
} catch {
|
|
525
|
+
return undefined;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function createChildPassthroughCallbackGate(options: HandleResponsesOptions) {
|
|
530
|
+
type Pending =
|
|
531
|
+
| { kind: "terminal"; status: ResponsesTerminalStatus }
|
|
532
|
+
| { kind: "cancel" };
|
|
533
|
+
let state: "pending" | "committed" | "discarded" = "pending";
|
|
534
|
+
let pending: Pending | undefined;
|
|
535
|
+
let accepted = false;
|
|
536
|
+
const publish = (value: Pending): void => {
|
|
537
|
+
if (value.kind === "terminal") options.onNativePassthroughTerminal?.(value.status);
|
|
538
|
+
else options.onNativePassthroughCancel?.();
|
|
539
|
+
};
|
|
540
|
+
const receive = (value: Pending): void => {
|
|
541
|
+
if (state === "discarded" || accepted) return;
|
|
542
|
+
accepted = true;
|
|
543
|
+
if (state === "committed") return publish(value);
|
|
544
|
+
pending ??= value;
|
|
545
|
+
};
|
|
546
|
+
return {
|
|
547
|
+
onTerminal: (status: ResponsesTerminalStatus) => receive({ kind: "terminal", status }),
|
|
548
|
+
onCancel: () => receive({ kind: "cancel" }),
|
|
549
|
+
commit: () => {
|
|
550
|
+
if (state !== "pending") return;
|
|
551
|
+
state = "committed";
|
|
552
|
+
if (pending) publish(pending);
|
|
553
|
+
pending = undefined;
|
|
554
|
+
},
|
|
555
|
+
discard: () => {
|
|
556
|
+
state = "discarded";
|
|
557
|
+
pending = undefined;
|
|
558
|
+
},
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function handleComboResponses(
|
|
563
|
+
req: Request,
|
|
564
|
+
rawBody: unknown,
|
|
565
|
+
comboId: string,
|
|
566
|
+
config: OcxConfig,
|
|
567
|
+
logCtx: RequestLogContext,
|
|
568
|
+
options: HandleResponsesOptions,
|
|
569
|
+
): Promise<Response> {
|
|
570
|
+
Object.assign(logCtx, {
|
|
571
|
+
requestedModel: `combo/${comboId}`,
|
|
572
|
+
model: `combo/${comboId}`,
|
|
573
|
+
provider: "combo",
|
|
574
|
+
});
|
|
575
|
+
const combo = getCombo(config, comboId);
|
|
576
|
+
if (!combo) {
|
|
577
|
+
return formatErrorResponse(404, "invalid_request_error", `Unknown combo: ${comboId}`);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const initialNow = Date.now();
|
|
581
|
+
let pick = pickComboTarget(config, comboId, {
|
|
582
|
+
eligible: target => !isComboTargetInCooldown(comboId, target, initialNow),
|
|
583
|
+
});
|
|
584
|
+
if (!pick) {
|
|
585
|
+
return comboUnavailableResponse(`No available targets for combo: ${comboId}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
let lastFailure: Response | null = null;
|
|
589
|
+
while (pick) {
|
|
590
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
591
|
+
const childLog: RequestLogContext = {
|
|
592
|
+
model: pick.target.model,
|
|
593
|
+
provider: pick.target.provider,
|
|
594
|
+
};
|
|
595
|
+
const childBody = concreteComboRequestBody(
|
|
596
|
+
rawBody,
|
|
597
|
+
pick.target,
|
|
598
|
+
comboDefaultEffort(config, comboId),
|
|
599
|
+
);
|
|
600
|
+
const childHeaders = new Headers(req.headers);
|
|
601
|
+
childHeaders.delete("content-length");
|
|
602
|
+
const childRequest = new Request(req.url, {
|
|
603
|
+
method: req.method,
|
|
604
|
+
headers: childHeaders,
|
|
605
|
+
body: JSON.stringify(childBody),
|
|
606
|
+
});
|
|
607
|
+
let resolvedAuth: CodexAuthContext | undefined;
|
|
608
|
+
let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
|
|
609
|
+
const started = Date.now();
|
|
610
|
+
const attempt = beginRequestAttempt(
|
|
611
|
+
(logCtx.attempts?.length ?? 0) + 1,
|
|
612
|
+
pick.target.provider,
|
|
613
|
+
pick.target.model,
|
|
614
|
+
config.providers[pick.target.provider]!.adapter,
|
|
615
|
+
);
|
|
616
|
+
childLog.activeAttempt = attempt;
|
|
617
|
+
let attemptRetained = false;
|
|
618
|
+
const retainCancelledAttempt = (): void => {
|
|
619
|
+
if (attemptRetained) return;
|
|
620
|
+
sealRequestAttemptIdentity(
|
|
621
|
+
attempt,
|
|
622
|
+
childLog.provider,
|
|
623
|
+
childLog.providerAdapter ?? attempt.adapter,
|
|
624
|
+
);
|
|
625
|
+
finishRequestAttempt(attempt, 499, Date.now() - started, childLog.usage);
|
|
626
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
627
|
+
attemptRetained = true;
|
|
628
|
+
};
|
|
629
|
+
let consumedChildFailure: ConsumedComboFailure | undefined;
|
|
630
|
+
const callbackGate = createChildPassthroughCallbackGate(options);
|
|
631
|
+
let response: Response;
|
|
632
|
+
try {
|
|
633
|
+
response = await handleResponses(childRequest, config, childLog, {
|
|
634
|
+
...options,
|
|
635
|
+
comboAttempt: true,
|
|
636
|
+
onCodexAuthContextResolved: value => { resolvedAuth = value; },
|
|
637
|
+
setTerminalOutcomeRecorder: value => { terminalRecorder = value; },
|
|
638
|
+
onConsumedComboFailure: value => { consumedChildFailure = value; },
|
|
639
|
+
onNativePassthroughTerminal: callbackGate.onTerminal,
|
|
640
|
+
onNativePassthroughCancel: callbackGate.onCancel,
|
|
641
|
+
});
|
|
642
|
+
} catch (error) {
|
|
643
|
+
callbackGate.discard();
|
|
644
|
+
if (options.abortSignal?.aborted) {
|
|
645
|
+
retainCancelledAttempt();
|
|
646
|
+
return clientCancelledResponse();
|
|
647
|
+
}
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
if (options.abortSignal?.aborted) {
|
|
652
|
+
callbackGate.discard();
|
|
653
|
+
retainCancelledAttempt();
|
|
654
|
+
return clientCancelledResponse();
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
if (response.ok) {
|
|
658
|
+
sealRequestAttemptIdentity(
|
|
659
|
+
attempt,
|
|
660
|
+
childLog.provider,
|
|
661
|
+
childLog.providerAdapter ?? attempt.adapter,
|
|
662
|
+
);
|
|
663
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
664
|
+
attemptRetained = true;
|
|
665
|
+
noteComboSuccess(comboId, combo, pick.target);
|
|
666
|
+
Object.assign(logCtx, childLog, {
|
|
667
|
+
requestedModel: `combo/${comboId}`,
|
|
668
|
+
model: `combo/${comboId}`,
|
|
669
|
+
provider: "combo",
|
|
670
|
+
attempts: logCtx.attempts,
|
|
671
|
+
activeAttempt: attempt,
|
|
672
|
+
activeAttemptStartedAt: started,
|
|
673
|
+
resolvedModel: childLog.resolvedModel ?? childLog.model,
|
|
674
|
+
});
|
|
675
|
+
options.onCodexAuthContextResolved?.(resolvedAuth);
|
|
676
|
+
options.setTerminalOutcomeRecorder?.(terminalRecorder);
|
|
677
|
+
callbackGate.commit();
|
|
678
|
+
return response;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
callbackGate.discard();
|
|
682
|
+
if (response.status === 499) {
|
|
683
|
+
retainCancelledAttempt();
|
|
684
|
+
return clientCancelledResponse();
|
|
685
|
+
}
|
|
686
|
+
let failure: ConsumedComboFailure;
|
|
687
|
+
try {
|
|
688
|
+
failure = consumedChildFailure
|
|
689
|
+
?? await consumeComboFailure(response, options.abortSignal);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
if (options.abortSignal?.aborted) {
|
|
692
|
+
retainCancelledAttempt();
|
|
693
|
+
return clientCancelledResponse();
|
|
694
|
+
}
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
if (options.abortSignal?.aborted) {
|
|
698
|
+
retainCancelledAttempt();
|
|
699
|
+
return clientCancelledResponse();
|
|
700
|
+
}
|
|
701
|
+
sealRequestAttemptIdentity(
|
|
702
|
+
attempt,
|
|
703
|
+
childLog.provider,
|
|
704
|
+
childLog.providerAdapter ?? attempt.adapter,
|
|
705
|
+
);
|
|
706
|
+
finishRequestAttempt(
|
|
707
|
+
attempt,
|
|
708
|
+
response.status,
|
|
709
|
+
Date.now() - started,
|
|
710
|
+
failure.usage,
|
|
711
|
+
);
|
|
712
|
+
(logCtx.attempts ??= []).push(attempt);
|
|
713
|
+
attemptRetained = true;
|
|
714
|
+
lastFailure = failure.response;
|
|
715
|
+
if (comboFailureDecision(response.status, failure.classificationText) === "stop") {
|
|
716
|
+
Object.assign(logCtx, childLog, {
|
|
717
|
+
requestedModel: `combo/${comboId}`,
|
|
718
|
+
model: `combo/${comboId}`,
|
|
719
|
+
provider: "combo",
|
|
720
|
+
attempts: logCtx.attempts,
|
|
721
|
+
activeAttempt: undefined,
|
|
722
|
+
activeAttemptStartedAt: undefined,
|
|
723
|
+
});
|
|
724
|
+
return lastFailure;
|
|
725
|
+
}
|
|
726
|
+
console.warn(
|
|
727
|
+
`[combo] ${comboId}: ${targetKey(pick.target)} failed with ${response.status} after ${Date.now() - started}ms`,
|
|
728
|
+
);
|
|
729
|
+
pick = advanceComboAfterFailure(config, pick, {
|
|
730
|
+
retryAfter: failure.retryAfter,
|
|
731
|
+
now: Date.now(),
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
return lastFailure!;
|
|
735
|
+
}
|
|
736
|
+
|
|
417
737
|
export async function handleResponses(
|
|
418
738
|
req: Request,
|
|
419
739
|
config: OcxConfig,
|
|
420
740
|
logCtx: RequestLogContext,
|
|
421
|
-
options: {
|
|
422
|
-
forceEmptyResponseId?: boolean;
|
|
423
|
-
abortSignal?: AbortSignal;
|
|
424
|
-
authContext?: CodexAuthContext;
|
|
425
|
-
selectedForwardHeaders?: Headers;
|
|
426
|
-
recordTerminalOutcomes?: boolean;
|
|
427
|
-
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
|
|
428
|
-
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
429
|
-
onNativePassthroughCancel?: () => void;
|
|
430
|
-
} = {},
|
|
741
|
+
options: HandleResponsesOptions = {},
|
|
431
742
|
): Promise<Response> {
|
|
432
743
|
let body: unknown;
|
|
433
744
|
try {
|
|
@@ -435,6 +746,10 @@ export async function handleResponses(
|
|
|
435
746
|
} catch (err) {
|
|
436
747
|
return decodeRequestErrorResponse(err, "responses");
|
|
437
748
|
}
|
|
749
|
+
const comboId = !options.comboAttempt ? comboIdFromRawBody(body) : null;
|
|
750
|
+
if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
|
|
751
|
+
return handleComboResponses(req, body, comboId, config, logCtx, options);
|
|
752
|
+
}
|
|
438
753
|
const originalBody = body;
|
|
439
754
|
body = expandPreviousResponseInput(body);
|
|
440
755
|
const previousResponseInputExpanded = body !== originalBody;
|
|
@@ -489,6 +804,9 @@ export async function handleResponses(
|
|
|
489
804
|
try {
|
|
490
805
|
route = routeModel(config, parsed.modelId);
|
|
491
806
|
} catch (err) {
|
|
807
|
+
if (err instanceof NoAvailableComboTargetsError) {
|
|
808
|
+
return comboUnavailableResponse(err.message);
|
|
809
|
+
}
|
|
492
810
|
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
493
811
|
}
|
|
494
812
|
|
|
@@ -505,6 +823,10 @@ export async function handleResponses(
|
|
|
505
823
|
logCtx.provider = route.providerName;
|
|
506
824
|
logCtx.providerAdapter = route.provider.adapter;
|
|
507
825
|
|
|
826
|
+
// Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro".
|
|
827
|
+
// Must run before effort caps/native clamps so the base model gets correct limits.
|
|
828
|
+
applyOpenAiVirtualModel(parsed, route, logCtx);
|
|
829
|
+
|
|
508
830
|
// Fast mode override: when config.fastMode is explicitly set, inject or strip
|
|
509
831
|
// service_tier for OpenAI-routed models. Undefined = passthrough (client decides).
|
|
510
832
|
if (config.fastMode !== undefined && route.provider.adapter === "openai-responses") {
|
|
@@ -586,11 +908,17 @@ export async function handleResponses(
|
|
|
586
908
|
logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
|
|
587
909
|
);
|
|
588
910
|
|
|
589
|
-
let authCtx: CodexAuthContext;
|
|
911
|
+
let authCtx: CodexAuthContext = { kind: "main", accountId: null };
|
|
590
912
|
let selectedForwardHeaders: Headers;
|
|
591
913
|
try {
|
|
592
|
-
|
|
593
|
-
|
|
914
|
+
if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
|
|
915
|
+
if (route.codexAccountMode) {
|
|
916
|
+
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
|
|
917
|
+
options.onCodexAuthContextResolved?.(authCtx);
|
|
918
|
+
} else {
|
|
919
|
+
options.onCodexAuthContextResolved?.(undefined);
|
|
920
|
+
}
|
|
921
|
+
selectedForwardHeaders = headersForCodexAuthContext(req.headers, authCtx);
|
|
594
922
|
} catch (err) {
|
|
595
923
|
if (err instanceof CodexAccountCooldownError) {
|
|
596
924
|
return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
|
|
@@ -603,12 +931,21 @@ export async function handleResponses(
|
|
|
603
931
|
console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
604
932
|
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
605
933
|
}
|
|
934
|
+
if (err instanceof CodexPoolAuthenticationError) {
|
|
935
|
+
return formatErrorResponse(401, "authentication_error", err.message);
|
|
936
|
+
}
|
|
937
|
+
if (err instanceof CodexDirectAuthenticationError) {
|
|
938
|
+
return formatErrorResponse(401, "authentication_error", err.message);
|
|
939
|
+
}
|
|
940
|
+
if (err instanceof ForwardAdmissionCredentialError) {
|
|
941
|
+
return formatErrorResponse(401, "authentication_error", err.message);
|
|
942
|
+
}
|
|
606
943
|
throw err;
|
|
607
944
|
}
|
|
608
945
|
if (!isCodexAuthContextUsable(authCtx, config)) {
|
|
609
946
|
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
610
947
|
}
|
|
611
|
-
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx);
|
|
948
|
+
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
612
949
|
logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
|
|
613
950
|
|
|
614
951
|
// OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
|
|
@@ -638,22 +975,48 @@ export async function handleResponses(
|
|
|
638
975
|
}
|
|
639
976
|
}
|
|
640
977
|
route.provider = resolveProviderTransport(route.providerName, route.provider, parsed.options.promptCacheKey);
|
|
978
|
+
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
979
|
+
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
|
|
980
|
+
logCtx.providerAdapter = adapter.name;
|
|
981
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, adapter.name);
|
|
982
|
+
const isPassthrough = "passthrough" in adapter && !!adapter.passthrough;
|
|
983
|
+
|
|
984
|
+
let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined;
|
|
985
|
+
const needsOpenAiVision = shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed);
|
|
986
|
+
const needsOpenAiSearch = shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough);
|
|
987
|
+
if (needsOpenAiVision || needsOpenAiSearch) {
|
|
988
|
+
try {
|
|
989
|
+
openAiSidecar = await resolveFirstUsableOpenAiSidecar(
|
|
990
|
+
listOpenAiForwardSidecarCandidates(config),
|
|
991
|
+
req.headers,
|
|
992
|
+
config,
|
|
993
|
+
);
|
|
994
|
+
} catch (err) {
|
|
995
|
+
// Sidecars are optional helpers for an otherwise independent routed turn.
|
|
996
|
+
// An unavailable/cooling/expired Multi credential disables the helper; it
|
|
997
|
+
// must not turn a valid routed-provider request into a Codex-auth failure.
|
|
998
|
+
if (
|
|
999
|
+
!(err instanceof CodexPoolAuthenticationError)
|
|
1000
|
+
&& !(err instanceof CodexAuthContextError)
|
|
1001
|
+
&& !(err instanceof CodexAccountCooldownError)
|
|
1002
|
+
&& !(err instanceof CodexThreadAffinityExpiredError)
|
|
1003
|
+
) throw err;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
641
1006
|
|
|
642
1007
|
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
|
|
643
1008
|
// attached image through the selected sidecar backend and replace it with text BEFORE the main
|
|
644
1009
|
// call, so the text-only model can reason about it.
|
|
645
|
-
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed,
|
|
646
|
-
const recordSidecarOutcome =
|
|
1010
|
+
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar);
|
|
1011
|
+
const recordSidecarOutcome = openAiSidecar?.recordOutcome;
|
|
647
1012
|
if (visionPlan) {
|
|
648
|
-
await describeImagesInPlace(parsed, visionPlan, selectedForwardHeaders, options.abortSignal, recordSidecarOutcome);
|
|
1013
|
+
await describeImagesInPlace(parsed, visionPlan, openAiSidecar?.headers ?? selectedForwardHeaders, options.abortSignal, recordSidecarOutcome);
|
|
649
1014
|
} else if (modelInList(route.provider.noVisionModels, route.modelId)) {
|
|
650
1015
|
// Sidecar-covered model but NO plan (no forward provider / missing forwarded auth / sidecar
|
|
651
1016
|
// disabled): fail closed — never forward raw images to a text-only upstream.
|
|
652
1017
|
stripImagesInPlace(parsed);
|
|
653
1018
|
}
|
|
654
1019
|
|
|
655
|
-
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
656
|
-
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
|
|
657
1020
|
const recordTerminalOutcomes = options.recordTerminalOutcomes !== false;
|
|
658
1021
|
|
|
659
1022
|
// Remote compaction v2 on a ROUTED model: Codex sent `compaction_trigger` and requires exactly
|
|
@@ -692,6 +1055,12 @@ export async function handleResponses(
|
|
|
692
1055
|
);
|
|
693
1056
|
}
|
|
694
1057
|
const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
1058
|
+
const passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
1059
|
+
? request.usageLog.inputTokens
|
|
1060
|
+
: undefined;
|
|
1061
|
+
if (passthroughEstimate !== undefined) {
|
|
1062
|
+
logCtx.usageLogInputTokens = passthroughEstimate;
|
|
1063
|
+
}
|
|
695
1064
|
// Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
|
|
696
1065
|
// consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
|
|
697
1066
|
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
|
|
@@ -704,15 +1073,19 @@ export async function handleResponses(
|
|
|
704
1073
|
// the ChatGPT backend emits transient 502/520s that an immediate retry absorbs.
|
|
705
1074
|
// Body is a replayable string; nothing has streamed to the client yet.
|
|
706
1075
|
upstreamResponse = await fetchWithTransientRetry(
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1076
|
+
recovery => {
|
|
1077
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery);
|
|
1078
|
+
return fetchWithHeaderTimeout(request.url, {
|
|
1079
|
+
method: request.method,
|
|
1080
|
+
headers: request.headers,
|
|
1081
|
+
body: request.body,
|
|
1082
|
+
}, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
1083
|
+
},
|
|
712
1084
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
713
1085
|
);
|
|
714
1086
|
} catch (err) {
|
|
715
1087
|
upstream.abort();
|
|
1088
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
716
1089
|
const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
717
1090
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) recordCodexUpstreamOutcome(config, authCtx.accountId, outcome);
|
|
718
1091
|
const msg = outcome === "timeout"
|
|
@@ -774,7 +1147,7 @@ export async function handleResponses(
|
|
|
774
1147
|
// async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun
|
|
775
1148
|
// native relay, never enters JS Sink.write); branch[1] is consumed in the
|
|
776
1149
|
// background for terminal-outcome/quota inspection only.
|
|
777
|
-
if (isEventStream && upstreamResponse.body) {
|
|
1150
|
+
if (upstreamResponse.ok && isEventStream && upstreamResponse.body) {
|
|
778
1151
|
const [nativeBody, inspectBody] = upstreamResponse.body.tee();
|
|
779
1152
|
const turnAc = new AbortController();
|
|
780
1153
|
linkAbortSignal(upstream, turnAc.signal);
|
|
@@ -813,6 +1186,11 @@ export async function handleResponses(
|
|
|
813
1186
|
}));
|
|
814
1187
|
}
|
|
815
1188
|
if (headers.get("content-type")?.toLowerCase().includes("application/json")) {
|
|
1189
|
+
if (!upstreamResponse.ok && options.comboAttempt) {
|
|
1190
|
+
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal);
|
|
1191
|
+
options.onConsumedComboFailure?.(failure);
|
|
1192
|
+
return failure.response;
|
|
1193
|
+
}
|
|
816
1194
|
const text = await upstreamResponse.text();
|
|
817
1195
|
inspectResponseLogJson(logCtx, text);
|
|
818
1196
|
if (upstreamResponse.ok && rememberPassthroughResponse) {
|
|
@@ -841,6 +1219,7 @@ export async function handleResponses(
|
|
|
841
1219
|
const queue = createAdapterEventQueue();
|
|
842
1220
|
const runTurn = async (): Promise<void> => {
|
|
843
1221
|
try {
|
|
1222
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
844
1223
|
await adapter.runTurn?.(
|
|
845
1224
|
parsed,
|
|
846
1225
|
{ headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal },
|
|
@@ -859,8 +1238,19 @@ export async function handleResponses(
|
|
|
859
1238
|
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
860
1239
|
if (parsed.stream) {
|
|
861
1240
|
void runTurn();
|
|
1241
|
+
let eventSource: AsyncIterable<AdapterEvent> = queue.stream();
|
|
1242
|
+
if (options.comboAttempt) {
|
|
1243
|
+
const preflight = await preflightAdapterEvents(eventSource);
|
|
1244
|
+
if (preflight.error || preflight.empty) {
|
|
1245
|
+
runTurnAbort.abort();
|
|
1246
|
+
queue.close();
|
|
1247
|
+
const message = preflight.error?.message ?? "Adapter ended before producing a response";
|
|
1248
|
+
return formatErrorResponse(502, "upstream_error", redactSecretString(message));
|
|
1249
|
+
}
|
|
1250
|
+
eventSource = preflight.stream;
|
|
1251
|
+
}
|
|
862
1252
|
const sseStream = bridgeToResponsesSSE(
|
|
863
|
-
|
|
1253
|
+
eventSource, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
864
1254
|
() => {
|
|
865
1255
|
runTurnAbort.abort();
|
|
866
1256
|
queue.close();
|
|
@@ -882,6 +1272,15 @@ export async function handleResponses(
|
|
|
882
1272
|
|
|
883
1273
|
await runTurn();
|
|
884
1274
|
const events = await queue.collect();
|
|
1275
|
+
if (options.comboAttempt) {
|
|
1276
|
+
const firstMeaningful = events.find(event => event.type !== "heartbeat");
|
|
1277
|
+
if (!firstMeaningful || firstMeaningful.type === "error") {
|
|
1278
|
+
const message = firstMeaningful?.type === "error"
|
|
1279
|
+
? firstMeaningful.message
|
|
1280
|
+
: "Adapter ended before producing a response";
|
|
1281
|
+
return formatErrorResponse(502, "upstream_error", redactSecretString(message));
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
885
1284
|
const json = buildResponseJSON(events, parsed.modelId, {
|
|
886
1285
|
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
887
1286
|
toolNsMap,
|
|
@@ -896,21 +1295,22 @@ export async function handleResponses(
|
|
|
896
1295
|
// Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
|
|
897
1296
|
// run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
|
|
898
1297
|
// through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
|
|
899
|
-
const wsPlan = planWebSearch(config, parsed, false,
|
|
1298
|
+
const wsPlan = planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar);
|
|
900
1299
|
if (wsPlan) {
|
|
901
1300
|
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
1301
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
902
1302
|
const wsResponse = await runWithWebSearch({
|
|
903
1303
|
parsed, adapter,
|
|
904
1304
|
backend: wsPlan.backend,
|
|
905
|
-
forwardProvider: wsPlan.
|
|
1305
|
+
forwardProvider: wsPlan.forwardSidecar?.provider,
|
|
906
1306
|
anthropicSidecar: wsPlan.anthropicSidecar,
|
|
907
1307
|
hostedTool: wsPlan.hostedTool,
|
|
908
|
-
selectedForwardHeaders,
|
|
1308
|
+
selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
|
|
909
1309
|
settings: wsPlan.settings,
|
|
910
1310
|
maxSearches: wsPlan.maxSearches,
|
|
911
1311
|
forceEmptyResponseId: true,
|
|
912
1312
|
abortSignal: options.abortSignal,
|
|
913
|
-
recordSidecarOutcome,
|
|
1313
|
+
recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
|
|
914
1314
|
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
915
1315
|
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
916
1316
|
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
@@ -946,22 +1346,34 @@ export async function handleResponses(
|
|
|
946
1346
|
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
947
1347
|
|
|
948
1348
|
const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1349
|
+
const inputTokenEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
1350
|
+
? request.usageLog.inputTokens
|
|
1351
|
+
: undefined;
|
|
1352
|
+
if (inputTokenEstimate !== undefined) logCtx.usageLogInputTokens = inputTokenEstimate;
|
|
952
1353
|
let upstreamResponse: Response;
|
|
953
1354
|
try {
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1355
|
+
if (adapter.fetchResponse) {
|
|
1356
|
+
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate);
|
|
1357
|
+
upstreamResponse = await adapter.fetchResponse(request, {
|
|
1358
|
+
abortSignal: upstream.signal,
|
|
1359
|
+
timeoutMs: connectMs,
|
|
1360
|
+
stream: parsed.stream,
|
|
1361
|
+
});
|
|
1362
|
+
} else {
|
|
1363
|
+
upstreamResponse = await fetchWithResetRetry(
|
|
1364
|
+
recovery => {
|
|
1365
|
+
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
|
|
1366
|
+
return fetchWithHeaderTimeout(request.url, {
|
|
958
1367
|
method: request.method, headers: request.headers, body: request.body,
|
|
959
|
-
}, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider))
|
|
960
|
-
|
|
961
|
-
)
|
|
1368
|
+
}, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
1369
|
+
},
|
|
1370
|
+
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
1371
|
+
);
|
|
1372
|
+
}
|
|
962
1373
|
} catch (err) {
|
|
963
1374
|
cleanupUpstreamAbort();
|
|
964
1375
|
upstream.abort();
|
|
1376
|
+
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
965
1377
|
const msg = err instanceof Error && err.name === "TimeoutError"
|
|
966
1378
|
? `Provider connect timeout after ${connectMs}ms`
|
|
967
1379
|
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -978,11 +1390,20 @@ export async function handleResponses(
|
|
|
978
1390
|
let imageTierBias = 0;
|
|
979
1391
|
let imageRetryAttempted = false;
|
|
980
1392
|
let oauth401ReplayAttempted = false;
|
|
981
|
-
const rebuildAndRefetch = async (
|
|
1393
|
+
const rebuildAndRefetch = async (
|
|
1394
|
+
recovery: AttemptRecoveryKind,
|
|
1395
|
+
): Promise<Response | { failed: Response }> => {
|
|
982
1396
|
const retryRequest = await activeAdapter.buildRequest(parsed, {
|
|
983
1397
|
headers: selectedForwardHeaders,
|
|
984
1398
|
...(imageTierBias > 0 ? { imageTierBias } : {}),
|
|
985
1399
|
});
|
|
1400
|
+
const retryEstimate = typeof retryRequest.usageLog?.inputTokens === "number"
|
|
1401
|
+
? retryRequest.usageLog.inputTokens
|
|
1402
|
+
: undefined;
|
|
1403
|
+
if (retryEstimate !== undefined) logCtx.usageLogInputTokens = retryEstimate;
|
|
1404
|
+
logCtx.providerAdapter = activeAdapter.name;
|
|
1405
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
1406
|
+
noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
|
|
986
1407
|
try {
|
|
987
1408
|
return activeAdapter.fetchResponse
|
|
988
1409
|
? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
|
|
@@ -992,6 +1413,9 @@ export async function handleResponses(
|
|
|
992
1413
|
} catch (err) {
|
|
993
1414
|
cleanupUpstreamAbort();
|
|
994
1415
|
upstream.abort();
|
|
1416
|
+
if (options.abortSignal?.aborted) {
|
|
1417
|
+
return { failed: clientCancelledResponse() };
|
|
1418
|
+
}
|
|
995
1419
|
const msg = err instanceof Error && err.name === "TimeoutError"
|
|
996
1420
|
? `Provider connect timeout after ${connectMs}ms`
|
|
997
1421
|
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -1025,7 +1449,7 @@ export async function handleResponses(
|
|
|
1025
1449
|
resolveWireProtocolOverride(route.providerName, route.modelId, refreshedProvider),
|
|
1026
1450
|
config.cacheRetention,
|
|
1027
1451
|
);
|
|
1028
|
-
const result = await rebuildAndRefetch();
|
|
1452
|
+
const result = await rebuildAndRefetch("oauth-401");
|
|
1029
1453
|
if ("failed" in result) return result.failed;
|
|
1030
1454
|
upstreamResponse = result;
|
|
1031
1455
|
continue recovery;
|
|
@@ -1050,7 +1474,7 @@ export async function handleResponses(
|
|
|
1050
1474
|
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
1051
1475
|
config.cacheRetention,
|
|
1052
1476
|
);
|
|
1053
|
-
const result = await rebuildAndRefetch();
|
|
1477
|
+
const result = await rebuildAndRefetch("key-429");
|
|
1054
1478
|
if ("failed" in result) return result.failed;
|
|
1055
1479
|
upstreamResponse = result;
|
|
1056
1480
|
}
|
|
@@ -1065,7 +1489,7 @@ export async function handleResponses(
|
|
|
1065
1489
|
imageRetryAttempted = true;
|
|
1066
1490
|
imageTierBias = 1;
|
|
1067
1491
|
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
1068
|
-
const result = await rebuildAndRefetch();
|
|
1492
|
+
const result = await rebuildAndRefetch("image-413");
|
|
1069
1493
|
if ("failed" in result) return result.failed;
|
|
1070
1494
|
upstreamResponse = result;
|
|
1071
1495
|
continue recovery;
|
|
@@ -1073,6 +1497,12 @@ export async function handleResponses(
|
|
|
1073
1497
|
break;
|
|
1074
1498
|
}
|
|
1075
1499
|
if (!upstreamResponse.ok) {
|
|
1500
|
+
if (options.comboAttempt) {
|
|
1501
|
+
const failure = await consumeComboFailure(upstreamResponse, options.abortSignal)
|
|
1502
|
+
.finally(cleanupUpstreamAbort);
|
|
1503
|
+
options.onConsumedComboFailure?.(failure);
|
|
1504
|
+
return failure.response;
|
|
1505
|
+
}
|
|
1076
1506
|
const errorText = await upstreamResponse.text().catch(() => "unknown error");
|
|
1077
1507
|
cleanupUpstreamAbort();
|
|
1078
1508
|
// Upstreams occasionally echo request details in error bodies — scrub token-shaped
|
|
@@ -1146,7 +1576,62 @@ export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal)
|
|
|
1146
1576
|
* REPLACEMENT history (compact_remote.rs). Passthrough forwards to the real ChatGPT backend;
|
|
1147
1577
|
* routed models run the same summarizer used for v2 and convert the summary to v1 history items.
|
|
1148
1578
|
*/
|
|
1149
|
-
export
|
|
1579
|
+
export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024;
|
|
1580
|
+
|
|
1581
|
+
function compactResponseTooLargeError(): Response {
|
|
1582
|
+
return new Response(JSON.stringify({
|
|
1583
|
+
error: {
|
|
1584
|
+
message: "Compact response exceeded 32 MiB",
|
|
1585
|
+
type: "compact_response_too_large",
|
|
1586
|
+
code: "compact_response_too_large",
|
|
1587
|
+
},
|
|
1588
|
+
}), { status: 502, headers: { "Content-Type": "application/json" } });
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
async function bufferCompactResponse(upstream: Response, signal: AbortSignal): Promise<Response> {
|
|
1592
|
+
const reader = upstream.body?.getReader();
|
|
1593
|
+
const contentType = upstream.headers.get("content-type") ?? "application/json";
|
|
1594
|
+
if (!reader) return new Response(null, { status: upstream.status, headers: { "Content-Type": contentType } });
|
|
1595
|
+
const declaredLength = Number(upstream.headers.get("content-length"));
|
|
1596
|
+
if (Number.isFinite(declaredLength) && declaredLength > COMPACT_RESPONSE_MAX_BYTES) {
|
|
1597
|
+
await reader.cancel("compact_response_too_large").catch(() => undefined);
|
|
1598
|
+
return compactResponseTooLargeError();
|
|
1599
|
+
}
|
|
1600
|
+
const chunks: Uint8Array[] = [];
|
|
1601
|
+
let total = 0;
|
|
1602
|
+
try {
|
|
1603
|
+
while (true) {
|
|
1604
|
+
if (signal.aborted) {
|
|
1605
|
+
await reader.cancel(signal.reason).catch(() => undefined);
|
|
1606
|
+
return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
|
|
1607
|
+
}
|
|
1608
|
+
const { done, value } = await reader.read();
|
|
1609
|
+
if (done) break;
|
|
1610
|
+
total += value.byteLength;
|
|
1611
|
+
if (total > COMPACT_RESPONSE_MAX_BYTES) {
|
|
1612
|
+
await reader.cancel("compact_response_too_large").catch(() => undefined);
|
|
1613
|
+
return compactResponseTooLargeError();
|
|
1614
|
+
}
|
|
1615
|
+
chunks.push(value);
|
|
1616
|
+
}
|
|
1617
|
+
} catch {
|
|
1618
|
+
if (signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
|
|
1619
|
+
return formatErrorResponse(502, "upstream_error", "Failed to read compact response");
|
|
1620
|
+
}
|
|
1621
|
+
const body = new Uint8Array(total);
|
|
1622
|
+
let offset = 0;
|
|
1623
|
+
for (const chunk of chunks) {
|
|
1624
|
+
body.set(chunk, offset);
|
|
1625
|
+
offset += chunk.byteLength;
|
|
1626
|
+
}
|
|
1627
|
+
return new Response(body, { status: upstream.status, headers: { "Content-Type": contentType } });
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
export async function handleResponsesCompact(
|
|
1631
|
+
req: Request,
|
|
1632
|
+
config: OcxConfig,
|
|
1633
|
+
logCtx: RequestLogContext,
|
|
1634
|
+
): Promise<Response> {
|
|
1150
1635
|
let body: unknown;
|
|
1151
1636
|
try {
|
|
1152
1637
|
body = await readJsonRequestBody(req);
|
|
@@ -1167,6 +1652,27 @@ export async function handleResponsesCompact(req: Request, config: OcxConfig): P
|
|
|
1167
1652
|
} catch (err) {
|
|
1168
1653
|
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
1169
1654
|
}
|
|
1655
|
+
const selectedModelId = route.modelId;
|
|
1656
|
+
logCtx.requestedModel = raw.model;
|
|
1657
|
+
logCtx.model = selectedModelId;
|
|
1658
|
+
logCtx.provider = route.providerName;
|
|
1659
|
+
logCtx.providerAdapter = route.provider.adapter;
|
|
1660
|
+
const virtual = resolveOpenAiCompactModel(route.providerName, selectedModelId);
|
|
1661
|
+
if (virtual) {
|
|
1662
|
+
route.modelId = virtual.wireModelId;
|
|
1663
|
+
logCtx.model = virtual.selectedModelId;
|
|
1664
|
+
logCtx.resolvedModel = virtual.wireModelId;
|
|
1665
|
+
} else {
|
|
1666
|
+
logCtx.resolvedModel = route.modelId;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
if (route.codexAccountMode === "direct") {
|
|
1670
|
+
try { validateForwardAdmissionCredential(req.headers, config); }
|
|
1671
|
+
catch (err) {
|
|
1672
|
+
if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
|
|
1673
|
+
throw err;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1170
1676
|
|
|
1171
1677
|
if (route.provider.adapter === "openai-responses") {
|
|
1172
1678
|
// Native ChatGPT/OpenAI model: forward the compact request verbatim to the real backend.
|
|
@@ -1176,37 +1682,51 @@ export async function handleResponsesCompact(req: Request, config: OcxConfig): P
|
|
|
1176
1682
|
let compactProvider = route.provider;
|
|
1177
1683
|
const headers = new Headers({ "content-type": "application/json" });
|
|
1178
1684
|
try {
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1685
|
+
if (route.codexAccountMode) {
|
|
1686
|
+
const authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
|
|
1687
|
+
const selected = headersForCodexAuthContext(req.headers, authCtx);
|
|
1688
|
+
compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
1689
|
+
for (const name of FORWARD_HEADERS) {
|
|
1690
|
+
const value = selected.get(name);
|
|
1691
|
+
if (value) headers.set(name, value);
|
|
1692
|
+
}
|
|
1693
|
+
const override = (compactProvider as { _codexAccountOverride?: { accessToken: string; chatgptAccountId: string } })._codexAccountOverride;
|
|
1694
|
+
if (override) {
|
|
1695
|
+
headers.set("authorization", `Bearer ${override.accessToken}`);
|
|
1696
|
+
headers.set("chatgpt-account-id", override.chatgptAccountId);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
} catch (err) {
|
|
1700
|
+
if (err instanceof CodexAccountCooldownError) {
|
|
1701
|
+
return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
|
|
1185
1702
|
}
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
headers.set("authorization", `Bearer ${override.accessToken}`);
|
|
1189
|
-
headers.set("chatgpt-account-id", override.chatgptAccountId);
|
|
1703
|
+
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
1704
|
+
return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
|
1190
1705
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
// than failing the compact turn outright — codex-rs treats compact errors as session-fatal.
|
|
1194
|
-
for (const name of FORWARD_HEADERS) {
|
|
1195
|
-
const value = req.headers.get(name);
|
|
1196
|
-
if (value) headers.set(name, value);
|
|
1706
|
+
if (err instanceof CodexAuthContextError) {
|
|
1707
|
+
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
1197
1708
|
}
|
|
1709
|
+
if (err instanceof CodexPoolAuthenticationError || err instanceof CodexDirectAuthenticationError) {
|
|
1710
|
+
return formatErrorResponse(401, "authentication_error", err.message);
|
|
1711
|
+
}
|
|
1712
|
+
throw err;
|
|
1198
1713
|
}
|
|
1199
1714
|
const base = (compactProvider.baseUrl ?? "").replace(/\/$/, "");
|
|
1200
1715
|
if (compactProvider.apiKey) headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`);
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1716
|
+
const { reasoning: _reasoning, ...compactBody } = raw as typeof raw & { reasoning?: unknown };
|
|
1717
|
+
let upstream: Response;
|
|
1718
|
+
try {
|
|
1719
|
+
upstream = await fetch(`${base}/responses/compact`, {
|
|
1720
|
+
method: "POST",
|
|
1721
|
+
headers,
|
|
1722
|
+
body: JSON.stringify({ ...compactBody, model: route.modelId }),
|
|
1723
|
+
signal: req.signal,
|
|
1724
|
+
});
|
|
1725
|
+
} catch {
|
|
1726
|
+
if (req.signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
|
|
1727
|
+
return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream");
|
|
1728
|
+
}
|
|
1729
|
+
return bufferCompactResponse(upstream, req.signal);
|
|
1210
1730
|
}
|
|
1211
1731
|
|
|
1212
1732
|
// ROUTED model: run the v2 synthetic-compaction turn internally (appends COMPACT_PROMPT, no
|
|
@@ -1227,7 +1747,6 @@ export async function handleResponsesCompact(req: Request, config: OcxConfig): P
|
|
|
1227
1747
|
headers: internalHeaders,
|
|
1228
1748
|
body: JSON.stringify(internalBody),
|
|
1229
1749
|
});
|
|
1230
|
-
const logCtx: RequestLogContext = { model: route.modelId, provider: route.providerName };
|
|
1231
1750
|
const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal });
|
|
1232
1751
|
if (!response.ok) return response;
|
|
1233
1752
|
let json: { output?: unknown[] };
|