@bitkyc08/opencodex 2.55.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-VuoiWj9J.js → index-Cz7CLdif.js} +21 -21
- package/gui/dist/index.html +2 -2
- package/package.json +4 -3
- package/src/adapters/base.ts +21 -0
- 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/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/adapter.ts +42 -1
- 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/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +495 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +40 -1452
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +642 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +58 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +630 -0
- package/src/bridge/sse.ts +1462 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -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-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +184 -35
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +566 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +431 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +252 -35
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +305 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +714 -0
- package/src/codex/catalog/routed-gather.ts +895 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- 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/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +567 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +89 -1444
- package/src/codex/lineage.ts +458 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/pool-refresh-backoff.ts +161 -0
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +285 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +780 -0
- package/src/codex/routing/thread-affinity.ts +586 -0
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +370 -2271
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- 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/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +649 -121
- package/src/images/loop.ts +1 -1
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +198 -23
- package/src/lib/spend-reservation-ledger.ts +958 -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 +132 -21
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/workflow-budget.ts +553 -30
- 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/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1228 -0
- package/src/providers/registry/entries-extended.ts +1213 -0
- package/src/providers/registry/model-seeds.ts +912 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +456 -0
- package/src/routing/probe-lease.ts +613 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +601 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +339 -0
- package/src/server/index.ts +45 -2552
- 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/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +372 -4
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +307 -0
- package/src/server/responses/adapter-continuation.ts +540 -0
- package/src/server/responses/adapter-delivery.ts +208 -0
- package/src/server/responses/adapter-dispatch.ts +1042 -0
- 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 +113 -17
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +529 -0
- package/src/server/responses/core-codex-account.ts +907 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +787 -0
- package/src/server/responses/core-errors.ts +170 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +298 -0
- package/src/server/responses/core.ts +192 -8893
- 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 +869 -0
- package/src/server/responses/passthrough-dispatch.ts +1494 -0
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +1080 -0
- package/src/server/responses/request-send-budget.ts +259 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +803 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +476 -0
- package/src/server/responses/sidecar-execution.ts +463 -0
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/workflow-refusal.ts +84 -0
- 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 +34 -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 +147 -1
- package/src/usage/summary.ts +171 -21
- 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
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { ResponsesRequestContext } from "./core-options";
|
|
2
|
+
import { createRequestExecutionBudget, isRequestExecutionBudget } from "../../lib/request-execution-budget";
|
|
3
|
+
import { chargeWorkflowSends, workflowSendCeilingReached } from "../../lib/workflow-budget";
|
|
4
|
+
import { workflowRefusalResponse } from "../workflow-refusal";
|
|
5
|
+
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
6
|
+
import { noteAttemptSend } from "../request-log";
|
|
7
|
+
import { TRANSIENT_RETRY_MAX_ATTEMPTS } from "../../lib/upstream-retry";
|
|
8
|
+
import type {
|
|
9
|
+
DispatchDecision,
|
|
10
|
+
DispatchIntent,
|
|
11
|
+
RequestExecutionBudget,
|
|
12
|
+
SendClass,
|
|
13
|
+
SingleUseDispatchPermit,
|
|
14
|
+
} from "../../lib/request-execution-budget";
|
|
15
|
+
|
|
16
|
+
/** Owns the shared request send counter and recovery permits. */
|
|
17
|
+
export function createResponsesSendBudget(
|
|
18
|
+
requestContext: Pick<ResponsesRequestContext, "options" | "req" | "logCtx">,
|
|
19
|
+
) {
|
|
20
|
+
const { options, req, logCtx } = requestContext;
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
// One transient-retry budget for the whole LOGICAL request, read ABOVE the passthrough branch
|
|
24
|
+
// so that branch shares it too. It used to be a local declared below, which put it in the
|
|
25
|
+
// temporal dead zone for the passthrough sends and left each recovery leg taking the helper's
|
|
26
|
+
// fresh default of 3. It is now a holder carried on options, so a combo child inherits the
|
|
27
|
+
// parent's spend instead of starting over per target -- both halves of the measured
|
|
28
|
+
// amplification in #4546.
|
|
29
|
+
const sendBudget = options.sendBudget ?? createRequestExecutionBudget();
|
|
30
|
+
// The root workflow is the user-visible task. A per-request cap cannot bound a fan-out that
|
|
31
|
+
// sends once per child seven hundred times, so every send charged to the request is charged
|
|
32
|
+
// to the root as well (#4546).
|
|
33
|
+
const workflowRootId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
|
|
34
|
+
const noteTransientSends = (used: number): void => {
|
|
35
|
+
const charged = Math.max(0, used);
|
|
36
|
+
sendBudget.used += charged;
|
|
37
|
+
chargeWorkflowSends(workflowRootId, charged);
|
|
38
|
+
};
|
|
39
|
+
// Refused before any dispatch, and deliberately not by evicting the root's ledger entry:
|
|
40
|
+
// dropping the record to make room would hand the fan-out a fresh allowance, which is the
|
|
41
|
+
// laundering this ceiling exists to stop. The client is told the task needs a new grant
|
|
42
|
+
// rather than being given a synthetic upstream error.
|
|
43
|
+
if (workflowSendCeilingReached(workflowRootId)) {
|
|
44
|
+
// A log context exists here, unlike at HTTP admission, so the row this request writes is
|
|
45
|
+
// marked synthetic rather than reading as a request that vanished with zero sends.
|
|
46
|
+
return workflowRefusalResponse("workflow-sends-exhausted", logCtx, undefined, workflowRootId);
|
|
47
|
+
}
|
|
48
|
+
// No floor. Math.max(1, ...) meant an exhausted request still funded one send on every
|
|
49
|
+
// recovery leg, so a bounded per-leg allowance never became a bounded per-request one.
|
|
50
|
+
const remainingTransientSendBudget = (budget: number): number =>
|
|
51
|
+
isRequestExecutionBudget(sendBudget)
|
|
52
|
+
? sendBudget.remainingBaseSends(budget)
|
|
53
|
+
: Math.max(0, budget - sendBudget.used);
|
|
54
|
+
// The adapter contract needs the full budget, not just the counter. options.sendBudget is
|
|
55
|
+
// typed as the narrow holder so a caller that predates this can still pass one, so narrow it
|
|
56
|
+
// once here rather than asserting at each adapter call site.
|
|
57
|
+
const adapterSendBudget = isRequestExecutionBudget(sendBudget) ? sendBudget : undefined;
|
|
58
|
+
/**
|
|
59
|
+
* Records an adapter's OWN inner retries against this attempt.
|
|
60
|
+
*
|
|
61
|
+
* Ordinal 1 is the send each call site already recorded through `noteRoutedAttemptSend`, so only
|
|
62
|
+
* the extra physical sends are added here and an adapter that does not retry internally
|
|
63
|
+
* leaves its log byte-for-byte as it was. Kiro reaches roughly eighteen sends per call and
|
|
64
|
+
* Cursor re-sends a whole turn, and both reported one; a count that cannot be observed
|
|
65
|
+
* cannot be pinned by a regression, which is why the instrumentation precedes the cap.
|
|
66
|
+
*/
|
|
67
|
+
const noteAdapterPhysicalSend = (
|
|
68
|
+
inputTokens: number | undefined,
|
|
69
|
+
send: { ordinal: number; recovery?: AttemptRecoveryKind },
|
|
70
|
+
): void => {
|
|
71
|
+
if (send.ordinal <= 1) return;
|
|
72
|
+
noteAttemptSend(logCtx.activeAttempt, inputTokens, send.recovery);
|
|
73
|
+
};
|
|
74
|
+
const sendBudgetExhausted = (): boolean =>
|
|
75
|
+
remainingTransientSendBudget(TRANSIENT_RETRY_MAX_ATTEMPTS) === 0;
|
|
76
|
+
/**
|
|
77
|
+
* A credential hop reserves the send its own replay will make, and that replay is a recovery
|
|
78
|
+
* leg. The leg must SPEND the hop's reservation instead of taking a second one: the
|
|
79
|
+
* final-recovery reserve is single, so a rebuild that reserved on top of a hop would be
|
|
80
|
+
* refused and the request would answer with a synthetic 502 in place of the real 429 the hop
|
|
81
|
+
* was recovering from.
|
|
82
|
+
*/
|
|
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
|
+
});
|
|
110
|
+
/**
|
|
111
|
+
* How many sends a recovery leg may make, and the permit that authorises the last one.
|
|
112
|
+
*
|
|
113
|
+
* The base allowance is spent first. Once it is gone a recovery class may still draw the
|
|
114
|
+
* single shared final-recovery reserve -- which is what keeps the validated sanitized rebuild
|
|
115
|
+
* after a 5xx streak alive at four total sends -- but an account move and a rebuild cannot
|
|
116
|
+
* each take one. `countedExternally` is set because these legs run through the retry helper,
|
|
117
|
+
* which reports the same send again through `onSendsConsumed`.
|
|
118
|
+
*/
|
|
119
|
+
const recoverySendAllowance = (
|
|
120
|
+
cap: number,
|
|
121
|
+
sendClass: SendClass,
|
|
122
|
+
targetKey: string,
|
|
123
|
+
): { attempts: number; permit?: SingleUseDispatchPermit } => {
|
|
124
|
+
const base = remainingTransientSendBudget(cap);
|
|
125
|
+
if (base > 0) return { attempts: base };
|
|
126
|
+
if (pendingHopPermit) {
|
|
127
|
+
const hopPermit = pendingHopPermit;
|
|
128
|
+
pendingHopPermit = undefined;
|
|
129
|
+
return { attempts: 1, permit: hopPermit };
|
|
130
|
+
}
|
|
131
|
+
if (!isRequestExecutionBudget(sendBudget)) return { attempts: 0 };
|
|
132
|
+
const decision = sendBudget.reserveDispatch({ sendClass, targetKey, countedExternally: true });
|
|
133
|
+
return decision.allowed ? { attempts: 1, permit: decision.permit } : { attempts: 0 };
|
|
134
|
+
};
|
|
135
|
+
/**
|
|
136
|
+
* One credential hop of this logical request, admitted by the INTERSECTION of two bounds.
|
|
137
|
+
*
|
|
138
|
+
* `GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST` and `ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST`
|
|
139
|
+
* stay exactly as they are: they bound rotation within one credential roster. What neither
|
|
140
|
+
* can see is everything else this request already sent, so three hops layered on a spent
|
|
141
|
+
* budget still reached upstream three more times. A hop now happens only when its own layer
|
|
142
|
+
* cap AND the shared budget both permit it, and the smaller of the two wins.
|
|
143
|
+
*
|
|
144
|
+
* `countedExternally` is for the hops whose replay goes out through the retry helper, which
|
|
145
|
+
* reports the same physical send through `onSendsConsumed`; the others are charged here and
|
|
146
|
+
* nowhere else. A refusal is not an error: the caller keeps the real upstream response --
|
|
147
|
+
* status, `Retry-After`, quota body -- because return-the-last-answer is the exhaustion
|
|
148
|
+
* contract this unit settled on.
|
|
149
|
+
*/
|
|
150
|
+
/**
|
|
151
|
+
* A credential rotation inside ONE provider's roster is "auth-recovery", not
|
|
152
|
+
* "account-failover". The distinction is load-bearing: "account-failover" sets
|
|
153
|
+
* `isAlternateTarget` unconditionally, so under `maxAlternateTargetSends: 1` the first
|
|
154
|
+
* rotation would refuse every later one AND consume the single slot a genuine cross-pool
|
|
155
|
+
* move needs -- a roster whose first two accounts are both 429'd would return the 429
|
|
156
|
+
* while a free third account sat unused. The roster cap bounds how far rotation walks;
|
|
157
|
+
* the shared total bounds how many sends the request makes. Reserve "account-failover"
|
|
158
|
+
* for a real move between pools.
|
|
159
|
+
*/
|
|
160
|
+
const reserveCredentialHop = (
|
|
161
|
+
sendClass: SendClass,
|
|
162
|
+
targetKey: string,
|
|
163
|
+
countedExternally = false,
|
|
164
|
+
): { allowed: boolean; permit?: SingleUseDispatchPermit } => {
|
|
165
|
+
if (!isRequestExecutionBudget(sendBudget)) return { allowed: true };
|
|
166
|
+
const decision = sendBudget.reserveDispatch({ sendClass, targetKey, countedExternally });
|
|
167
|
+
return decision.allowed ? { allowed: true, permit: decision.permit } : { allowed: false };
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Both classes share the one reserve, so this only changes what the decision is called --
|
|
171
|
+
* but a recovery event that says "repair" when a credential refresh drove it is the kind of
|
|
172
|
+
* mislabelled evidence #4592 existed to stop.
|
|
173
|
+
*/
|
|
174
|
+
const recoveryClassFor = (recovery: AttemptRecoveryKind): SendClass =>
|
|
175
|
+
/401|429|oauth|rate-limit|key/.test(recovery) ? "auth-recovery" : "repair";
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
workflowRootId,
|
|
179
|
+
noteTransientSends,
|
|
180
|
+
remainingTransientSendBudget,
|
|
181
|
+
adapterSendBudget,
|
|
182
|
+
adapterDispatchBudget,
|
|
183
|
+
noteAdapterPhysicalSend,
|
|
184
|
+
sendBudgetExhausted,
|
|
185
|
+
get pendingHopPermit(): SingleUseDispatchPermit | undefined {
|
|
186
|
+
return pendingHopPermit;
|
|
187
|
+
},
|
|
188
|
+
set pendingHopPermit(value: SingleUseDispatchPermit | undefined) {
|
|
189
|
+
pendingHopPermit = value;
|
|
190
|
+
},
|
|
191
|
+
recoverySendAllowance,
|
|
192
|
+
reserveCredentialHop,
|
|
193
|
+
recoveryClassFor,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
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,149 @@
|
|
|
1
|
+
import type { ResponsesRequestContext } from "./core-options";
|
|
2
|
+
import type { PreparedResponsesRequest } from "./request-prepare";
|
|
3
|
+
import type { ResponsesTransport } from "./request-transport";
|
|
4
|
+
import type { ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar";
|
|
5
|
+
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
6
|
+
import {
|
|
7
|
+
shouldResolveOpenAiVisionSidecar,
|
|
8
|
+
resolveOpenAiVisionModel,
|
|
9
|
+
planVisionSidecar,
|
|
10
|
+
describeImagesInPlace,
|
|
11
|
+
requiresVisionPreprocessing,
|
|
12
|
+
stripImagesInPlace,
|
|
13
|
+
} from "../../vision";
|
|
14
|
+
import { shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
|
|
15
|
+
import { shouldResolveOpenAiPassthroughWebSearchBridge } from "../../web-search/passthrough-bridge";
|
|
16
|
+
import {
|
|
17
|
+
listOpenAiForwardSidecarCandidates,
|
|
18
|
+
captureExplicitOpenAiCallerAuth,
|
|
19
|
+
resolveFirstUsableOpenAiSidecar,
|
|
20
|
+
} from "../../providers/openai-sidecar";
|
|
21
|
+
import {
|
|
22
|
+
tryClaimNativeMainProfileForTurn as tryClaimStoredSidecarMainProfile,
|
|
23
|
+
} from "../../codex/native-main-admission";
|
|
24
|
+
import { codexAccountSelectionForTurn } from "../lifecycle";
|
|
25
|
+
import {
|
|
26
|
+
CodexPoolAuthenticationError,
|
|
27
|
+
CodexAuthContextError,
|
|
28
|
+
CodexAccountCooldownError,
|
|
29
|
+
CodexThreadAffinityExpiredError,
|
|
30
|
+
CodexMainProfileDrainingError,
|
|
31
|
+
} from "../../codex/auth-context";
|
|
32
|
+
|
|
33
|
+
/** One responsibility of the Responses request pipeline; state owners are explicit. */
|
|
34
|
+
export async function prepareResponsesSidecarAuth(
|
|
35
|
+
requestContext: Pick<ResponsesRequestContext, "options" | "config" | "req">,
|
|
36
|
+
requestState: Pick<
|
|
37
|
+
PreparedResponsesRequest,
|
|
38
|
+
| "parsed"
|
|
39
|
+
| "route"
|
|
40
|
+
| "selectedForwardHeaders"
|
|
41
|
+
| "translatorBudget"
|
|
42
|
+
>,
|
|
43
|
+
transportState: Pick<ResponsesTransport, "adapter" | "isPassthrough">,
|
|
44
|
+
) {
|
|
45
|
+
const { options, config, req } = requestContext;
|
|
46
|
+
const { parsed, route, translatorBudget } = requestState;
|
|
47
|
+
const { isPassthrough } = transportState;
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined;
|
|
51
|
+
const visionDescribeTerminal = options.visionDescribeTerminal === true;
|
|
52
|
+
const routedCompaction = parsed._compactionRequest === true
|
|
53
|
+
&& !isCanonicalOpenAiForwardProvider(route.provider);
|
|
54
|
+
const needsOpenAiVision = !visionDescribeTerminal
|
|
55
|
+
&& shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName);
|
|
56
|
+
const needsOpenAiSearch = !routedCompaction && !transportState.adapter.runTurn
|
|
57
|
+
&& (shouldResolveOpenAiWebSearchSidecar(config, parsed, isPassthrough)
|
|
58
|
+
|| shouldResolveOpenAiPassthroughWebSearchBridge(route.provider, parsed, isPassthrough));
|
|
59
|
+
if (needsOpenAiVision || needsOpenAiSearch) {
|
|
60
|
+
try {
|
|
61
|
+
const candidates = listOpenAiForwardSidecarCandidates(config);
|
|
62
|
+
let sidecarAuth = options.openAiSidecarAuth;
|
|
63
|
+
if (!sidecarAuth && options.allowStoredOpenAiSidecarAuth === true
|
|
64
|
+
&& route.codexAccountId === undefined
|
|
65
|
+
&& candidates.some(candidate => candidate.accountMode === "direct")
|
|
66
|
+
&& tryClaimStoredSidecarMainProfile(options.turnAdmissionLease)) {
|
|
67
|
+
// Request-local helper authority only: never promote this pair to caller, primary,
|
|
68
|
+
// or retry credentials. Claim before reading so profile switches remain fenced.
|
|
69
|
+
try {
|
|
70
|
+
const { getMainAccountToken } = await import("../../codex/main-account");
|
|
71
|
+
const token = getMainAccountToken();
|
|
72
|
+
if (token) sidecarAuth = captureExplicitOpenAiCallerAuth(new Headers({
|
|
73
|
+
authorization: `Bearer ${token.accessToken}`, "chatgpt-account-id": token.chatgptAccountId,
|
|
74
|
+
}), config);
|
|
75
|
+
} catch { /* stored enrichment is optional */ }
|
|
76
|
+
}
|
|
77
|
+
// Preserve explicit OpenAI helper auth across route changes without returning it to
|
|
78
|
+
// primary-provider headers or alternate-main retry. The resolver revalidates scope.
|
|
79
|
+
const sidecarHeaders = new Headers(req.headers);
|
|
80
|
+
sidecarHeaders.delete("authorization");
|
|
81
|
+
sidecarHeaders.delete("chatgpt-account-id");
|
|
82
|
+
if (sidecarAuth) {
|
|
83
|
+
sidecarHeaders.set("authorization", sidecarAuth.authorization);
|
|
84
|
+
sidecarHeaders.set("chatgpt-account-id", sidecarAuth.chatgptAccountId);
|
|
85
|
+
}
|
|
86
|
+
openAiSidecar = await resolveFirstUsableOpenAiSidecar(
|
|
87
|
+
candidates,
|
|
88
|
+
sidecarHeaders,
|
|
89
|
+
config,
|
|
90
|
+
{
|
|
91
|
+
admission: options.admission,
|
|
92
|
+
codexAuthPolicy: options.codexAuthPolicy,
|
|
93
|
+
// Account-qualified native routes are passthrough, so their in-turn helper is vision.
|
|
94
|
+
// Scope its cooldown and outcome to the helper model, not the routed text model.
|
|
95
|
+
...(route.codexAccountId !== undefined
|
|
96
|
+
? { exactAccount: { accountId: route.codexAccountId, modelId: resolveOpenAiVisionModel(config) } }
|
|
97
|
+
: {}),
|
|
98
|
+
beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// Sidecars are optional helpers for an otherwise independent routed turn.
|
|
103
|
+
// An unavailable/cooling/expired Multi credential disables the helper; it
|
|
104
|
+
// must not turn a valid routed-provider request into a Codex-auth failure.
|
|
105
|
+
if (
|
|
106
|
+
!(err instanceof CodexPoolAuthenticationError)
|
|
107
|
+
&& !(err instanceof CodexAuthContextError)
|
|
108
|
+
&& !(err instanceof CodexAccountCooldownError)
|
|
109
|
+
&& !(err instanceof CodexThreadAffinityExpiredError)
|
|
110
|
+
&& !(err instanceof CodexMainProfileDrainingError)
|
|
111
|
+
) throw err;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Describe each
|
|
116
|
+
// attached image through the selected sidecar backend and replace it with text BEFORE the main
|
|
117
|
+
// call, so the text-only model can reason about it.
|
|
118
|
+
// Terminal describe fence (roadmap 180): the sidecar's OWN loopback describe
|
|
119
|
+
// call must never plan another describe. The flag arrives from the Chat
|
|
120
|
+
// surface (whose bridge rebuilds headers) or as the raw header for native
|
|
121
|
+
// Responses callers. Marked + text-only routed model → strip, depth cap 1.
|
|
122
|
+
const visionPlan = visionDescribeTerminal
|
|
123
|
+
? undefined
|
|
124
|
+
: planVisionSidecar(config, route.provider, route.modelId, parsed, openAiSidecar, {
|
|
125
|
+
admission: options.admission, codexAuthPolicy: options.codexAuthPolicy, providerName: route.providerName,
|
|
126
|
+
});
|
|
127
|
+
const recordSidecarOutcome = openAiSidecar?.recordOutcome;
|
|
128
|
+
if (visionPlan) {
|
|
129
|
+
await describeImagesInPlace(
|
|
130
|
+
parsed,
|
|
131
|
+
visionPlan,
|
|
132
|
+
openAiSidecar?.headers ?? requestState.selectedForwardHeaders,
|
|
133
|
+
options.abortSignal,
|
|
134
|
+
recordSidecarOutcome,
|
|
135
|
+
translatorBudget,
|
|
136
|
+
);
|
|
137
|
+
} else if (requiresVisionPreprocessing(config, route.provider, route.modelId, route.providerName)) {
|
|
138
|
+
// Image capability is not positively proven but no sidecar plan is dispatchable: fail closed.
|
|
139
|
+
// Never forward raw image bytes to an unverified upstream.
|
|
140
|
+
stripImagesInPlace(parsed, translatorBudget);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
openAiSidecar,
|
|
145
|
+
routedCompaction,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type ResponsesSidecarAuth = Exclude<Awaited<ReturnType<typeof prepareResponsesSidecarAuth>>, Response>;
|
|
@@ -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
|
+
}
|