@bitkyc08/opencodex 2.58.0 → 2.60.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/README.md +28 -10
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +289 -16
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
|
@@ -41,7 +41,6 @@ import {
|
|
|
41
41
|
import {
|
|
42
42
|
copyPreviousResponseReplayProvenance,
|
|
43
43
|
expandPreviousResponseInput,
|
|
44
|
-
previousResponseScopeMismatch,
|
|
45
44
|
previousResponseReplayFailure,
|
|
46
45
|
markBodyNonPersistable,
|
|
47
46
|
previousResponseProviderState,
|
|
@@ -56,6 +55,7 @@ import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../re
|
|
|
56
55
|
import { requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log";
|
|
57
56
|
import type { RouteResult } from "../../router";
|
|
58
57
|
import {
|
|
58
|
+
captureRouteStaticPolicy,
|
|
59
59
|
routeConcreteModel,
|
|
60
60
|
routeCompactionModel,
|
|
61
61
|
routeModel,
|
|
@@ -105,7 +105,9 @@ import { hasUnmappedRoutedCustomToolOutput } from "../../responses/custom-tool-c
|
|
|
105
105
|
import { PROVIDER_OWNED_CONTINUATION_WIRES, resolvedAdapterWire } from "../../responses/continuation-ownership";
|
|
106
106
|
import {
|
|
107
107
|
isCodexReserveHelperUnsupported,
|
|
108
|
+
isCodexReserveOptInMissing,
|
|
108
109
|
CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE,
|
|
110
|
+
CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE,
|
|
109
111
|
} from "../../codex/loopback-target";
|
|
110
112
|
import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission";
|
|
111
113
|
import { nativeContextLimits } from "../../codex/catalog";
|
|
@@ -117,6 +119,7 @@ import {
|
|
|
117
119
|
codexLogAccountId,
|
|
118
120
|
} from "./core-codex-account";
|
|
119
121
|
import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health";
|
|
122
|
+
import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing";
|
|
120
123
|
import { codexAuthContextLogLabel } from "../../codex/account-label";
|
|
121
124
|
import {
|
|
122
125
|
conversationStateBindingFromAuth,
|
|
@@ -147,6 +150,13 @@ export async function prepareResponsesRequest(
|
|
|
147
150
|
}
|
|
148
151
|
return decodeRequestErrorResponse(err, "responses");
|
|
149
152
|
}
|
|
153
|
+
if (!options.comboAttempt && !options.compactionRoutingOverride && inboundWire === "responses") {
|
|
154
|
+
options.compactionRoutingOverride = applyCompactionRoutingOverride(body, req.headers, config, {
|
|
155
|
+
endpoint: "responses",
|
|
156
|
+
transport: options.inboundTransport,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
options.onRequestBodyParsed?.(body);
|
|
150
160
|
// An effort row naming a table-less combo (`combo/x--high`) must reach the combo dispatcher
|
|
151
161
|
// as its base id, so the selector is normalized here, before comboIdFromRawBody reads model.
|
|
152
162
|
const comboRows = !options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)
|
|
@@ -178,7 +188,7 @@ export async function prepareResponsesRequest(
|
|
|
178
188
|
}
|
|
179
189
|
// Compaction may send the last client-visible bare model after a combo switch.
|
|
180
190
|
// Configured selectors take precedence; otherwise recall before combo dispatch (#3891).
|
|
181
|
-
if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) {
|
|
191
|
+
if (!options.comboAttempt && !options.compactionRoutingOverride && body && typeof body === "object" && !Array.isArray(body)) {
|
|
182
192
|
const rawModel = (body as { model?: unknown }).model;
|
|
183
193
|
const rawInput = (body as { input?: unknown }).input;
|
|
184
194
|
const isCompactionTrigger = Array.isArray(rawInput)
|
|
@@ -200,7 +210,7 @@ export async function prepareResponsesRequest(
|
|
|
200
210
|
// hops — which only exist inside that loop — are unreachable (#4129). Rewrite the selector
|
|
201
211
|
// here instead, before comboIdFromRawBody reads `model`, and identify the combo by CONFIG
|
|
202
212
|
// LOOKUP so the check can never observe a one-candidate collapse.
|
|
203
|
-
if (!options.comboAttempt && body && typeof body === "object" && !Array.isArray(body)) {
|
|
213
|
+
if (!options.comboAttempt && !options.compactionRoutingOverride && body && typeof body === "object" && !Array.isArray(body)) {
|
|
204
214
|
const shadowIntercept = config.shadowCallIntercept;
|
|
205
215
|
const rawShadowModel = (body as { model?: unknown }).model;
|
|
206
216
|
if (shadowIntercept?.enabled && shadowIntercept.model && typeof rawShadowModel === "string"
|
|
@@ -231,16 +241,23 @@ export async function prepareResponsesRequest(
|
|
|
231
241
|
(body as { input?: unknown } | undefined)?.input,
|
|
232
242
|
);
|
|
233
243
|
const inboundClientThreadId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
|
|
244
|
+
// The request's OWN thread, which `x-codex-parent-thread-id` is not: parallel children of one
|
|
245
|
+
// parent all present the same parent id. `codexConversationIdentity` already reads this header
|
|
246
|
+
// for the same reason, and a surface that must tell siblings apart needs it too (#5033).
|
|
247
|
+
const inboundOwnThreadId = req.headers.get("thread-id")?.trim() || undefined;
|
|
234
248
|
const cursorClientThreadId = codexPoolAffinityKey(req.headers);
|
|
235
249
|
const originalBody = body;
|
|
236
250
|
if (options.comboReplaySnapshot) {
|
|
237
251
|
copyPreviousResponseReplayProvenance(options.comboReplaySnapshot.sourceBody, body);
|
|
238
252
|
} else {
|
|
239
253
|
body = expandPreviousResponseInput(body, inboundClientThreadId);
|
|
240
|
-
|
|
241
|
-
|
|
254
|
+
const replayFailure = previousResponseReplayFailure(body);
|
|
255
|
+
if (replayFailure?.reason === "scope_mismatch") {
|
|
256
|
+
// Bounded and content-free: no task scope and nothing about the retained entry.
|
|
257
|
+
console.warn("[opencodex] refusing continuation because the client task scope does not match replay state");
|
|
242
258
|
}
|
|
243
|
-
|
|
259
|
+
// Local replay failures require full client replay.
|
|
260
|
+
if (replayFailure) {
|
|
244
261
|
return formatErrorResponse(
|
|
245
262
|
400,
|
|
246
263
|
"previous_response_not_found",
|
|
@@ -256,10 +273,14 @@ export async function prepareResponsesRequest(
|
|
|
256
273
|
// encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE
|
|
257
274
|
// parsing so every consumer sees the payload: parseRequest (routed/translated providers read
|
|
258
275
|
// the parsed messages) and the native passthrough (_rawBody is this same object, serialized
|
|
259
|
-
// verbatim).
|
|
276
|
+
// verbatim). Structurally valid backend ciphertext stays byte-identical; encoded-looking unknown
|
|
277
|
+
// slots remain opaque only until final-route handling can preserve or strip them safely.
|
|
260
278
|
{
|
|
261
279
|
const rewritten = sanitizeEncryptedContentInPlace(
|
|
262
280
|
(body as { input?: unknown } | undefined)?.input,
|
|
281
|
+
// The final destination is not known yet. Keep ambiguous encoded slots opaque until route
|
|
282
|
+
// selection can either strip them for a third party or apply strict native classification.
|
|
283
|
+
{ preserveUnknownOpaqueSlots: true },
|
|
263
284
|
);
|
|
264
285
|
if (rewritten > 0)
|
|
265
286
|
console.warn(
|
|
@@ -303,6 +324,7 @@ export async function prepareResponsesRequest(
|
|
|
303
324
|
? options.comboReplaySnapshot.providerContinuation
|
|
304
325
|
: previousResponseProviderState(parsed.previousResponseId);
|
|
305
326
|
if (providerContinuationCandidate) parsed._providerContinuationCandidate = providerContinuationCandidate;
|
|
327
|
+
if (inboundOwnThreadId) parsed._codexOwnThreadId = inboundOwnThreadId;
|
|
306
328
|
if (inboundClientThreadId) {
|
|
307
329
|
parsed._clientThreadId = inboundClientThreadId;
|
|
308
330
|
} else if (
|
|
@@ -371,7 +393,7 @@ export async function prepareResponsesRequest(
|
|
|
371
393
|
if (!logCtx.conversationId) {
|
|
372
394
|
logCtx.conversationId = resolvedConversationId;
|
|
373
395
|
}
|
|
374
|
-
logCtx.requestedModel = parsed.modelId;
|
|
396
|
+
logCtx.requestedModel = options.compactionRoutingOverride?.sourceModel ?? parsed.modelId;
|
|
375
397
|
logCtx.requestedEffort = parsed.options.reasoning;
|
|
376
398
|
// What this request may spend beyond its input, for the durable spend reservation (#4707).
|
|
377
399
|
// Read from the caller rather than from the adapter's serialized body, because the
|
|
@@ -389,19 +411,29 @@ export async function prepareResponsesRequest(
|
|
|
389
411
|
|
|
390
412
|
let route: RouteResult;
|
|
391
413
|
let credentialDomainWasRewritten = false;
|
|
414
|
+
const captureInboundRoutePolicy = (candidate: RouteResult): RouteResult => {
|
|
415
|
+
candidate.staticPolicy = captureRouteStaticPolicy(
|
|
416
|
+
candidate.providerName,
|
|
417
|
+
candidate.modelId,
|
|
418
|
+
candidate.provider,
|
|
419
|
+
candidate.staticPolicy.effectiveAlias,
|
|
420
|
+
inboundWire,
|
|
421
|
+
);
|
|
422
|
+
return candidate;
|
|
423
|
+
};
|
|
392
424
|
try {
|
|
393
425
|
// A `compaction_trigger` turn may name a bare native model the operator has
|
|
394
426
|
// no canonical OpenAI route for (#2901). Only the initial compaction route
|
|
395
427
|
// may fall back to the configured default provider; combo attempts and the
|
|
396
428
|
// later fallback/recovery re-routes keep the ordinary reservation.
|
|
397
|
-
const resolveRoute = (modelId: string) => options.comboAttempt
|
|
429
|
+
const resolveRoute = (modelId: string) => captureInboundRoutePolicy(options.comboAttempt
|
|
398
430
|
? routeConcreteModel(config, modelId)
|
|
399
431
|
: parsed._compactionRequest === true
|
|
400
432
|
? routeCompactionModel(config, modelId, evidenceFromBody(parsed._rawBody))
|
|
401
|
-
: routeModel(config, modelId, evidenceFromBody(parsed._rawBody));
|
|
433
|
+
: routeModel(config, modelId, evidenceFromBody(parsed._rawBody)));
|
|
402
434
|
const _sci = config.shadowCallIntercept;
|
|
403
435
|
let shadowRoute: RouteResult | undefined;
|
|
404
|
-
if (_sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
|
|
436
|
+
if (!options.compactionRoutingOverride && _sci?.enabled && _sci.model && isShadowSourceModel(parsed.modelId, _sci.sourceModels)) {
|
|
405
437
|
const sourcePrefix = shadowSourceModelPrefix(parsed.modelId, _sci.sourceModels)!;
|
|
406
438
|
let sourceIdentity = { providerName: OPENAI_CODEX_PROVIDER_ID, modelId: sourcePrefix };
|
|
407
439
|
try {
|
|
@@ -429,8 +461,17 @@ export async function prepareResponsesRequest(
|
|
|
429
461
|
shadowRoute = targetRoute;
|
|
430
462
|
}
|
|
431
463
|
}
|
|
432
|
-
if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
|
|
464
|
+
if (parsed._compactionRequest === true || options.compactionRoutingOverride) parsed._cursorIsolateConversation = true;
|
|
433
465
|
route = shadowRoute ?? resolveRoute(parsed.modelId);
|
|
466
|
+
if (options.compactionRoutingOverride && !compactionRoutingKeepsProviderIdentity(config, options.compactionRoutingOverride, route)) {
|
|
467
|
+
credentialDomainWasRewritten = true;
|
|
468
|
+
// The destination does not share the conversation's credential domain, so it can neither
|
|
469
|
+
// verify the source backend's reasoning ciphertext nor decode its native compaction blob.
|
|
470
|
+
// This is the same condition an account change already reports (account-change-state.ts),
|
|
471
|
+
// and the serializer turns a stored summary into readable text instead of dropping it.
|
|
472
|
+
parsed._stripReasoningEncryptedContent = true;
|
|
473
|
+
if (parsed._compactionRequest === true) parsed._portableCompaction = true;
|
|
474
|
+
}
|
|
434
475
|
logCtx.routeDecision = route.routeDecision;
|
|
435
476
|
} catch (err) {
|
|
436
477
|
if (err instanceof NoAvailableComboTargetsError) {
|
|
@@ -626,7 +667,9 @@ export async function prepareResponsesRequest(
|
|
|
626
667
|
|
|
627
668
|
if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
|
|
628
669
|
try {
|
|
629
|
-
route =
|
|
670
|
+
route = captureInboundRoutePolicy(
|
|
671
|
+
routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)),
|
|
672
|
+
);
|
|
630
673
|
credentialDomainWasRewritten = true;
|
|
631
674
|
logCtx.routeDecision = route.routeDecision;
|
|
632
675
|
} catch (err) {
|
|
@@ -699,6 +742,7 @@ export async function prepareResponsesRequest(
|
|
|
699
742
|
"_providerContinuationOwner",
|
|
700
743
|
"_cursorConversationId",
|
|
701
744
|
"_clientThreadId",
|
|
745
|
+
"_codexOwnThreadId",
|
|
702
746
|
"_promptCacheKeyIsSharedCohort",
|
|
703
747
|
"_cursorClientThreadId",
|
|
704
748
|
"_reasoningReplayScope",
|
|
@@ -823,7 +867,9 @@ export async function prepareResponsesRequest(
|
|
|
823
867
|
|
|
824
868
|
if (fallback?.to && !slugsEquivalent(fallback.to, route.modelId)) {
|
|
825
869
|
try {
|
|
826
|
-
route =
|
|
870
|
+
route = captureInboundRoutePolicy(
|
|
871
|
+
routeModel(config, fallback.to, evidenceFromBody(parsed._rawBody)),
|
|
872
|
+
);
|
|
827
873
|
credentialDomainWasRewritten = true;
|
|
828
874
|
logCtx.routeDecision = route.routeDecision;
|
|
829
875
|
} catch (err) {
|
|
@@ -849,6 +895,17 @@ export async function prepareResponsesRequest(
|
|
|
849
895
|
|
|
850
896
|
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
851
897
|
|
|
898
|
+
if (inboundWire === "responses" && isCanonicalOpenAiForwardProvider(route.provider)) {
|
|
899
|
+
const rewritten = sanitizeEncryptedContentInPlace(
|
|
900
|
+
(body as { input?: unknown } | undefined)?.input,
|
|
901
|
+
);
|
|
902
|
+
if (rewritten > 0) {
|
|
903
|
+
console.warn(
|
|
904
|
+
`[opencodex] rewrote ${rewritten} non-Fernet encrypted_content part(s) before canonical native replay`,
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
852
909
|
// Encrypted child tasks may reach the canonical native backend or an explicitly trusted
|
|
853
910
|
// direct Responses route. This runs against the FINAL route so native-only fallback can
|
|
854
911
|
// rescue an incompatible primary without weakening combo behavior.
|
|
@@ -894,6 +951,7 @@ export async function prepareResponsesRequest(
|
|
|
894
951
|
route.modelId,
|
|
895
952
|
route.provider,
|
|
896
953
|
inboundWire,
|
|
954
|
+
route.staticPolicy,
|
|
897
955
|
);
|
|
898
956
|
if (wireProvider.adapter === "openai-responses" && !isCanonicalOpenAiForwardProvider(wireProvider)) {
|
|
899
957
|
const repaired = stripAgentMessageCiphertextInPlace((body as { input?: unknown } | undefined)?.input);
|
|
@@ -917,12 +975,12 @@ export async function prepareResponsesRequest(
|
|
|
917
975
|
return formatErrorResponse(
|
|
918
976
|
400,
|
|
919
977
|
"previous_response_not_found",
|
|
920
|
-
"
|
|
978
|
+
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
|
|
921
979
|
);
|
|
922
980
|
}
|
|
923
981
|
|
|
924
982
|
if (hasUnexpandedPreviousResponse) {
|
|
925
|
-
const continuationProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
983
|
+
const continuationProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy);
|
|
926
984
|
// Can the DESTINATION see the history this process failed to restore? Only the native
|
|
927
985
|
// Responses passthrough can: it forwards previous_response_id to a backend that stored the
|
|
928
986
|
// chain. Every translated wire rebuilds the conversation from this request's input alone —
|
|
@@ -943,7 +1001,7 @@ export async function prepareResponsesRequest(
|
|
|
943
1001
|
return formatErrorResponse(
|
|
944
1002
|
400,
|
|
945
1003
|
"previous_response_not_found",
|
|
946
|
-
"
|
|
1004
|
+
"Continuation state is unavailable or corrupt; resend the full conversation without previous_response_id.",
|
|
947
1005
|
);
|
|
948
1006
|
}
|
|
949
1007
|
}
|
|
@@ -976,6 +1034,27 @@ export async function prepareResponsesRequest(
|
|
|
976
1034
|
options.admission, options.visionDescribeTerminal === true)) {
|
|
977
1035
|
return formatErrorResponse(400, "invalid_request_error", CODEX_RESERVE_HELPER_UNSUPPORTED_MESSAGE);
|
|
978
1036
|
}
|
|
1037
|
+
// #4940: the opt-in is off, so every Reserve affordance in this process is inert -- no catalog
|
|
1038
|
+
// row, no main-credential substitution, no authorization handshake, and no `luna-reserve` header
|
|
1039
|
+
// on the send. Forwarding `gpt-reserve` as an ordinary native model therefore buys nothing but a
|
|
1040
|
+
// 429 "The usage limit has been reached", which names neither the real cause nor the setting the
|
|
1041
|
+
// operator would have to change. Refuse here instead, on the same terms and in the same place as
|
|
1042
|
+
// the helper refusal above: after alias/combo resolution, before auth, host-circuit budget or any
|
|
1043
|
+
// upstream byte.
|
|
1044
|
+
//
|
|
1045
|
+
// Two narrowings beyond the predicate, both about not answering a question this refusal cannot
|
|
1046
|
+
// answer correctly. A terminal vision/search helper is excluded because enabling the opt-in would
|
|
1047
|
+
// not make it work -- it would produce the helper refusal above instead, so telling that caller to
|
|
1048
|
+
// enable the flag is advice that does not hold. Non-native inbound wires are excluded because a
|
|
1049
|
+
// `gpt-reserve` selector reaching us over Chat or Anthropic Messages is an operator-authored
|
|
1050
|
+
// route (a `claudeCode.modelMap` entry, say), not a Codex client that was forced onto Reserve by
|
|
1051
|
+
// its own usage snapshot, and that route keeps whatever behavior it has today.
|
|
1052
|
+
if (inboundWire === "responses"
|
|
1053
|
+
&& options.visionDescribeTerminal !== true
|
|
1054
|
+
&& isCanonicalOpenAiForwardProvider(route.provider)
|
|
1055
|
+
&& isCodexReserveOptInMissing(options.codexAuthPolicy ?? config, route.modelId, options.admission)) {
|
|
1056
|
+
return formatErrorResponse(400, "invalid_request_error", CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE);
|
|
1057
|
+
}
|
|
979
1058
|
// Refuse an input that cannot plausibly fit the model context window before spending auth,
|
|
980
1059
|
// circuit budget, or upstream bandwidth on a turn the provider will reject anyway (#1412).
|
|
981
1060
|
//
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { ResponsesRequestContext } from "./core-options";
|
|
2
2
|
import { createRequestExecutionBudget, isRequestExecutionBudget } from "../../lib/request-execution-budget";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
chargeWorkflowSends,
|
|
5
|
+
workflowSendCeilingReached,
|
|
6
|
+
workflowSpendCeilingReached,
|
|
7
|
+
} from "../../lib/workflow-budget";
|
|
4
8
|
import { workflowRefusalResponse } from "../workflow-refusal";
|
|
5
|
-
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
6
|
-
import { noteAttemptSend } from "../request-log";
|
|
9
|
+
import type { AttemptRecoveryKind, AttemptRecoveryWithheld } from "../../usage/log";
|
|
10
|
+
import { noteAttemptRecoveryWithheld, noteAttemptSend } from "../request-log";
|
|
7
11
|
import { TRANSIENT_RETRY_MAX_ATTEMPTS } from "../../lib/upstream-retry";
|
|
8
12
|
import type {
|
|
9
13
|
DispatchDecision,
|
|
@@ -13,6 +17,28 @@ import type {
|
|
|
13
17
|
SingleUseDispatchPermit,
|
|
14
18
|
} from "../../lib/request-execution-budget";
|
|
15
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The transient-5xx ladder cap for ONE leg, given the provider's configured request total.
|
|
22
|
+
*
|
|
23
|
+
* `remainingTransientSendBudget(cap)` treats `cap` as a ceiling on what REMAINS, which is the
|
|
24
|
+
* right shape for the fixed constant: every leg may ask for up to three, and the request-wide
|
|
25
|
+
* base allowance is what actually bounds the total. A configured `transientRetryOn5xx.attempts`
|
|
26
|
+
* is documented as the total for one request including the first send, so it has to be reduced
|
|
27
|
+
* by what the request already sent before it is intersected with that allowance. Passing it
|
|
28
|
+
* straight through would make it a per-leg ceiling instead, and a request configured at one send
|
|
29
|
+
* could still reach upstream again on a recovery leg (#4893).
|
|
30
|
+
*
|
|
31
|
+
* An absent policy returns the constant unchanged, so a provider that configures nothing behaves
|
|
32
|
+
* exactly as it does today at every call site.
|
|
33
|
+
*/
|
|
34
|
+
export function transientSendCapFor(
|
|
35
|
+
configuredAttempts: number | undefined,
|
|
36
|
+
sendsUsed: number,
|
|
37
|
+
): number {
|
|
38
|
+
if (configuredAttempts === undefined) return TRANSIENT_RETRY_MAX_ATTEMPTS;
|
|
39
|
+
return Math.max(0, configuredAttempts - Math.max(0, sendsUsed));
|
|
40
|
+
}
|
|
41
|
+
|
|
16
42
|
/** Owns the shared request send counter and recovery permits. */
|
|
17
43
|
export function createResponsesSendBudget(
|
|
18
44
|
requestContext: Pick<ResponsesRequestContext, "options" | "req" | "logCtx">,
|
|
@@ -45,6 +71,22 @@ export function createResponsesSendBudget(
|
|
|
45
71
|
// marked synthetic rather than reading as a request that vanished with zero sends.
|
|
46
72
|
return workflowRefusalResponse("workflow-sends-exhausted", logCtx, undefined, workflowRootId);
|
|
47
73
|
}
|
|
74
|
+
// The token ceiling asked at the same seam, for the same reason the count one is asked here.
|
|
75
|
+
// Without it a spent root reaches the dispatch ladder, is refused by the ledger at the first
|
|
76
|
+
// physical send, and answers with the generic send-budget error every exhausted request
|
|
77
|
+
// returns -- a refusal an operator cannot tell from an ordinary budget exhaustion, on a
|
|
78
|
+
// ceiling they configured themselves. Asked before dispatch, it names the scope and the
|
|
79
|
+
// number instead. Returns undefined and touches no ledger when no ceiling is configured.
|
|
80
|
+
const spentCeiling = workflowSpendCeilingReached(workflowRootId);
|
|
81
|
+
if (spentCeiling) {
|
|
82
|
+
return workflowRefusalResponse(
|
|
83
|
+
"workflow-spend-exhausted",
|
|
84
|
+
logCtx,
|
|
85
|
+
undefined,
|
|
86
|
+
workflowRootId,
|
|
87
|
+
spentCeiling,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
48
90
|
// No floor. Math.max(1, ...) meant an exhausted request still funded one send on every
|
|
49
91
|
// recovery leg, so a bounded per-leg allowance never became a bounded per-request one.
|
|
50
92
|
const remainingTransientSendBudget = (budget: number): number =>
|
|
@@ -67,12 +109,36 @@ export function createResponsesSendBudget(
|
|
|
67
109
|
const noteAdapterPhysicalSend = (
|
|
68
110
|
inputTokens: number | undefined,
|
|
69
111
|
send: { ordinal: number; recovery?: AttemptRecoveryKind },
|
|
112
|
+
options: { readonly includeFirst?: boolean } = {},
|
|
70
113
|
): void => {
|
|
71
|
-
|
|
114
|
+
// Ordinal 1 is skipped because the caller normally records it before dispatch. An adapter
|
|
115
|
+
// that reports every send asks for it to be counted here instead, so that the first send is
|
|
116
|
+
// logged where it actually happens rather than before admission could still refuse it.
|
|
117
|
+
if (send.ordinal <= 1 && options.includeFirst !== true) return;
|
|
72
118
|
noteAttemptSend(logCtx.activeAttempt, inputTokens, send.recovery);
|
|
73
119
|
};
|
|
74
|
-
|
|
75
|
-
|
|
120
|
+
/**
|
|
121
|
+
* Records a recovery an adapter was ready to make and the budget refused.
|
|
122
|
+
*
|
|
123
|
+
* No send happened, so this deliberately does not touch `sendCount`. It is the other half of
|
|
124
|
+
* the pair that makes a one-send log readable: no recovery kind AND no withheld reason means
|
|
125
|
+
* nothing was eligible; a withheld reason means something was (#5044).
|
|
126
|
+
*/
|
|
127
|
+
const noteAdapterRecoveryWithheld = (withheld: { reason: AttemptRecoveryWithheld }): void => {
|
|
128
|
+
noteAttemptRecoveryWithheld(logCtx.activeAttempt, withheld.reason);
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Whether this request has any base send left under `cap`.
|
|
132
|
+
*
|
|
133
|
+
* The cap is a parameter because a lane that reads a provider's configured
|
|
134
|
+
* `transientRetryOn5xx` ladder has to ask this question at the SAME cap its sends use.
|
|
135
|
+
* Asking at the constant while dispatching at a configured value lets a provider with
|
|
136
|
+
* headroom be told it is exhausted, and lets one configured below the constant pass this
|
|
137
|
+
* check and then be refused at the send (#4893). Defaulted, so every existing caller keeps
|
|
138
|
+
* the constant it already used.
|
|
139
|
+
*/
|
|
140
|
+
const sendBudgetExhausted = (cap: number = TRANSIENT_RETRY_MAX_ATTEMPTS): boolean =>
|
|
141
|
+
remainingTransientSendBudget(cap) === 0;
|
|
76
142
|
/**
|
|
77
143
|
* A credential hop reserves the send its own replay will make, and that replay is a recovery
|
|
78
144
|
* leg. The leg must SPEND the hop's reservation instead of taking a second one: the
|
|
@@ -113,16 +179,22 @@ export function createResponsesSendBudget(
|
|
|
113
179
|
* The base allowance is spent first. Once it is gone a recovery class may still draw the
|
|
114
180
|
* single shared final-recovery reserve -- which is what keeps the validated sanitized rebuild
|
|
115
181
|
* after a 5xx streak alive at four total sends -- but an account move and a rebuild cannot
|
|
116
|
-
* each take one.
|
|
117
|
-
*
|
|
182
|
+
* each take one. A caller with an exact provider total can suppress that reserve. The
|
|
183
|
+
* `countedExternally` flag exists because these legs run through the retry helper, which reports
|
|
184
|
+
* the same send again through `onSendsConsumed`.
|
|
118
185
|
*/
|
|
119
186
|
const recoverySendAllowance = (
|
|
120
187
|
cap: number,
|
|
121
188
|
sendClass: SendClass,
|
|
122
189
|
targetKey: string,
|
|
190
|
+
options: { allowFinalRecoveryReserve?: boolean } = {},
|
|
123
191
|
): { attempts: number; permit?: SingleUseDispatchPermit } => {
|
|
124
192
|
const base = remainingTransientSendBudget(cap);
|
|
125
193
|
if (base > 0) return { attempts: base };
|
|
194
|
+
// A provider-configured transient total is an exact physical-send ceiling. Once it is
|
|
195
|
+
// exhausted, the request-wide recovery reserve must not silently widen it. The default stays
|
|
196
|
+
// permissive so unconfigured providers retain the guarded profile's fourth recovery send.
|
|
197
|
+
if (options.allowFinalRecoveryReserve === false) return { attempts: 0 };
|
|
126
198
|
if (pendingHopPermit) {
|
|
127
199
|
const hopPermit = pendingHopPermit;
|
|
128
200
|
pendingHopPermit = undefined;
|
|
@@ -178,9 +250,18 @@ export function createResponsesSendBudget(
|
|
|
178
250
|
workflowRootId,
|
|
179
251
|
noteTransientSends,
|
|
180
252
|
remainingTransientSendBudget,
|
|
253
|
+
/**
|
|
254
|
+
* Physical sends this logical request has already made.
|
|
255
|
+
*
|
|
256
|
+
* A live getter, not a snapshot: it is read once per dispatch leg to resolve a configured
|
|
257
|
+
* ladder, and a value frozen at construction would answer for a request that had sent
|
|
258
|
+
* nothing.
|
|
259
|
+
*/
|
|
260
|
+
get sendsUsed(): number { return sendBudget.used; },
|
|
181
261
|
adapterSendBudget,
|
|
182
262
|
adapterDispatchBudget,
|
|
183
263
|
noteAdapterPhysicalSend,
|
|
264
|
+
noteAdapterRecoveryWithheld,
|
|
184
265
|
sendBudgetExhausted,
|
|
185
266
|
get pendingHopPermit(): SingleUseDispatchPermit | undefined {
|
|
186
267
|
return pendingHopPermit;
|
|
@@ -50,7 +50,7 @@ export async function prepareResponsesSidecarAuth(
|
|
|
50
50
|
let openAiSidecar: ResolvedOpenAiForwardSidecar | undefined;
|
|
51
51
|
const visionDescribeTerminal = options.visionDescribeTerminal === true;
|
|
52
52
|
const routedCompaction = parsed._compactionRequest === true
|
|
53
|
-
&& !isCanonicalOpenAiForwardProvider(route.provider);
|
|
53
|
+
&& (!isCanonicalOpenAiForwardProvider(route.provider) || parsed._portableCompaction === true);
|
|
54
54
|
const needsOpenAiVision = !visionDescribeTerminal
|
|
55
55
|
&& shouldResolveOpenAiVisionSidecar(config, route.provider, route.modelId, parsed, route.providerName);
|
|
56
56
|
const needsOpenAiSearch = !routedCompaction && !transportState.adapter.runTurn
|
|
@@ -126,18 +126,26 @@ export async function prepareResponsesSidecarAuth(
|
|
|
126
126
|
});
|
|
127
127
|
const recordSidecarOutcome = openAiSidecar?.recordOutcome;
|
|
128
128
|
if (visionPlan) {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
129
|
+
try {
|
|
130
|
+
await describeImagesInPlace(
|
|
131
|
+
parsed,
|
|
132
|
+
visionPlan,
|
|
133
|
+
openAiSidecar?.headers ?? requestState.selectedForwardHeaders,
|
|
134
|
+
options.abortSignal,
|
|
135
|
+
recordSidecarOutcome,
|
|
136
|
+
translatorBudget,
|
|
137
|
+
);
|
|
138
|
+
} finally {
|
|
139
|
+
// Local validation can reject every image before the sidecar fetch records an outcome.
|
|
140
|
+
// Vision-only turns must hand that unused cooldown probe back; when a fetch did run the
|
|
141
|
+
// outcome already consumed it, so this generation-bound release is a safe no-op.
|
|
142
|
+
if (!needsOpenAiSearch) openAiSidecar?.releaseProbeLease?.();
|
|
143
|
+
}
|
|
137
144
|
} else if (requiresVisionPreprocessing(config, route.provider, route.modelId, route.providerName)) {
|
|
138
145
|
// Image capability is not positively proven but no sidecar plan is dispatchable: fail closed.
|
|
139
146
|
// Never forward raw image bytes to an unverified upstream.
|
|
140
147
|
stripImagesInPlace(parsed, translatorBudget);
|
|
148
|
+
if (!needsOpenAiSearch) openAiSidecar?.releaseProbeLease?.();
|
|
141
149
|
}
|
|
142
150
|
|
|
143
151
|
return {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { RequestSendObserver } from "../../lib/request-execution-budget";
|
|
3
3
|
import { sharedSpendLedger, type SpendReservationLedger } from "../../lib/spend-reservation-ledger";
|
|
4
|
-
import type
|
|
4
|
+
import { markLocalRequestLogRefusal, type RequestLogContext } from "../request-log";
|
|
5
|
+
import { recordWorkflowRefusalEvent, workflowDenialSummary } from "../../lib/workflow-budget";
|
|
5
6
|
|
|
6
7
|
/** The terminal usage a request reported, in the only two fields the ledger books. */
|
|
7
8
|
export interface TerminalSpendUsage {
|
|
@@ -42,7 +43,7 @@ export function createRequestSpendTracker(
|
|
|
42
43
|
logCtx: Pick<
|
|
43
44
|
RequestLogContext,
|
|
44
45
|
"provider" | "accountLogLabel" | "usageLogInputTokens" | "spendOutputCeilingTokens"
|
|
45
|
-
|
|
46
|
+
> & Partial<Pick<RequestLogContext, "localTerminalReason" | "terminalSource" | "errorCode">>,
|
|
46
47
|
rootId: string | undefined,
|
|
47
48
|
injected?: SpendReservationLedger,
|
|
48
49
|
): RequestSpendTracker {
|
|
@@ -70,7 +71,13 @@ export function createRequestSpendTracker(
|
|
|
70
71
|
for (let index = 0; index < live.length - 1; index += 1) ledger().markDispatched(live[index] as string);
|
|
71
72
|
};
|
|
72
73
|
return {
|
|
73
|
-
charge(): boolean {
|
|
74
|
+
charge(options?: { alreadySent?: boolean }): boolean {
|
|
75
|
+
// A send that has already left is RECORDED, never refused: the tokens are spent, and a
|
|
76
|
+
// booking the ledger drops is a booking the ceiling can never see. This is the reporting
|
|
77
|
+
// transports' path -- the passthrough ladder reports through `onSendsConsumed` after the
|
|
78
|
+
// fetch -- so without it a root ceiling on the canonical Codex path would sit one send
|
|
79
|
+
// short of its limit forever and refuse nothing.
|
|
80
|
+
const alreadySent = options?.alreadySent === true;
|
|
74
81
|
const sendId = randomUUID();
|
|
75
82
|
const decision = ledger().reserve({
|
|
76
83
|
sendId,
|
|
@@ -83,18 +90,40 @@ export function createRequestSpendTracker(
|
|
|
83
90
|
},
|
|
84
91
|
inputTokens: logCtx.usageLogInputTokens ?? 0,
|
|
85
92
|
outputCeilingTokens: logCtx.spendOutputCeilingTokens ?? 0,
|
|
93
|
+
...(alreadySent ? { alreadySent: true } : {}),
|
|
86
94
|
});
|
|
87
95
|
if (!decision.reserved) {
|
|
88
96
|
refusals += 1;
|
|
89
|
-
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// and
|
|
94
|
-
|
|
97
|
+
const denial = decision.denial;
|
|
98
|
+
// A ceiling refuses, and so does a ledger that cannot make the reservation durable
|
|
99
|
+
// under one. That second case is the whole reason this store is on disk: admitting a
|
|
100
|
+
// send whose record a restart would forget is how an exhausted budget comes back with
|
|
101
|
+
// a fresh allowance, and the ledger raises those two denials ONLY when a limit is
|
|
102
|
+
// configured -- so an install that configured nothing is still never refused here.
|
|
103
|
+
// Capacity and a duplicate send id stay permissive: they say the ledger cannot account
|
|
104
|
+
// for this send, which is a degradation to report, not an outage to cause.
|
|
105
|
+
if (denial.reason === "reserve-not-durable" || denial.reason === "journal-corrupt") {
|
|
106
|
+
return alreadySent;
|
|
107
|
+
}
|
|
108
|
+
if (denial.reason !== "spend-limit-exceeded") return true;
|
|
109
|
+
// This send is refused, and the dispatch path that asked will report an exhausted send
|
|
110
|
+
// budget -- from there, that is all it can see. The row is where an operator actually
|
|
111
|
+
// looks, so the ceiling is named on it here: a locally assigned code wins in
|
|
112
|
+
// addFinalRequestLog, so the request that CROSSED the ceiling reads as a spend refusal
|
|
113
|
+
// rather than as the ordinary budget exhaustion it would otherwise be indistinguishable
|
|
114
|
+
// from. The event ring gets the same pair so /api/workflow-budget agrees with the row.
|
|
115
|
+
const detail = { scope: denial.scope, limit: denial.limit, projected: denial.projected };
|
|
116
|
+
const summary = workflowDenialSummary("workflow-spend-exhausted", detail);
|
|
117
|
+
markLocalRequestLogRefusal(logCtx, summary.code);
|
|
118
|
+
logCtx.errorCode = summary.code;
|
|
119
|
+
recordWorkflowRefusalEvent(rootId, "workflow-spend-exhausted", Date.now(), detail);
|
|
120
|
+
return false;
|
|
95
121
|
}
|
|
96
122
|
live.push(sendId);
|
|
97
123
|
confirmOlderSends();
|
|
124
|
+
// It has already left, so the reservation cannot be handed back for free: from here only
|
|
125
|
+
// a settlement or unresolved spend is honest about it.
|
|
126
|
+
if (alreadySent) ledger().markDispatched(sendId);
|
|
98
127
|
return true;
|
|
99
128
|
},
|
|
100
129
|
refund(): void {
|
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
resolveCurrentProviderApiKeyTransport,
|
|
42
42
|
} from "../../providers/api-key-selection";
|
|
43
43
|
import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
|
|
44
|
-
import { providerFetch } from "./fetch-helpers";
|
|
44
|
+
import { providerFetch, sendWithConnectionPolicy } from "./fetch-helpers";
|
|
45
45
|
import type { ProviderFetchOptions } from "./fetch-helpers";
|
|
46
46
|
import { captureConfigGeneration } from "../../lib/state-store-sweeper";
|
|
47
47
|
import { recordAnthropicAccountQuotaFromHeaders, hasPassiveAccountQuota } from "../../providers/quota";
|
|
@@ -190,8 +190,9 @@ export async function prepareResponsesTransport(
|
|
|
190
190
|
* lines, and the divergence that produced was the bug: `apiKey` was swapped while the routing
|
|
191
191
|
* metadata paired with it stayed behind.
|
|
192
192
|
*
|
|
193
|
-
* Returns
|
|
194
|
-
*
|
|
193
|
+
* Returns the admitted snapshot, which can differ when a newer manual selection wins the
|
|
194
|
+
* proposal race. Returns null when the snapshot cannot be used safely, and the caller must then
|
|
195
|
+
* abandon the rotation rather than send a half-applied identity:
|
|
195
196
|
*
|
|
196
197
|
* - Copilot pins its bearer to an account-scoped regional origin, so transport is re-resolved
|
|
197
198
|
* with the new account's `apiBaseUrl` instead of inheriting the previous account's host. The
|
|
@@ -207,10 +208,10 @@ export async function prepareResponsesTransport(
|
|
|
207
208
|
const applyFailoverSnapshot = async (
|
|
208
209
|
snapshot: OAuthAccessSnapshot,
|
|
209
210
|
retryParsed: OcxParsedRequest = parsed,
|
|
210
|
-
): Promise<
|
|
211
|
-
if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return
|
|
211
|
+
): Promise<OAuthAccessSnapshot | null> => {
|
|
212
|
+
if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return null;
|
|
212
213
|
const committed = await commitResolvedOAuthSelection(snapshot);
|
|
213
|
-
if (!committed) return
|
|
214
|
+
if (!committed) return null;
|
|
214
215
|
snapshot = committed;
|
|
215
216
|
let rotatedProvider: OcxProviderConfig = { ...route.provider, apiKey: snapshot.accessToken };
|
|
216
217
|
if (route.providerName === "github-copilot") {
|
|
@@ -243,7 +244,7 @@ export async function prepareResponsesTransport(
|
|
|
243
244
|
}
|
|
244
245
|
sentOAuthSnapshot = snapshot;
|
|
245
246
|
replayOAuthCredentialSnapshot = { accountId: snapshot.accountId, generation: snapshot.generation };
|
|
246
|
-
return
|
|
247
|
+
return snapshot;
|
|
247
248
|
};
|
|
248
249
|
// Key sends may be rebuilt while queued. Keep metadata pending until the guarded
|
|
249
250
|
// physical dispatch binds it to the selection that actually reaches the upstream.
|
|
@@ -337,7 +338,7 @@ export async function prepareResponsesTransport(
|
|
|
337
338
|
route.provider = current;
|
|
338
339
|
}
|
|
339
340
|
adapter = activeAdapter = runTurnAdapter = resolveSelectionAdapter(
|
|
340
|
-
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire),
|
|
341
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy),
|
|
341
342
|
);
|
|
342
343
|
invalidateSameTargetRequest();
|
|
343
344
|
return adapter;
|
|
@@ -405,8 +406,10 @@ export async function prepareResponsesTransport(
|
|
|
405
406
|
&& sentHeaders?.get("authorization") === `Bearer ${snapshot.accessToken}`
|
|
406
407
|
&& !sentHeaders?.has("x-api-key");
|
|
407
408
|
// Reselection can choose a provider override instead of the supplied executor.
|
|
409
|
+
// Either way the send crosses the physical boundary, so the connection policy is
|
|
410
|
+
// applied around whichever implementation was just selected (#4992).
|
|
408
411
|
commitKeyAttemptSend();
|
|
409
|
-
const response = await fetchImpl
|
|
412
|
+
const response = await sendWithConnectionPolicy(fetchImpl, destination, { ...dispatchInit, redirect: "manual" });
|
|
410
413
|
if (!response.ok) await recordKeyAttemptFailure(logCtx, response, dispatchInit.signal ?? options.abortSignal);
|
|
411
414
|
// Observe each physical response before retries replace it. The binding belongs to
|
|
412
415
|
// this dispatch, so a manual switch cannot file A's headers against B. Header
|
|
@@ -487,7 +490,7 @@ export async function prepareResponsesTransport(
|
|
|
487
490
|
// measured as spent. A null answer means "use the active account", so every provider
|
|
488
491
|
// without quota evidence keeps the resolution it has today.
|
|
489
492
|
const preferredAccountId = isGenericFailoverProvider(route.providerName, route.provider)
|
|
490
|
-
? preferredInitialAccount(config, route.providerName)
|
|
493
|
+
? preferredInitialAccount(config, route.providerName, Date.now(), route.modelId)
|
|
491
494
|
: null;
|
|
492
495
|
// Resolved account-scoped, NOT through failoverAccountSnapshot: that helper marks a
|
|
493
496
|
// rotation site, and rotation sites must apply their credential through
|
|
@@ -550,7 +553,7 @@ export async function prepareResponsesTransport(
|
|
|
550
553
|
// Advance the pool cursor only now that this account is actually admitted. The
|
|
551
554
|
// helper returns immediately unless the kernel is on AND the strategy is
|
|
552
555
|
// round-robin, so quota and fill-first pools reach it without being touched.
|
|
553
|
-
noteGenericPoolSelection(config, route.providerName, resolved.accountId);
|
|
556
|
+
noteGenericPoolSelection(config, route.providerName, resolved.accountId, route.modelId);
|
|
554
557
|
}
|
|
555
558
|
// Anthropic is excluded from isGenericFailoverProvider -- its own pool owns affinity and
|
|
556
559
|
// a fail-closed local-cli credential rule -- so without this stamp its identity is
|
|
@@ -616,7 +619,7 @@ export async function prepareResponsesTransport(
|
|
|
616
619
|
? resolveCopilotApiBaseUrl(sentOAuthSnapshot?.apiBaseUrl)
|
|
617
620
|
: undefined,
|
|
618
621
|
);
|
|
619
|
-
let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire);
|
|
622
|
+
let adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire, route.staticPolicy);
|
|
620
623
|
const stripClaudeMainAuth = options.stripClaudeMainAuthForNoncanonicalForward === true
|
|
621
624
|
&& !isCanonicalOpenAiForwardProvider(adapterProvider)
|
|
622
625
|
&& ((adapterProvider.adapter === "openai-responses" && adapterProvider.authMode === "forward")
|