@bitkyc08/opencodex 2.56.0 → 2.57.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/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/assets/{index-D4zuyIxQ.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +3 -3
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +248 -0
- package/src/adapters/command-code.ts +1 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +32 -1
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +7 -1
- package/src/bridge/sse.ts +19 -1
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +18 -0
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/index.ts +48 -5
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +4 -4
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +157 -7
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/inject/restore.ts +29 -2
- package/src/codex/inject.ts +9 -9
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/pending-teardown.ts +31 -0
- package/src/generated/compatibility-version.json +163 -135
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +16 -0
- package/src/providers/registry/entries-core.ts +7 -0
- package/src/providers/registry/entries-extended.ts +9 -0
- package/src/providers/registry/model-seeds.ts +4 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +6 -2
- package/src/server/index.ts +5 -5
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +103 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/input-admission.ts +126 -6
- package/src/server/responses/passthrough-delivery.ts +19 -6
- package/src/server/responses/passthrough-dispatch.ts +28 -10
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +132 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +4 -1
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
codexPoolAffinityKey,
|
|
36
36
|
previewCodexPoolLineage,
|
|
37
37
|
applyCodexAuthContextToProvider,
|
|
38
|
+
hasCallerCodexBearer,
|
|
38
39
|
} from "../../codex/auth-context";
|
|
39
40
|
import {
|
|
40
41
|
copyPreviousResponseReplayProvenance,
|
|
@@ -84,7 +85,11 @@ import {
|
|
|
84
85
|
canPassThroughEncryptedV2AgentTask,
|
|
85
86
|
applyFinalRouteRequestNormalization,
|
|
86
87
|
} from "./core-normalize";
|
|
87
|
-
import {
|
|
88
|
+
import {
|
|
89
|
+
cachedDeniedCodexAccountIdsForModel,
|
|
90
|
+
resolveCodexModelEntitlements,
|
|
91
|
+
} from "../../codex/model-entitlements";
|
|
92
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
|
|
88
93
|
import {
|
|
89
94
|
previewCodexAccountForRequest,
|
|
90
95
|
codexQuotaScopeForModel,
|
|
@@ -101,7 +106,7 @@ import {
|
|
|
101
106
|
isCodexReserveHelperUnsupported,
|
|
102
107
|
CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE,
|
|
103
108
|
} from "../../codex/loopback-target";
|
|
104
|
-
import { checkInputAdmission } from "./input-admission";
|
|
109
|
+
import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission";
|
|
105
110
|
import { nativeContextLimits } from "../../codex/catalog";
|
|
106
111
|
import { streamingContextOverflowResponse } from "./context-overflow";
|
|
107
112
|
import {
|
|
@@ -115,6 +120,8 @@ import { codexAuthContextLogLabel } from "../../codex/account-label";
|
|
|
115
120
|
import {
|
|
116
121
|
conversationStateBindingFromAuth,
|
|
117
122
|
applyAccountChangeConversationStateScrub,
|
|
123
|
+
accountChangeFileReferenceRefusal,
|
|
124
|
+
conversationCarriesUploadedFiles,
|
|
118
125
|
} from "./account-change-state";
|
|
119
126
|
|
|
120
127
|
/** Parses, selects, and admits one request without changing the dispatch policy. */
|
|
@@ -365,6 +372,14 @@ export async function prepareResponsesRequest(
|
|
|
365
372
|
}
|
|
366
373
|
logCtx.requestedModel = parsed.modelId;
|
|
367
374
|
logCtx.requestedEffort = parsed.options.reasoning;
|
|
375
|
+
// What this request may spend beyond its input, for the durable spend reservation (#4707).
|
|
376
|
+
// Read from the caller rather than from the adapter's serialized body, because the
|
|
377
|
+
// reservation has to exist before the body does. A caller that omits it leaves the
|
|
378
|
+
// provider/model default in charge and reserves only the input estimate; settlement then
|
|
379
|
+
// books the real figure, so the gap is a looser bound up front, never a wrong one after.
|
|
380
|
+
if (typeof parsed.options.maxOutputTokens === "number" && parsed.options.maxOutputTokens > 0) {
|
|
381
|
+
logCtx.spendOutputCeilingTokens = Math.trunc(parsed.options.maxOutputTokens);
|
|
382
|
+
}
|
|
368
383
|
logCtx.callerServiceTier = sanitizeLogMetadataString(parsed.options.serviceTier);
|
|
369
384
|
logCtx.requestedServiceTier = parsed.options.serviceTier;
|
|
370
385
|
logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
|
|
@@ -442,12 +457,41 @@ export async function prepareResponsesRequest(
|
|
|
442
457
|
&& (route.codexAccountId === undefined || initialSubagentFallbackChain !== null)
|
|
443
458
|
? codexAccountSelectionForTurn(options.turnAdmissionLease)?.()
|
|
444
459
|
: undefined;
|
|
460
|
+
// The credential headers final authentication will be given, resolved once and reused by
|
|
461
|
+
// everything below that has to predict what final auth decides.
|
|
462
|
+
const previewAuthHeaders = codexRouteCredentialDomainHeaders(
|
|
463
|
+
req,
|
|
464
|
+
route,
|
|
465
|
+
options,
|
|
466
|
+
credentialDomainWasRewritten,
|
|
467
|
+
);
|
|
468
|
+
// Does the CALLER own the credential this request will authenticate with? Validated exactly
|
|
469
|
+
// the way final auth validates it: the route ownership predicate AND the caller-bearer check
|
|
470
|
+
// `resolveCodexAuthContext` re-applies to these same headers.
|
|
471
|
+
const previewRequestScopedMainCredential = codexRouteCredentialOwnership(
|
|
472
|
+
previewAuthHeaders,
|
|
473
|
+
config,
|
|
474
|
+
route,
|
|
475
|
+
options,
|
|
476
|
+
).requestScopedMainCredential && hasCallerCodexBearer(previewAuthHeaders);
|
|
445
477
|
const nativeMainRecoveryBlocked = isNativeMainTrafficBlocked();
|
|
446
|
-
|
|
478
|
+
// The same three inputs final auth ORs together (src/codex/auth-context.ts). Request-owned
|
|
479
|
+
// ownership is first there and has to be first here: computing the preview fence from
|
|
480
|
+
// recovery and drain state alone let a `thread_spawn` carrying a forwardable caller bearer
|
|
481
|
+
// read the physical main token it is forbidden to touch, and score main differently than the
|
|
482
|
+
// resolution this preview exists to predict.
|
|
483
|
+
const nativeMainReadsForbidden = previewRequestScopedMainCredential
|
|
484
|
+
|| nativeMainRecoveryBlocked
|
|
447
485
|
|| previewSelectionAdmission?.mainProfileDraining === true;
|
|
486
|
+
// Deliberately NOT fenced on ownership: final auth derives `nativeMainSelectionOnly` from the
|
|
487
|
+
// drain alone, and adding a term here would diverge from it in the other direction.
|
|
448
488
|
const previewSelectionOptions = {
|
|
449
489
|
nativeMainSelectionOnly: !nativeMainRecoveryBlocked
|
|
450
490
|
&& previewSelectionAdmission?.mainProfileDraining === true,
|
|
491
|
+
// Preview must reach the same answer as the final resolution, including the uploaded-file
|
|
492
|
+
// retention (#4778): a preview that reported a quota move the request will not make would
|
|
493
|
+
// hand subagent fallback a different account than the one that actually serves.
|
|
494
|
+
retainAccountForUploadedFiles: conversationCarriesUploadedFiles(parsed._rawBody),
|
|
451
495
|
};
|
|
452
496
|
let selectedForwardHeaders = req.headers;
|
|
453
497
|
let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
|
|
@@ -466,22 +510,11 @@ export async function prepareResponsesRequest(
|
|
|
466
510
|
// deliberately create no affinity at all -- previewing a family binding for one of those would
|
|
467
511
|
// hand model fallback an account this request can never authenticate as. Read-only: the record
|
|
468
512
|
// is written by the resolution that binds, never by a preview that may own no Pool state.
|
|
469
|
-
const previewAuthHeaders = codexRouteCredentialDomainHeaders(
|
|
470
|
-
req,
|
|
471
|
-
route,
|
|
472
|
-
options,
|
|
473
|
-
credentialDomainWasRewritten,
|
|
474
|
-
);
|
|
475
513
|
const poolLineage = previewCodexPoolLineage(previewAuthHeaders, options.codexAuthPolicy ?? config, {
|
|
476
514
|
accountId: route.codexAccountId,
|
|
477
515
|
modelId: route.modelId,
|
|
478
516
|
admission: options.admission,
|
|
479
|
-
requestScopedMainCredential:
|
|
480
|
-
previewAuthHeaders,
|
|
481
|
-
config,
|
|
482
|
-
route,
|
|
483
|
-
options,
|
|
484
|
-
).requestScopedMainCredential,
|
|
517
|
+
requestScopedMainCredential: previewRequestScopedMainCredential,
|
|
485
518
|
});
|
|
486
519
|
|
|
487
520
|
try {
|
|
@@ -519,7 +552,20 @@ export async function prepareResponsesRequest(
|
|
|
519
552
|
config,
|
|
520
553
|
previewNow,
|
|
521
554
|
codexQuotaScopeForModel(modelId),
|
|
522
|
-
{
|
|
555
|
+
{
|
|
556
|
+
...previewSelectionOptions,
|
|
557
|
+
modelEligibleAccountIds,
|
|
558
|
+
// Per CANDIDATE model, like the scope and the eligible set above: the preference is
|
|
559
|
+
// model-specific, so hoisting it out of the closure would score every fallback
|
|
560
|
+
// candidate against the requested model's evidence and diverge from final auth (#4768).
|
|
561
|
+
// Under the same native-main read fence final auth applies: the reader validates each
|
|
562
|
+
// cached roster against the account's current credential, and for main that is a
|
|
563
|
+
// synchronous read of the stored token. A preview that read it would both cross the
|
|
564
|
+
// fence and score main differently than the resolution it is supposed to predict.
|
|
565
|
+
deniedModelAccountIds: cachedDeniedCodexAccountIdsForModel(modelId, previewNow, {
|
|
566
|
+
excludeAccountIds: nativeMainReadsForbidden ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined,
|
|
567
|
+
}),
|
|
568
|
+
},
|
|
523
569
|
modelId,
|
|
524
570
|
poolLineage,
|
|
525
571
|
);
|
|
@@ -650,9 +696,31 @@ export async function prepareResponsesRequest(
|
|
|
650
696
|
const fallback = (() => {
|
|
651
697
|
try {
|
|
652
698
|
const recoveryNativeMainBlocked = isNativeMainTrafficBlocked();
|
|
699
|
+
// Recompute ownership here rather than reusing the pre-decryption value: a
|
|
700
|
+
// subagent fallback above may have re-routed, and `requestScopedMainCredential`
|
|
701
|
+
// is a function of the route as well as the headers.
|
|
702
|
+
const recoveryAuthHeaders = codexRouteCredentialDomainHeaders(
|
|
703
|
+
req,
|
|
704
|
+
route,
|
|
705
|
+
options,
|
|
706
|
+
credentialDomainWasRewritten,
|
|
707
|
+
);
|
|
708
|
+
const recoveryRequestScopedMainCredential = codexRouteCredentialOwnership(
|
|
709
|
+
recoveryAuthHeaders,
|
|
710
|
+
config,
|
|
711
|
+
route,
|
|
712
|
+
options,
|
|
713
|
+
).requestScopedMainCredential && hasCallerCodexBearer(recoveryAuthHeaders);
|
|
653
714
|
const recoverySelectionOptions = {
|
|
654
715
|
nativeMainSelectionOnly: !recoveryNativeMainBlocked
|
|
655
716
|
&& recoverySelectionAdmission?.mainProfileDraining === true,
|
|
717
|
+
// #4778, same reason as `previewSelectionOptions` above: this preview decides
|
|
718
|
+
// which account subagent fallback scores against, and final auth passes the
|
|
719
|
+
// retention. Recovery is exactly where the two could diverge -- it re-previews
|
|
720
|
+
// against the DECRYPTED body, which is the first point at which a file reference
|
|
721
|
+
// that was ciphertext-only becomes readable, so reconstructing the options
|
|
722
|
+
// without the bit lets preview report a quota move the request will not make.
|
|
723
|
+
retainAccountForUploadedFiles: conversationCarriesUploadedFiles(parsed._rawBody),
|
|
656
724
|
};
|
|
657
725
|
const recoveryNow = Date.now();
|
|
658
726
|
// Carry the entitlement filter through recovery too (#2509/#2623). The scope was
|
|
@@ -665,7 +733,21 @@ export async function prepareResponsesRequest(
|
|
|
665
733
|
config,
|
|
666
734
|
previewNow,
|
|
667
735
|
codexQuotaScopeForModel(modelId),
|
|
668
|
-
{
|
|
736
|
+
{
|
|
737
|
+
...recoverySelectionOptions,
|
|
738
|
+
modelEligibleAccountIds,
|
|
739
|
+
// Same read fence as the first preview, evaluated against recovery's own view
|
|
740
|
+
// of the drain AND of credential ownership, rather than the one captured before
|
|
741
|
+
// decryption. Omitting ownership here would reopen the fence the first preview
|
|
742
|
+
// closes, on the one path that re-previews after the route may have moved.
|
|
743
|
+
deniedModelAccountIds: cachedDeniedCodexAccountIdsForModel(modelId, previewNow, {
|
|
744
|
+
excludeAccountIds: recoveryRequestScopedMainCredential
|
|
745
|
+
|| recoveryNativeMainBlocked
|
|
746
|
+
|| recoverySelectionAdmission?.mainProfileDraining === true
|
|
747
|
+
? new Set([MAIN_CODEX_ACCOUNT_ID])
|
|
748
|
+
: undefined,
|
|
749
|
+
}),
|
|
750
|
+
},
|
|
669
751
|
modelId,
|
|
670
752
|
poolLineage,
|
|
671
753
|
);
|
|
@@ -860,7 +942,12 @@ export async function prepareResponsesRequest(
|
|
|
860
942
|
// refusing the turn that shrinks the context would deadlock the client against the very
|
|
861
943
|
// limit this gate reports — it would be told to compact and then denied the compaction.
|
|
862
944
|
if (parsed._compactionRequest !== true) {
|
|
863
|
-
|
|
945
|
+
// A combo child is the one caller that can afford a strict gate: skipping a target it
|
|
946
|
+
// cannot fit is safe before any upstream bytes are sent, and the ladder continues. A
|
|
947
|
+
// direct request has nowhere to go, so it keeps the loose pathological-input gate.
|
|
948
|
+
const inputAdmission = options.comboAttempt
|
|
949
|
+
? checkComboTargetInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config))
|
|
950
|
+
: checkInputAdmission(parsed, route.provider, route.providerName, parsed.modelId, nativeContextLimits(config));
|
|
864
951
|
if (!inputAdmission.admitted) {
|
|
865
952
|
// #1524: this is a LOCAL preflight refusal, not an upstream verdict. A policy or combo
|
|
866
953
|
// fallback must be able to skip this candidate and try one whose context window fits,
|
|
@@ -876,9 +963,13 @@ export async function prepareResponsesRequest(
|
|
|
876
963
|
return formatErrorResponse(
|
|
877
964
|
413,
|
|
878
965
|
"input_admission_refused",
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
966
|
+
inputAdmission.requiredOutputHeadroom !== undefined
|
|
967
|
+
? `Estimated input (~${inputAdmission.estimatedTokens} tokens) plus ${inputAdmission.requiredOutputHeadroom} `
|
|
968
|
+
+ `tokens of requested output headroom cannot fit the context window of ${parsed.modelId} `
|
|
969
|
+
+ `(${inputAdmission.ceiling} tokens).`
|
|
970
|
+
: `Estimated input (~${inputAdmission.estimatedTokens} tokens) is far past the context window `
|
|
971
|
+
+ `of ${parsed.modelId} (${inputAdmission.ceiling} tokens). Start a new session or choose a `
|
|
972
|
+
+ `model with a larger context window.`,
|
|
882
973
|
);
|
|
883
974
|
}
|
|
884
975
|
}
|
|
@@ -897,7 +988,18 @@ export async function prepareResponsesRequest(
|
|
|
897
988
|
let substituteMainCredential = false;
|
|
898
989
|
let callerAuthHeaders: Headers;
|
|
899
990
|
{
|
|
900
|
-
|
|
991
|
+
// #4778: uploaded files are scoped to the account that issued them, so a conversation
|
|
992
|
+
// carrying live references must retain its binding across a voluntary quota move. Answered
|
|
993
|
+
// from the body alone, by the same predicate the refusal guard uses, so the two can never
|
|
994
|
+
// disagree about which conversations are in scope.
|
|
995
|
+
const finalAuth = await resolveResponsesCodexAuth(
|
|
996
|
+
req,
|
|
997
|
+
config,
|
|
998
|
+
route,
|
|
999
|
+
options,
|
|
1000
|
+
credentialDomainWasRewritten,
|
|
1001
|
+
conversationCarriesUploadedFiles(parsed._rawBody),
|
|
1002
|
+
);
|
|
901
1003
|
if (!finalAuth.ok) return finalAuth.response;
|
|
902
1004
|
admissionState.authCtx = finalAuth.authCtx;
|
|
903
1005
|
selectedForwardHeaders = withClaudeNativeSession(finalAuth.headers, route.provider, options.claudeNativeSessionId);
|
|
@@ -921,6 +1023,14 @@ export async function prepareResponsesRequest(
|
|
|
921
1023
|
{
|
|
922
1024
|
const binding = conversationStateBindingFromAuth(admissionState.authCtx, poolAffinityKey);
|
|
923
1025
|
if (binding) {
|
|
1026
|
+
// Before the scrub, because a file reference is refused rather than removed and the
|
|
1027
|
+
// refusal has to happen while there is still no dispatch to undo.
|
|
1028
|
+
const refusal = accountChangeFileReferenceRefusal({
|
|
1029
|
+
body: parsed._rawBody,
|
|
1030
|
+
bindingKey: binding.bindingKey,
|
|
1031
|
+
servingAccountId: binding.accountId,
|
|
1032
|
+
});
|
|
1033
|
+
if (refusal) return refusal;
|
|
924
1034
|
applyAccountChangeConversationStateScrub({
|
|
925
1035
|
body: parsed._rawBody,
|
|
926
1036
|
parsed,
|
|
@@ -5,7 +5,13 @@ import { workflowRefusalResponse } from "../workflow-refusal";
|
|
|
5
5
|
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
6
6
|
import { noteAttemptSend } from "../request-log";
|
|
7
7
|
import { TRANSIENT_RETRY_MAX_ATTEMPTS } from "../../lib/upstream-retry";
|
|
8
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
DispatchDecision,
|
|
10
|
+
DispatchIntent,
|
|
11
|
+
RequestExecutionBudget,
|
|
12
|
+
SendClass,
|
|
13
|
+
SingleUseDispatchPermit,
|
|
14
|
+
} from "../../lib/request-execution-budget";
|
|
9
15
|
|
|
10
16
|
/** Owns the shared request send counter and recovery permits. */
|
|
11
17
|
export function createResponsesSendBudget(
|
|
@@ -52,7 +58,7 @@ export function createResponsesSendBudget(
|
|
|
52
58
|
/**
|
|
53
59
|
* Records an adapter's OWN inner retries against this attempt.
|
|
54
60
|
*
|
|
55
|
-
* Ordinal 1 is the send each call site already recorded through `
|
|
61
|
+
* Ordinal 1 is the send each call site already recorded through `noteRoutedAttemptSend`, so only
|
|
56
62
|
* the extra physical sends are added here and an adapter that does not retry internally
|
|
57
63
|
* leaves its log byte-for-byte as it was. Kiro reaches roughly eighteen sends per call and
|
|
58
64
|
* Cursor re-sends a whole turn, and both reported one; a count that cannot be observed
|
|
@@ -75,6 +81,32 @@ export function createResponsesSendBudget(
|
|
|
75
81
|
* was recovering from.
|
|
76
82
|
*/
|
|
77
83
|
let pendingHopPermit: SingleUseDispatchPermit | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* The budget an adapter's OWN dispatch ladder reserves against.
|
|
86
|
+
*
|
|
87
|
+
* Kiro and Cursor reserve once per physical send, and that is right: their ladders are the
|
|
88
|
+
* layer that actually sends, and counting one adapter call as one send hid up to eighteen
|
|
89
|
+
* upstream requests. But a credential hop has already booked the replay it is about to make,
|
|
90
|
+
* and a reservation IS the charge, so an adapter that reserves again turns one physical send
|
|
91
|
+
* into two charges -- and once the base allowance is spent, into a refusal that answers with
|
|
92
|
+
* a synthetic error in place of the 429 the hop was recovering from (#4709).
|
|
93
|
+
*
|
|
94
|
+
* The hop hands its reservation down through `pendingHopPermit`, the same seam the
|
|
95
|
+
* passthrough ladder already uses, and this view spends it on the adapter's FIRST
|
|
96
|
+
* reservation. Every later send in that ladder is a new physical send and is charged
|
|
97
|
+
* normally. A permit the adapter takes but never sends under is released through the same
|
|
98
|
+
* call it would have used for a reservation of its own, so an abandoned replay is refunded
|
|
99
|
+
* rather than left charged.
|
|
100
|
+
*/
|
|
101
|
+
const adapterDispatchBudget: RequestExecutionBudget | undefined = adapterSendBudget === undefined
|
|
102
|
+
? undefined
|
|
103
|
+
: adapterDispatchBudgetView(adapterSendBudget, {
|
|
104
|
+
claimHopPermit: () => {
|
|
105
|
+
const permit = pendingHopPermit;
|
|
106
|
+
pendingHopPermit = undefined;
|
|
107
|
+
return permit;
|
|
108
|
+
},
|
|
109
|
+
});
|
|
78
110
|
/**
|
|
79
111
|
* How many sends a recovery leg may make, and the permit that authorises the last one.
|
|
80
112
|
*
|
|
@@ -147,6 +179,7 @@ export function createResponsesSendBudget(
|
|
|
147
179
|
noteTransientSends,
|
|
148
180
|
remainingTransientSendBudget,
|
|
149
181
|
adapterSendBudget,
|
|
182
|
+
adapterDispatchBudget,
|
|
150
183
|
noteAdapterPhysicalSend,
|
|
151
184
|
sendBudgetExhausted,
|
|
152
185
|
get pendingHopPermit(): SingleUseDispatchPermit | undefined {
|
|
@@ -162,3 +195,65 @@ export function createResponsesSendBudget(
|
|
|
162
195
|
}
|
|
163
196
|
|
|
164
197
|
export type ResponsesSendBudget = Exclude<ReturnType<typeof createResponsesSendBudget>, Response>;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A LIVE delegating view of one request's execution budget, with a credential hop's
|
|
201
|
+
* reservation spendable through it.
|
|
202
|
+
*
|
|
203
|
+
* Every member forwards rather than copying. A spread of the budget would freeze `used`,
|
|
204
|
+
* `reserveSpent` and the target counters at construction time, handing the adapter a budget
|
|
205
|
+
* that can never read as exhausted -- the same class of defect as the fresh per-layer
|
|
206
|
+
* allowances #4546 removed.
|
|
207
|
+
*/
|
|
208
|
+
function adapterDispatchBudgetView(
|
|
209
|
+
budget: RequestExecutionBudget,
|
|
210
|
+
hop: { claimHopPermit: () => SingleUseDispatchPermit | undefined },
|
|
211
|
+
): RequestExecutionBudget {
|
|
212
|
+
return {
|
|
213
|
+
get used(): number { return budget.used; },
|
|
214
|
+
set used(next: number) { budget.used = next; },
|
|
215
|
+
logicalRequestId: budget.logicalRequestId,
|
|
216
|
+
policyVersion: budget.policyVersion,
|
|
217
|
+
policy: budget.policy,
|
|
218
|
+
get reserveSpent(): boolean { return budget.reserveSpent; },
|
|
219
|
+
get alternateTargetSends(): number { return budget.alternateTargetSends; },
|
|
220
|
+
get targetTransitions(): number { return budget.targetTransitions; },
|
|
221
|
+
get lastTargetKey(): string | undefined { return budget.lastTargetKey; },
|
|
222
|
+
remainingBaseSends: (cap: number): number => budget.remainingBaseSends(cap),
|
|
223
|
+
reserveDispatch(intent: DispatchIntent): DispatchDecision {
|
|
224
|
+
// A dispatch whose upstream state is unknown is refused on its own merits. A hop that
|
|
225
|
+
// already paid does not make an unsafe replay safe, so that check stays with the budget.
|
|
226
|
+
if (intent.replaySafe !== false) {
|
|
227
|
+
const hopPermit = hop.claimHopPermit();
|
|
228
|
+
// Confirmed here rather than in `use()`: the adapter reserves immediately before it
|
|
229
|
+
// opens the transport, which is the same boundary the hop's own confirmation uses.
|
|
230
|
+
// A permit some other leg already settled returns false, and this falls through to a
|
|
231
|
+
// real reservation rather than handing the adapter a dead permit -- an adapter whose
|
|
232
|
+
// `use()` fails treats the request as exhausted and stops sending entirely.
|
|
233
|
+
if (hopPermit !== undefined && hopPermit.assumeCharge()) {
|
|
234
|
+
let spent = false;
|
|
235
|
+
return {
|
|
236
|
+
allowed: true,
|
|
237
|
+
permit: {
|
|
238
|
+
sendClass: hopPermit.sendClass,
|
|
239
|
+
use: (): boolean => {
|
|
240
|
+
if (spent) return false;
|
|
241
|
+
spent = true;
|
|
242
|
+
return true;
|
|
243
|
+
},
|
|
244
|
+
assumeCharge: (): boolean => {
|
|
245
|
+
if (spent) return false;
|
|
246
|
+
spent = true;
|
|
247
|
+
return true;
|
|
248
|
+
},
|
|
249
|
+
// The hop's charge is already settled and belongs to the leg that asked for it,
|
|
250
|
+
// so there is nothing here to refund.
|
|
251
|
+
release: (): void => {},
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return budget.reserveDispatch(intent);
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { RequestSendObserver } from "../../lib/request-execution-budget";
|
|
3
|
+
import { sharedSpendLedger, type SpendReservationLedger } from "../../lib/spend-reservation-ledger";
|
|
4
|
+
import type { RequestLogContext } from "../request-log";
|
|
5
|
+
|
|
6
|
+
/** The terminal usage a request reported, in the only two fields the ledger books. */
|
|
7
|
+
export interface TerminalSpendUsage {
|
|
8
|
+
inputTokens?: number;
|
|
9
|
+
outputTokens?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Settles one request's durable spend entries once its terminal usage is known. */
|
|
13
|
+
export interface RequestSpendSettlement {
|
|
14
|
+
settle(usage: TerminalSpendUsage | undefined): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RequestSpendTracker extends RequestSendObserver, RequestSpendSettlement {
|
|
18
|
+
/** Dispatches this request lost to a ledger ceiling. Zero on every ordinary request. */
|
|
19
|
+
readonly refusals: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* One request's entries in the durable spend ledger (#4707).
|
|
24
|
+
*
|
|
25
|
+
* The ledger has had the whole reserve/dispatch/settle vocabulary since #4546 and no production
|
|
26
|
+
* caller: `spend-ledger.jsonl` was never created by ordinary traffic, and the ceilings the
|
|
27
|
+
* feature advertised stayed process-local and count-only, resetting on restart. This is the
|
|
28
|
+
* caller.
|
|
29
|
+
*
|
|
30
|
+
* It books one entry per physical send by observing the request's own send counter rather than
|
|
31
|
+
* by being called from each dispatch site. That counter moves exactly once per physical send,
|
|
32
|
+
* so one entry per increment is one entry per send -- and a dispatch path added later cannot
|
|
33
|
+
* forget to book, which is how the previous wiring attempt ended up with no caller at all.
|
|
34
|
+
*
|
|
35
|
+
* Settlement follows what the request actually learned. The terminal usage belongs to the LAST
|
|
36
|
+
* send that left, so that one settles with the real figure. Every earlier send failed without
|
|
37
|
+
* reporting usage of its own and may still have been billed, so it becomes unresolved spend
|
|
38
|
+
* rather than free. A request that ends with no usage at all -- a cancel, a lost stream --
|
|
39
|
+
* leaves all of them unresolved, which is the conservative answer this ledger exists to give.
|
|
40
|
+
*/
|
|
41
|
+
export function createRequestSpendTracker(
|
|
42
|
+
logCtx: Pick<
|
|
43
|
+
RequestLogContext,
|
|
44
|
+
"provider" | "accountLogLabel" | "usageLogInputTokens" | "spendOutputCeilingTokens"
|
|
45
|
+
>,
|
|
46
|
+
rootId: string | undefined,
|
|
47
|
+
injected?: SpendReservationLedger,
|
|
48
|
+
): RequestSpendTracker {
|
|
49
|
+
// Resolved on the first CHARGE, not when the request is built. The shared ledger opens a
|
|
50
|
+
// journal under the OpenCodex home, and a request that never dispatches -- refused at
|
|
51
|
+
// admission, answered locally, cancelled before its first send -- has no business creating
|
|
52
|
+
// one. It also means the home in effect at dispatch is the one that gets written.
|
|
53
|
+
let ledgerRef: SpendReservationLedger | undefined = injected;
|
|
54
|
+
const ledger = (): SpendReservationLedger => (ledgerRef ??= sharedSpendLedger());
|
|
55
|
+
// Every send this request still owes the ledger an answer for, oldest first.
|
|
56
|
+
const live: string[] = [];
|
|
57
|
+
let refusals = 0;
|
|
58
|
+
let resolved = false;
|
|
59
|
+
/**
|
|
60
|
+
* Confirm the sends this request has already moved past.
|
|
61
|
+
*
|
|
62
|
+
* A booking is only marked dispatched once a LATER send exists, because that later send
|
|
63
|
+
* proves the earlier one left. The newest booking stays open until it is settled, so a
|
|
64
|
+
* reservation the budget hands back -- a rotation that found no alternate, a rebuild
|
|
65
|
+
* abandoned before the wire -- can still be released for free while this process is alive.
|
|
66
|
+
* A crash resolves every surviving reservation as unresolved spend regardless of this mark,
|
|
67
|
+
* because a journal that lost its tail cannot prove a send never left.
|
|
68
|
+
*/
|
|
69
|
+
const confirmOlderSends = (): void => {
|
|
70
|
+
for (let index = 0; index < live.length - 1; index += 1) ledger().markDispatched(live[index] as string);
|
|
71
|
+
};
|
|
72
|
+
return {
|
|
73
|
+
charge(): boolean {
|
|
74
|
+
const sendId = randomUUID();
|
|
75
|
+
const decision = ledger().reserve({
|
|
76
|
+
sendId,
|
|
77
|
+
scopes: {
|
|
78
|
+
...(rootId !== undefined ? { rootId } : {}),
|
|
79
|
+
// Already the privacy-safe label the request log uses, and the ledger aliases it
|
|
80
|
+
// again on the way to disk. A raw credential never reaches either.
|
|
81
|
+
...(logCtx.accountLogLabel !== undefined ? { identityId: logCtx.accountLogLabel } : {}),
|
|
82
|
+
...(logCtx.provider !== undefined ? { poolId: logCtx.provider } : {}),
|
|
83
|
+
},
|
|
84
|
+
inputTokens: logCtx.usageLogInputTokens ?? 0,
|
|
85
|
+
outputCeilingTokens: logCtx.spendOutputCeilingTokens ?? 0,
|
|
86
|
+
});
|
|
87
|
+
if (!decision.reserved) {
|
|
88
|
+
refusals += 1;
|
|
89
|
+
// Only an operator's configured ceiling refuses a dispatch. Every other denial --
|
|
90
|
+
// capacity, durability, a journal this process could not prove complete -- means the
|
|
91
|
+
// ledger cannot ACCOUNT for this send, which is not a reason to refuse one. An
|
|
92
|
+
// unconfigured install keeps the count caps it already had and is not newly refused,
|
|
93
|
+
// and a degraded ledger must not become an outage.
|
|
94
|
+
return decision.denial.reason !== "spend-limit-exceeded";
|
|
95
|
+
}
|
|
96
|
+
live.push(sendId);
|
|
97
|
+
confirmOlderSends();
|
|
98
|
+
return true;
|
|
99
|
+
},
|
|
100
|
+
refund(): void {
|
|
101
|
+
const sendId = live.pop();
|
|
102
|
+
if (sendId === undefined) return;
|
|
103
|
+
// Undispatched, so this returns the tokens. If the send was already confirmed by a later
|
|
104
|
+
// one, `abandon` refuses and unresolved is the only honest outcome left.
|
|
105
|
+
if (!ledger().abandon(sendId)) ledger().markLost(sendId);
|
|
106
|
+
},
|
|
107
|
+
settle(usage: TerminalSpendUsage | undefined): void {
|
|
108
|
+
if (resolved) return;
|
|
109
|
+
resolved = true;
|
|
110
|
+
const terminal = live.pop();
|
|
111
|
+
if (terminal !== undefined) {
|
|
112
|
+
const reported = typeof usage?.inputTokens === "number" || typeof usage?.outputTokens === "number";
|
|
113
|
+
if (reported) {
|
|
114
|
+
ledger().settle(terminal, {
|
|
115
|
+
inputTokens: usage?.inputTokens ?? 0,
|
|
116
|
+
outputTokens: usage?.outputTokens ?? 0,
|
|
117
|
+
});
|
|
118
|
+
} else {
|
|
119
|
+
// The response never reported usage. It may still have been billed.
|
|
120
|
+
ledger().markLost(terminal);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const sendId of live.splice(0)) ledger().markLost(sendId);
|
|
124
|
+
},
|
|
125
|
+
get refusals(): number { return refusals; },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Give a request a spend tracker and hand back the observer its budget reports through.
|
|
131
|
+
*
|
|
132
|
+
* The tracker is parked on the log context because `addFinalRequestLog` is the one seam every
|
|
133
|
+
* request passes exactly once, whatever transport served it and however it ended, and it is
|
|
134
|
+
* where the terminal usage is already known.
|
|
135
|
+
*/
|
|
136
|
+
export function attachRequestSpendTracker(
|
|
137
|
+
req: Pick<Request, "headers">,
|
|
138
|
+
logCtx: RequestLogContext,
|
|
139
|
+
ledger?: SpendReservationLedger,
|
|
140
|
+
): RequestSendObserver {
|
|
141
|
+
const rootId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
|
|
142
|
+
const tracker = ledger === undefined
|
|
143
|
+
? createRequestSpendTracker(logCtx, rootId)
|
|
144
|
+
: createRequestSpendTracker(logCtx, rootId, ledger);
|
|
145
|
+
logCtx.spendTracker = tracker;
|
|
146
|
+
return tracker;
|
|
147
|
+
}
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
credentialGeneration,
|
|
9
9
|
} from "../../oauth/store";
|
|
10
10
|
import type { ProviderAdapter, AdapterRequest } from "../../adapters/base";
|
|
11
|
-
import type { OcxParsedRequest, OcxProviderConfig } from "../../types";
|
|
11
|
+
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../../types";
|
|
12
12
|
import type { AnthropicAccountSelectionReason } from "../../oauth/anthropic-routing";
|
|
13
13
|
import {
|
|
14
14
|
isAnthropicAccountPoolEnabled,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
preferredInitialAccount,
|
|
34
34
|
noteGenericPoolSelection,
|
|
35
35
|
} from "../../oauth/generic-account-failover";
|
|
36
|
-
import { stampOAuthAccountLabel } from "../../providers/label";
|
|
36
|
+
import { stampOAuthAccountLabel, usesApiKeyAccount } from "../../providers/label";
|
|
37
37
|
import { resolveProviderTransport } from "../../providers/xai-transport";
|
|
38
38
|
import { resolveCopilotApiBaseUrl } from "../../oauth/github-copilot";
|
|
39
39
|
import {
|
|
@@ -59,7 +59,11 @@ import {
|
|
|
59
59
|
sealRequestAttemptIdentity,
|
|
60
60
|
recordAttemptCredentialSource,
|
|
61
61
|
recordAdapterTierMetadata,
|
|
62
|
+
noteProviderAttemptSend,
|
|
63
|
+
recordKeyAttemptFailure,
|
|
64
|
+
recordKeyAttemptUsage,
|
|
62
65
|
} from "../request-log";
|
|
66
|
+
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
63
67
|
import { resolvePassiveRouteSubjectId } from "../passive-route-linker";
|
|
64
68
|
|
|
65
69
|
/** Owns live credential selection and adapter bindings for one request. */
|
|
@@ -241,6 +245,30 @@ export async function prepareResponsesTransport(
|
|
|
241
245
|
replayOAuthCredentialSnapshot = { accountId: snapshot.accountId, generation: snapshot.generation };
|
|
242
246
|
return true;
|
|
243
247
|
};
|
|
248
|
+
// Key sends may be rebuilt while queued. Keep metadata pending until the guarded
|
|
249
|
+
// physical dispatch binds it to the selection that actually reaches the upstream.
|
|
250
|
+
let pendingKeySend: { estimate: number | undefined; recovery?: AttemptRecoveryKind } | undefined;
|
|
251
|
+
const noteRoutedAttemptSend = (estimate: number | undefined, recovery?: AttemptRecoveryKind): void => {
|
|
252
|
+
if (usesApiKeyAccount(route.provider)) pendingKeySend = { estimate, recovery };
|
|
253
|
+
else noteProviderAttemptSend(logCtx, route.providerName, route.provider, estimate, recovery);
|
|
254
|
+
};
|
|
255
|
+
const commitKeyAttemptSend = (): void => {
|
|
256
|
+
if (!usesApiKeyAccount(route.provider)) return;
|
|
257
|
+
noteProviderAttemptSend(logCtx, route.providerName, route.provider,
|
|
258
|
+
pendingKeySend?.estimate ?? logCtx.usageLogInputTokens, pendingKeySend?.recovery);
|
|
259
|
+
pendingKeySend = undefined;
|
|
260
|
+
};
|
|
261
|
+
const bindKeyUsageFromBridge = (usage: OcxUsage | undefined): void => {
|
|
262
|
+
logCtx.usageFromBridge = true;
|
|
263
|
+
if (usesApiKeyAccount(route.provider)) {
|
|
264
|
+
logCtx.usage = logCtx.activeAttempt?.usage;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (usage) {
|
|
268
|
+
logCtx.usage = usage;
|
|
269
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
244
272
|
const selectionIsCurrent = (binding: DispatchBinding | undefined): boolean => {
|
|
245
273
|
if (route.provider.authMode === "forward") return true;
|
|
246
274
|
if (!binding) return false;
|
|
@@ -260,6 +288,27 @@ export async function prepareResponsesTransport(
|
|
|
260
288
|
: undefined
|
|
261
289
|
: { kind: "api-key", provider: { ...route.provider } };
|
|
262
290
|
if (binding) adapterBindings.set(resolved, binding);
|
|
291
|
+
// Observe terminals before search/image loops or continuation guards hide earlier rounds.
|
|
292
|
+
// Each adapter parser is called once per physical response; bridge totals are client-only.
|
|
293
|
+
const observedResponses = new WeakSet<object>();
|
|
294
|
+
const observeUsage = (event: AdapterEvent, response: object): void => {
|
|
295
|
+
if (usesApiKeyAccount(provider) && "usage" in event && event.usage && !observedResponses.has(response)) {
|
|
296
|
+
observedResponses.add(response);
|
|
297
|
+
recordKeyAttemptUsage(logCtx, event.usage);
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
const parseStream = resolved.parseStream.bind(resolved);
|
|
301
|
+
resolved.parseStream = async function* (...args) {
|
|
302
|
+
for await (const event of parseStream(...args)) { observeUsage(event, args[0]); yield event; }
|
|
303
|
+
};
|
|
304
|
+
if (resolved.parseResponse) {
|
|
305
|
+
const parseResponse = resolved.parseResponse.bind(resolved);
|
|
306
|
+
resolved.parseResponse = async (...args) => {
|
|
307
|
+
const events = await parseResponse(...args);
|
|
308
|
+
events.forEach(event => observeUsage(event, args[0]));
|
|
309
|
+
return events;
|
|
310
|
+
};
|
|
311
|
+
}
|
|
263
312
|
const build = resolved.buildRequest.bind(resolved);
|
|
264
313
|
resolved.buildRequest = async (requestParsed, incoming) => {
|
|
265
314
|
const request = await build(requestParsed, incoming);
|
|
@@ -268,7 +317,11 @@ export async function prepareResponsesTransport(
|
|
|
268
317
|
return request;
|
|
269
318
|
};
|
|
270
319
|
if (resolved.runTurn) {
|
|
271
|
-
|
|
320
|
+
const runTurn = resolved.runTurn.bind(resolved);
|
|
321
|
+
rawRunTurns.set(resolved, (requestParsed, incoming, emit) => {
|
|
322
|
+
const response = {};
|
|
323
|
+
return runTurn(requestParsed, incoming, event => { observeUsage(event, response); emit(event); });
|
|
324
|
+
});
|
|
272
325
|
resolved.runTurn = (requestParsed, incoming, emit) => runSelectedTurn(resolved, requestParsed, incoming, emit);
|
|
273
326
|
}
|
|
274
327
|
return resolved;
|
|
@@ -319,6 +372,7 @@ export async function prepareResponsesTransport(
|
|
|
319
372
|
refused = true;
|
|
320
373
|
throw new Error("Account selection changed before the first turn dispatch");
|
|
321
374
|
}
|
|
375
|
+
commitKeyAttemptSend();
|
|
322
376
|
sent = true;
|
|
323
377
|
},
|
|
324
378
|
});
|
|
@@ -351,7 +405,9 @@ export async function prepareResponsesTransport(
|
|
|
351
405
|
&& sentHeaders?.get("authorization") === `Bearer ${snapshot.accessToken}`
|
|
352
406
|
&& !sentHeaders?.has("x-api-key");
|
|
353
407
|
// Reselection can choose a provider override instead of the supplied executor.
|
|
408
|
+
commitKeyAttemptSend();
|
|
354
409
|
const response = await fetchImpl(destination, { ...dispatchInit, redirect: "manual" });
|
|
410
|
+
if (!response.ok) await recordKeyAttemptFailure(logCtx, response, dispatchInit.signal ?? options.abortSignal);
|
|
355
411
|
// Observe each physical response before retries replace it. The binding belongs to
|
|
356
412
|
// this dispatch, so a manual switch cannot file A's headers against B. Header
|
|
357
413
|
// overrides and credential replacement make ownership unprovable: skip those writes.
|
|
@@ -736,6 +792,9 @@ export async function prepareResponsesTransport(
|
|
|
736
792
|
resolveSelectionAdapter,
|
|
737
793
|
refreshRunTurnAdapter,
|
|
738
794
|
oauthDispatch,
|
|
795
|
+
noteRoutedAttemptSend,
|
|
796
|
+
commitKeyAttemptSend,
|
|
797
|
+
bindKeyUsageFromBridge,
|
|
739
798
|
anthropicSessionKey,
|
|
740
799
|
isPassthrough,
|
|
741
800
|
};
|