@bitkyc08/opencodex 2.59.0 → 2.61.0-preview.20260922
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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-E64Rzjap.js +50 -0
- package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
- package/gui/dist/assets/index-DpdfZWMK.js +86 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +8 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +20 -1
- package/src/adapters/base.ts +23 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +32 -4
- package/src/adapters/command-code.ts +52 -4
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +20 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +14 -3
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin/cloud-direct/chat.ts +3 -1
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +42 -5
- package/src/adapters/devin.ts +125 -36
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +34 -7
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google-http.ts +49 -10
- 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 +66 -11
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +23 -58
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +17 -3
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +13 -151
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/account-extended.ts +4 -4
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +46 -7
- package/src/cli/doctor.ts +28 -9
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +203 -59
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-api.ts +2 -2
- package/src/client/machine-listener.ts +4 -7
- package/src/client/runtime.ts +26 -2
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/account-store.ts +65 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/auth-api/pool-quota-probe.ts +30 -7
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/gather-capture.ts +21 -2
- package/src/codex/catalog/model-hints.ts +29 -28
- package/src/codex/catalog/parsing.ts +7 -0
- package/src/codex/catalog/provider-models.ts +43 -17
- package/src/codex/catalog/retained-sync.ts +24 -28
- package/src/codex/catalog/routed-gather.ts +19 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +31 -166
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +99 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +116 -54
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/observed-model-denials.ts +101 -8
- package/src/codex/prompt-text-probe.ts +9 -6
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +12 -42
- package/src/codex/shim-templates.ts +29 -3
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +22 -4
- package/src/combos/failover.ts +3 -0
- package/src/companion/settings.ts +132 -0
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/diagnostics.ts +22 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +52 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/proxy-env.ts +8 -2
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +31 -1
- package/src/config/schema/leaf-validators.ts +59 -0
- package/src/config.ts +3 -3
- package/src/generated/compatibility-version.json +604 -280
- 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/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +921 -0
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +134 -110
- package/src/lab/conformance/fixture-provider.ts +5 -0
- 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/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +63 -0
- package/src/lib/errors.ts +79 -0
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- 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-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +109 -16
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/socks5-fetch.ts +136 -26
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +218 -27
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +1 -0
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +27 -108
- package/src/oauth/login-cli.ts +80 -29
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/key-failover.ts +97 -19
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +179 -38
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/vendor-probes-key.ts +38 -23
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +47 -20
- package/src/providers/registry/entries-extended.ts +63 -4
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +56 -10
- 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/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/apply-patch-envelope.ts +0 -12
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +3 -0
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +108 -117
- 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/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +60 -4
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +61 -20
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +142 -39
- package/src/server/index/spend-ledger-lifecycle.ts +92 -0
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +34 -38
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/config-routes.ts +18 -2
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +20 -0
- package/src/server/management/shared.ts +28 -4
- 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/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +10 -9
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +138 -12
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +169 -2
- package/src/server/request-metrics.ts +298 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +11 -6
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +43 -10
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +8 -3
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +6 -12
- package/src/server/responses/core-opaque-recovery.ts +91 -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 +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +45 -15
- package/src/server/responses/passthrough-dispatch.ts +215 -44
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +109 -18
- package/src/server/responses/request-send-budget.ts +17 -1
- package/src/server/responses/request-sidecar-auth.ts +1 -1
- package/src/server/responses/request-transport.ts +26 -6
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses/run-turn-execution.ts +25 -3
- package/src/server/responses/sidecar-execution.ts +17 -2
- package/src/server/responses/ws-upstream.ts +14 -27
- package/src/server/responses-custom-tool-repair.ts +27 -54
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +35 -2
- package/src/server/search.ts +25 -1
- package/src/server/sse-payload-rewrite.ts +1 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +156 -4
- package/src/types/config.ts +48 -3
- package/src/types/provider.ts +91 -0
- package/src/types/request.ts +30 -2
- package/src/types/tools.ts +33 -0
- package/src/types.ts +4 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/alpha-search.ts +21 -1
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/gui/dist/assets/index-C5IebErG.js +0 -136
- package/gui/dist/assets/index-OESInAjC.css +0 -1
|
@@ -165,6 +165,28 @@ export interface RequestExecutionBudget extends TransientSendBudget {
|
|
|
165
165
|
readonly alternateTargetSends: number;
|
|
166
166
|
readonly targetTransitions: number;
|
|
167
167
|
readonly lastTargetKey: string | undefined;
|
|
168
|
+
/**
|
|
169
|
+
* Spend one operator-granted replacement for an AMBIGUOUS failure of this logical request,
|
|
170
|
+
* up to `limit`. False once the request has none left.
|
|
171
|
+
*
|
|
172
|
+
* It lives on the budget rather than beside the policy that grants it because it has to be
|
|
173
|
+
* shared exactly where the physical-send ledger is shared. A combo child derives its own
|
|
174
|
+
* budget from the parent's ledger, and two counters would let a request whose parent leg
|
|
175
|
+
* reset before the head and whose child leg reset after it replace an unknown-state send
|
|
176
|
+
* twice. It is NOT a send budget: an authorised replacement still has to fit inside
|
|
177
|
+
* `remainingBaseSends` like every other send.
|
|
178
|
+
*
|
|
179
|
+
* `limit` is the ceiling the ASKING leg is authorised to present, and the request keeps the
|
|
180
|
+
* smallest one any leg has presented. A leg reads it from `route.provider`, which credential
|
|
181
|
+
* rotation, OAuth refresh, transport resolution and a combo target all reassign mid-request,
|
|
182
|
+
* so a per-call ceiling meant the number of duplicate inferences a request could make
|
|
183
|
+
* depended on which row happened to ask last: a row granting one, then a row granting two,
|
|
184
|
+
* bought a second replacement of a turn that may already have run.
|
|
185
|
+
*
|
|
186
|
+
* Optional so a hand-written stub that satisfies the shape test keeps typechecking; a caller
|
|
187
|
+
* that cannot reach it has no operator override, which is the fail-closed answer.
|
|
188
|
+
*/
|
|
189
|
+
claimAmbiguousResend?(limit: number): boolean;
|
|
168
190
|
}
|
|
169
191
|
|
|
170
192
|
const RESERVE_FUNDED_CLASSES: ReadonlySet<SendClass> = new Set<SendClass>([
|
|
@@ -192,11 +214,49 @@ let logicalRequestSeq = 0;
|
|
|
192
214
|
interface SharedSendLedger {
|
|
193
215
|
spent: number;
|
|
194
216
|
pendingExternalSends: number;
|
|
217
|
+
/**
|
|
218
|
+
* Spend one of this logical request's replacements for an ambiguous failure. Beside `spent`
|
|
219
|
+
* for the same reason `pendingExternalSends` is: a derived scope that shared one without the
|
|
220
|
+
* other would hand the request a second grant.
|
|
221
|
+
*
|
|
222
|
+
* A function rather than the raw count, because the count is not the whole state. The
|
|
223
|
+
* ceiling belongs to the request too, and a bridged scope has no counter of its own to keep
|
|
224
|
+
* it in -- it has to ask whoever holds the request's grant.
|
|
225
|
+
*/
|
|
226
|
+
claimAmbiguousResend(limit: number): boolean;
|
|
195
227
|
readonly observer?: RequestSendObserver;
|
|
196
228
|
}
|
|
197
229
|
|
|
198
230
|
const sharedSendLedgers = new WeakMap<RequestExecutionBudget, SharedSendLedger>();
|
|
199
231
|
|
|
232
|
+
/**
|
|
233
|
+
* One logical request's replacement grant: how many it has spent, and the ceiling it is held
|
|
234
|
+
* to.
|
|
235
|
+
*
|
|
236
|
+
* The ceiling is the SMALLEST any leg has presented rather than whatever the current leg
|
|
237
|
+
* presents. Each leg reads its number from the provider row it is running against, and that
|
|
238
|
+
* row changes inside one request -- credential rotation, OAuth refresh, transport resolution
|
|
239
|
+
* and each combo target reassign it. Taking the asking leg's number let a request that had
|
|
240
|
+
* already spent the one replacement a strict row granted buy another as soon as a more
|
|
241
|
+
* permissive row asked, which is a second duplicate inference of one turn.
|
|
242
|
+
*/
|
|
243
|
+
function createAmbiguousResendGrant(): (limit: number) => boolean {
|
|
244
|
+
let claimed = 0;
|
|
245
|
+
let ceiling: number | undefined;
|
|
246
|
+
return (limit: number): boolean => {
|
|
247
|
+
const presented = Number.isFinite(limit) ? Math.trunc(limit) : 0;
|
|
248
|
+
// A zero or nonsense ceiling refuses on its own and leaves the request's alone. It is a
|
|
249
|
+
// caller that cannot state a grant, not an operator narrowing this request: a leg with no
|
|
250
|
+
// policy is refused before it ever claims, so binding the request to a malformed number
|
|
251
|
+
// would only let such a caller cancel a grant an opted-in row really made.
|
|
252
|
+
if (presented <= 0) return false;
|
|
253
|
+
ceiling = ceiling === undefined ? presented : Math.min(ceiling, presented);
|
|
254
|
+
if (claimed >= ceiling) return false;
|
|
255
|
+
claimed += 1;
|
|
256
|
+
return true;
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
200
260
|
function createRequestExecutionBudgetWithLedger(
|
|
201
261
|
policy: RequestExecutionBudgetPolicy,
|
|
202
262
|
logicalRequestId: string | undefined,
|
|
@@ -239,6 +299,9 @@ function createRequestExecutionBudgetWithLedger(
|
|
|
239
299
|
const capped = Number.isFinite(cap) ? Math.trunc(cap) : 0;
|
|
240
300
|
return Math.max(0, Math.min(capped, policy.baseSendAllowance - counter.spent));
|
|
241
301
|
},
|
|
302
|
+
claimAmbiguousResend(limit: number): boolean {
|
|
303
|
+
return counter.claimAmbiguousResend(limit);
|
|
304
|
+
},
|
|
242
305
|
reserveDispatch(intent: DispatchIntent): DispatchDecision {
|
|
243
306
|
if (intent.replaySafe === false) return { allowed: false, reason: "not-replay-safe" };
|
|
244
307
|
if (counter.spent >= policy.maxTotalModelSends) return { allowed: false, reason: "total-exhausted" };
|
|
@@ -336,6 +399,7 @@ export function createRequestExecutionBudget(
|
|
|
336
399
|
return createRequestExecutionBudgetWithLedger(policy, logicalRequestId, {
|
|
337
400
|
spent: 0,
|
|
338
401
|
pendingExternalSends: 0,
|
|
402
|
+
claimAmbiguousResend: createAmbiguousResendGrant(),
|
|
339
403
|
...(observer ? { observer } : {}),
|
|
340
404
|
});
|
|
341
405
|
}
|
|
@@ -375,6 +439,14 @@ function ledgerFor(parent: RequestExecutionBudget): SharedSendLedger {
|
|
|
375
439
|
set spent(next: number) { parent.used = next; },
|
|
376
440
|
get pendingExternalSends(): number { return pendingExternalSends; },
|
|
377
441
|
set pendingExternalSends(next: number) { pendingExternalSends = next; },
|
|
442
|
+
// Asked of the parent rather than counted here. A local counter is a SECOND grant: two
|
|
443
|
+
// scopes derived from one bridged parent, or one scope beside the parent it was derived
|
|
444
|
+
// from, each replaced an unknown-state send once. Pending bookings and the durable-spend
|
|
445
|
+
// observer genuinely cannot cross this boundary because they are private to the factory,
|
|
446
|
+
// but the grant can -- `claimAmbiguousResend` is public on the parent. A parent that does
|
|
447
|
+
// not implement it grants nothing, which is the fail-closed answer for a send whose
|
|
448
|
+
// upstream state is unknown.
|
|
449
|
+
claimAmbiguousResend: (limit: number): boolean => parent.claimAmbiguousResend?.(limit) === true,
|
|
378
450
|
};
|
|
379
451
|
}
|
|
380
452
|
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive how far a failed request got and why, from the closed facts the recorder already holds.
|
|
3
|
+
*
|
|
4
|
+
* #2366 asked for durable failure attribution and shipped its own `FailureSide` and seven-member
|
|
5
|
+
* `FailureStage` to carry it. Those are a second attribution vocabulary beside the one that
|
|
6
|
+
* landed in {@link ../lib/request-failure-model}, and two vocabularies for one question is the
|
|
7
|
+
* class of defect that blocked 2.60.0. This module is the same answer expressed in the landed
|
|
8
|
+
* vocabulary: no new stage names, no new cause names, and no new record store.
|
|
9
|
+
*
|
|
10
|
+
* Everything it reads is a CLOSED value the row already carries -- an HTTP status, a terminal
|
|
11
|
+
* status, a close reason, a transport phase, a recovery kind. It never reads `errorCode` or
|
|
12
|
+
* `upstreamError`, which are open strings assembled partly from upstream text: a classification
|
|
13
|
+
* keyed on those is a different answer per provider and per locale, and a grouping key built from
|
|
14
|
+
* them cannot promise it carries no content.
|
|
15
|
+
*
|
|
16
|
+
* MUST stay a leaf. Its only imports are types and the two tables it decides with, so nothing
|
|
17
|
+
* here can pull the usage or budget subsystems into a request path that lacked them.
|
|
18
|
+
*/
|
|
19
|
+
import type { AttemptRecoveryKind, RequestFailureCause, RequestFailureStage } from "../usage/telemetry-contract";
|
|
20
|
+
import { causeForRecoveryKind } from "./request-failure-model";
|
|
21
|
+
import { classifyRequestOutcome, type RequestOutcomeFacts } from "../usage/request-outcome";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* What the recorder knows about one finished exchange at the single seam every request passes.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately the same narrow set {@link RequestOutcomeFacts} reads, plus the four observation
|
|
27
|
+
* facts a stage needs. A field that could carry a provider name, a model, an account or upstream
|
|
28
|
+
* text is absent by construction rather than by review.
|
|
29
|
+
*/
|
|
30
|
+
export interface RequestFailureFacts extends RequestOutcomeFacts {
|
|
31
|
+
readonly transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse" | undefined;
|
|
32
|
+
/** Where the status and message came from: an origin response, or a tail this proxy wrote. */
|
|
33
|
+
readonly terminalSource?: "upstream" | "synthetic" | undefined;
|
|
34
|
+
/** True when the upstream stream died after its head was committed. */
|
|
35
|
+
readonly streamAborted?: boolean | undefined;
|
|
36
|
+
/** True once any output-bearing event reached the caller; `firstOutputMs` is the usual source. */
|
|
37
|
+
readonly outputObserved?: boolean | undefined;
|
|
38
|
+
/** True once a tool call or other externally visible effect was relayed to the caller. */
|
|
39
|
+
readonly sideEffectObserved?: boolean | undefined;
|
|
40
|
+
/** True when this proxy answered the turn itself and issued no upstream request. */
|
|
41
|
+
readonly locallyAnswered?: boolean | undefined;
|
|
42
|
+
/**
|
|
43
|
+
* Recovery kinds recorded on the attempt that ended the request, in the order they happened.
|
|
44
|
+
* Only the LAST one is ever consulted, and only under the narrow rule below.
|
|
45
|
+
*/
|
|
46
|
+
readonly recoveryKinds?: readonly AttemptRecoveryKind[] | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* A cause the CALLER proved, which the status alone cannot reconstruct.
|
|
49
|
+
*
|
|
50
|
+
* Set only by a finalizer that is sealing an attempt it knows the rejection for -- the
|
|
51
|
+
* key-account rotation seals the previous attempt because a named recovery rejected it, and
|
|
52
|
+
* that argument is direct evidence rather than an inference from history. It outranks the
|
|
53
|
+
* status table and is outranked by a client cancel, which is a fact about the caller and not
|
|
54
|
+
* about the origin.
|
|
55
|
+
*/
|
|
56
|
+
readonly causeHint?: RequestFailureCause | undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* How far the caller's view of the exchange got.
|
|
61
|
+
*
|
|
62
|
+
* Total by construction and ordered downward from the most committed observation, so a fact that
|
|
63
|
+
* proves a later stage wins over one that only proves an earlier one. The boundary between
|
|
64
|
+
* `headers-only` and `protocol-prelude` is the one genuinely debatable step -- a non-streaming
|
|
65
|
+
* 4xx error body is a body, but not a protocol body event -- and it is safe to argue about
|
|
66
|
+
* because both stages carry the same `nothing-observed` commitment, so no resend decision turns
|
|
67
|
+
* on which side of it a row lands.
|
|
68
|
+
*/
|
|
69
|
+
export function deriveRequestFailureStage(facts: RequestFailureFacts): RequestFailureStage {
|
|
70
|
+
if (facts.terminalStatus === "completed" && facts.outputObserved === true) return "terminal";
|
|
71
|
+
if (facts.sideEffectObserved === true) return "side-effect";
|
|
72
|
+
if (facts.outputObserved === true) return "semantic-output";
|
|
73
|
+
if (facts.terminalStatus !== undefined
|
|
74
|
+
|| facts.closeReason === "terminal"
|
|
75
|
+
|| facts.transportPhase === "mid_stream"
|
|
76
|
+
|| facts.transportPhase === "terminal_sse") return "protocol-prelude";
|
|
77
|
+
if (facts.status >= 100) return "headers-only";
|
|
78
|
+
return "pre-header";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The two recovery kinds that name a 4xx the status alone cannot tell apart.
|
|
83
|
+
*
|
|
84
|
+
* `causeForRecoveryKind` answers why a recovery was ATTEMPTED, which is usually a different
|
|
85
|
+
* question from why the request finally failed -- one that recovered from a 401 and then died on
|
|
86
|
+
* a 500 failed for the 500. So the rule here is deliberately narrow on three axes at once: only
|
|
87
|
+
* these two kinds, only when they are the LAST recovery this attempt recorded, and only when the
|
|
88
|
+
* attempt then ended on the very status that recovery was made for. Everything else falls
|
|
89
|
+
* through to the status table.
|
|
90
|
+
*
|
|
91
|
+
* The residual: a ciphertext recovery that SUCCEEDED, followed by an unrelated 400 on the same
|
|
92
|
+
* attempt, still reads as `ciphertext-refusal`, because a successful recovery does not currently
|
|
93
|
+
* clear its own evidence. Closing that belongs in the recovery path rather than here -- it is
|
|
94
|
+
* recorded in this lane's devlog as the next step -- and the rule is kept meanwhile because
|
|
95
|
+
* without it a rejected ciphertext, a rejected reasoning parameter and a rejected payload are one
|
|
96
|
+
* undifferentiated answer, which is three different remedies collapsed into one.
|
|
97
|
+
*/
|
|
98
|
+
const STATUS_CONFIRMED_RECOVERY_KINDS: Readonly<Partial<Record<AttemptRecoveryKind, number>>> = Object.freeze({
|
|
99
|
+
"opaque-blob-rejection": 400,
|
|
100
|
+
"reasoning-effort-downgrade": 400,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
function refinedFourHundredCause(
|
|
104
|
+
facts: RequestFailureFacts,
|
|
105
|
+
): RequestFailureCause | undefined {
|
|
106
|
+
const last = facts.recoveryKinds?.at(-1);
|
|
107
|
+
if (last === undefined) return undefined;
|
|
108
|
+
const confirmedStatus = STATUS_CONFIRMED_RECOVERY_KINDS[last];
|
|
109
|
+
return confirmedStatus === facts.status ? causeForRecoveryKind(last) : undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Why the request failed.
|
|
114
|
+
*
|
|
115
|
+
* Returns `undefined` for an outcome that is not a failure. An incomplete turn is a real
|
|
116
|
+
* shortfall and gets a stage, but this dictionary answers "why did it fail", and a turn cut short
|
|
117
|
+
* by `max_output_tokens` did not fail for any of these reasons; inventing one would put a
|
|
118
|
+
* fabricated cause into a metric label and a grouping key.
|
|
119
|
+
*
|
|
120
|
+
* The status is the primary evidence because it is the one fact every transport produces. Two
|
|
121
|
+
* refinements sit above it, both from closed values: a client cancel is known from the close
|
|
122
|
+
* reason before any status is consulted, and a 400 that a recovery kind identified as a rejected
|
|
123
|
+
* ciphertext or a rejected reasoning parameter is not the same answer as a rejected payload.
|
|
124
|
+
*/
|
|
125
|
+
export function deriveRequestFailureCause(facts: RequestFailureFacts): RequestFailureCause | undefined {
|
|
126
|
+
const outcome = classifyRequestOutcome(facts);
|
|
127
|
+
if (outcome === "completed" || outcome === "incomplete") return undefined;
|
|
128
|
+
if (outcome === "aborted") return "client-cancelled";
|
|
129
|
+
if (facts.locallyAnswered === true) return "local-refusal";
|
|
130
|
+
// A cause the finalizer proved outranks anything reconstructed from the status.
|
|
131
|
+
if (facts.causeHint !== undefined) return facts.causeHint;
|
|
132
|
+
|
|
133
|
+
const status = facts.status;
|
|
134
|
+
// Transport evidence outranks the numeric status, because a stream that died mid-flight is
|
|
135
|
+
// reported as a SYNTHETIC 502 -- a tail this proxy wrote, not an answer the origin gave. Read
|
|
136
|
+
// in status order that 502 becomes `upstream-fault`, which claims the origin answered when it
|
|
137
|
+
// did not. Both causes refuse an automatic resend, so this is an accuracy fix rather than a
|
|
138
|
+
// safety one, but a label an operator cannot trust is a label they stop reading.
|
|
139
|
+
if (facts.streamAborted === true
|
|
140
|
+
|| (facts.terminalSource === "synthetic"
|
|
141
|
+
&& (facts.transportPhase === "mid_stream" || facts.transportPhase === "terminal_sse"))) {
|
|
142
|
+
return "transport-ambiguous";
|
|
143
|
+
}
|
|
144
|
+
// A 2xx head that carried a failed terminal: the origin ran the turn and said it failed. With
|
|
145
|
+
// no output relayed the useful distinction is that nothing usable came back at all.
|
|
146
|
+
if (status >= 100 && status < 400) {
|
|
147
|
+
return facts.outputObserved === true ? "upstream-fault" : "empty-output";
|
|
148
|
+
}
|
|
149
|
+
if (status === 401) return "credential-rejected";
|
|
150
|
+
if (status === 403) return "credential-rejected";
|
|
151
|
+
// Payment required. Waiting out a retry window does not help; the account has to change.
|
|
152
|
+
if (status === 402) return "quota-exhausted";
|
|
153
|
+
if (status === 413) return "payload-too-large";
|
|
154
|
+
if (status === 429) return "rate-limit";
|
|
155
|
+
if (status === 451) return "policy-refusal";
|
|
156
|
+
if (status === 503) return "upstream-declined";
|
|
157
|
+
if (status >= 500) return "upstream-fault";
|
|
158
|
+
if (status >= 400) return refinedFourHundredCause(facts) ?? "payload-rejected";
|
|
159
|
+
// No response head at all, and nothing proved the bytes never left. `transport-ambiguous` is
|
|
160
|
+
// the honest answer for an unknown execution state, and it is the safe one: it refuses an
|
|
161
|
+
// automatic resend where `transport-unsent` would permit one. `transport-unsent` is reachable
|
|
162
|
+
// only through `causeHint`, from a site that classified a pre-connect failure and can prove it.
|
|
163
|
+
return "transport-ambiguous";
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export interface RequestFailureAttribution {
|
|
167
|
+
stage: RequestFailureStage;
|
|
168
|
+
cause?: RequestFailureCause;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The attribution to persist, or `undefined` when the request completed.
|
|
173
|
+
*
|
|
174
|
+
* A completed request has no failure to attribute, and recording a stage for one would put a
|
|
175
|
+
* `terminal` row into every grouping that exists to find failures.
|
|
176
|
+
*/
|
|
177
|
+
export function deriveRequestFailureAttribution(
|
|
178
|
+
facts: RequestFailureFacts,
|
|
179
|
+
): RequestFailureAttribution | undefined {
|
|
180
|
+
if (classifyRequestOutcome(facts) === "completed") return undefined;
|
|
181
|
+
const cause = deriveRequestFailureCause(facts);
|
|
182
|
+
return { stage: deriveRequestFailureStage(facts), ...(cause ? { cause } : {}) };
|
|
183
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One vocabulary for how far a failed request got, why it failed, and whether this proxy may
|
|
3
|
+
* send it again (roadmap items 7 and 14).
|
|
4
|
+
*
|
|
5
|
+
* These two items are one module on purpose. Item 7 wants a resend decision per failure stage;
|
|
6
|
+
* item 14 wants one cause dictionary spanning logical request, attempt, physical send and
|
|
7
|
+
* terminal. Defined apart they typecheck on each branch and contradict each other in the merge,
|
|
8
|
+
* which is the class that blocked 2.60.0.
|
|
9
|
+
*
|
|
10
|
+
* What lives here is the vocabulary and the decision derived from it. What does NOT live here is
|
|
11
|
+
* a second record store: the durable shapes stay `PersistedUsageAttempt` and
|
|
12
|
+
* `PersistedUsageEntry` in src/usage/log.ts, and every projection below reads those structurally
|
|
13
|
+
* rather than growing a parallel history.
|
|
14
|
+
*
|
|
15
|
+
* MUST stay a leaf. Its one runtime import is `src/usage/telemetry-contract.ts`, which has no
|
|
16
|
+
* imports at all; everything else it names is a type, erased at runtime. So nothing here can
|
|
17
|
+
* pull the usage or budget subsystems into a request path that did not already have them.
|
|
18
|
+
*/
|
|
19
|
+
import type { SendClass } from "./request-execution-budget";
|
|
20
|
+
import {
|
|
21
|
+
REQUEST_FAILURE_STAGES,
|
|
22
|
+
type AttemptRecoveryKind,
|
|
23
|
+
type RequestFailureCause,
|
|
24
|
+
type RequestFailureStage,
|
|
25
|
+
type ResendPermission,
|
|
26
|
+
} from "../usage/telemetry-contract";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The vocabulary this module decides over is DECLARED in `src/usage/telemetry-contract.ts` and
|
|
30
|
+
* re-exported here, so every importer of this module keeps its path while the dashboard can
|
|
31
|
+
* reach the same rosters without pulling this file's import graph into the browser project.
|
|
32
|
+
*
|
|
33
|
+
* What stays here is the decision: the per-stage commitment, the per-cause evidence and
|
|
34
|
+
* disposition, and the resend permission derived from them.
|
|
35
|
+
*
|
|
36
|
+
* A stage is how far the OBSERVABLE progression got, not which events happened to arrive. A turn
|
|
37
|
+
* that settled carrying no output -- an empty completion, a 4xx error body -- did not reach
|
|
38
|
+
* `terminal`; it stalled below `semantic-output`, because the caller saw no answer. `terminal`
|
|
39
|
+
* means the answer was delivered, which is why it is both last and refused.
|
|
40
|
+
*/
|
|
41
|
+
export {
|
|
42
|
+
REQUEST_FAILURE_CAUSES,
|
|
43
|
+
REQUEST_FAILURE_STAGES,
|
|
44
|
+
RESEND_PERMISSIONS,
|
|
45
|
+
} from "../usage/telemetry-contract";
|
|
46
|
+
export type {
|
|
47
|
+
RequestFailureCause,
|
|
48
|
+
RequestFailureStage,
|
|
49
|
+
ResendPermission,
|
|
50
|
+
} from "../usage/telemetry-contract";
|
|
51
|
+
|
|
52
|
+
/** Position in {@link REQUEST_FAILURE_STAGES}. Derived, so the order is stated exactly once. */
|
|
53
|
+
export function stageRank(stage: RequestFailureStage): number {
|
|
54
|
+
return REQUEST_FAILURE_STAGES.indexOf(stage);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What the caller has irreversibly observed at a stage.
|
|
59
|
+
*
|
|
60
|
+
* Named separately from the rank so a reader can see WHY a stage refuses rather than inferring it
|
|
61
|
+
* from a position, and so the three committed stages stay distinguishable in a record.
|
|
62
|
+
*/
|
|
63
|
+
export type StageCommitment = "nothing-observed" | "output-observed" | "effect-observed" | "answer-delivered";
|
|
64
|
+
|
|
65
|
+
const STAGE_COMMITMENT = {
|
|
66
|
+
"pre-header": "nothing-observed",
|
|
67
|
+
"headers-only": "nothing-observed",
|
|
68
|
+
"protocol-prelude": "nothing-observed",
|
|
69
|
+
"semantic-output": "output-observed",
|
|
70
|
+
"side-effect": "effect-observed",
|
|
71
|
+
"terminal": "answer-delivered",
|
|
72
|
+
} as const satisfies Record<RequestFailureStage, StageCommitment>;
|
|
73
|
+
|
|
74
|
+
export function stageCommitment(stage: RequestFailureStage): StageCommitment {
|
|
75
|
+
return STAGE_COMMITMENT[stage];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What the cause proves about whether the origin ran the turn.
|
|
80
|
+
*
|
|
81
|
+
* This is the safety axis. `unknown` is the RFC 9110 9.2.2 case and is never upgraded by having
|
|
82
|
+
* budget left: a request whose upstream execution state is unknown is not replayable merely
|
|
83
|
+
* because a counter allows another send.
|
|
84
|
+
*/
|
|
85
|
+
export type UpstreamProcessingEvidence = "not-processed" | "declined" | "processed" | "unknown";
|
|
86
|
+
|
|
87
|
+
const CAUSE_EVIDENCE = {
|
|
88
|
+
"transport-unsent": "not-processed",
|
|
89
|
+
"transport-ambiguous": "unknown",
|
|
90
|
+
"upstream-declined": "declined",
|
|
91
|
+
"rate-limit": "declined",
|
|
92
|
+
"quota-exhausted": "declined",
|
|
93
|
+
"credential-rejected": "declined",
|
|
94
|
+
"policy-refusal": "processed",
|
|
95
|
+
"parameter-rejected": "declined",
|
|
96
|
+
"ciphertext-refusal": "declined",
|
|
97
|
+
"payload-too-large": "declined",
|
|
98
|
+
"payload-rejected": "declined",
|
|
99
|
+
"upstream-fault": "unknown",
|
|
100
|
+
"empty-output": "processed",
|
|
101
|
+
"client-cancelled": "unknown",
|
|
102
|
+
"local-refusal": "not-processed",
|
|
103
|
+
} as const satisfies Record<RequestFailureCause, UpstreamProcessingEvidence>;
|
|
104
|
+
|
|
105
|
+
export function causeEvidence(cause: RequestFailureCause): UpstreamProcessingEvidence {
|
|
106
|
+
return CAUSE_EVIDENCE[cause];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What a resend would have to change to have any chance.
|
|
111
|
+
*
|
|
112
|
+
* The usefulness axis, orthogonal to safety. A policy refusal is perfectly safe to repeat and
|
|
113
|
+
* completely pointless; an ambiguous reset is the reverse.
|
|
114
|
+
*/
|
|
115
|
+
export type ResendDisposition = "resend-may-help" | "resend-after-repair" | "resend-is-futile";
|
|
116
|
+
|
|
117
|
+
const CAUSE_DISPOSITION = {
|
|
118
|
+
"transport-unsent": "resend-may-help",
|
|
119
|
+
"transport-ambiguous": "resend-may-help",
|
|
120
|
+
"upstream-declined": "resend-may-help",
|
|
121
|
+
"rate-limit": "resend-may-help",
|
|
122
|
+
"quota-exhausted": "resend-is-futile",
|
|
123
|
+
"credential-rejected": "resend-after-repair",
|
|
124
|
+
"policy-refusal": "resend-is-futile",
|
|
125
|
+
"parameter-rejected": "resend-after-repair",
|
|
126
|
+
"ciphertext-refusal": "resend-after-repair",
|
|
127
|
+
"payload-too-large": "resend-after-repair",
|
|
128
|
+
"payload-rejected": "resend-is-futile",
|
|
129
|
+
"upstream-fault": "resend-may-help",
|
|
130
|
+
"empty-output": "resend-may-help",
|
|
131
|
+
"client-cancelled": "resend-is-futile",
|
|
132
|
+
"local-refusal": "resend-is-futile",
|
|
133
|
+
} as const satisfies Record<RequestFailureCause, ResendDisposition>;
|
|
134
|
+
|
|
135
|
+
export function causeDisposition(cause: RequestFailureCause): ResendDisposition {
|
|
136
|
+
return CAUSE_DISPOSITION[cause];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Whether this proxy may send the request again, from the stage it failed at and the cause.
|
|
141
|
+
*
|
|
142
|
+
* Derived from the two per-cause facts above and the per-stage commitment, rather than written
|
|
143
|
+
* out as a stage-by-cause matrix. A matrix of that size is a restatement: it would have to be
|
|
144
|
+
* re-derived by hand every time a member is added, and the cell nobody revisited is exactly how
|
|
145
|
+
* two correct branches merge into a wrong table.
|
|
146
|
+
*
|
|
147
|
+
* `refused-ambiguous` forbids an AUTOMATIC resend. It does not forbid a narrowly scoped,
|
|
148
|
+
* explicitly opted-in recovery that a maintainer reasoned about and bounded -- the reset replay
|
|
149
|
+
* behind a default-off provider flag, the single-shot empty-completion rebuild. Those are
|
|
150
|
+
* separate recorded decisions with their own acceptance, which is precisely what distinguishes
|
|
151
|
+
* them from a retry loop that fires because a counter had room.
|
|
152
|
+
*/
|
|
153
|
+
export function resendPermission(
|
|
154
|
+
stage: RequestFailureStage,
|
|
155
|
+
cause: RequestFailureCause,
|
|
156
|
+
): ResendPermission {
|
|
157
|
+
// Any stage at which the caller observed something refuses, whatever the cause says. Testing
|
|
158
|
+
// the commitment rather than listing the committed stages is what keeps a stage added later
|
|
159
|
+
// from defaulting into permission.
|
|
160
|
+
if (STAGE_COMMITMENT[stage] !== "nothing-observed") return "refused-committed";
|
|
161
|
+
if (CAUSE_DISPOSITION[cause] === "resend-is-futile") return "refused-futile";
|
|
162
|
+
const evidence = CAUSE_EVIDENCE[cause];
|
|
163
|
+
if (evidence === "unknown" || evidence === "processed") return "refused-ambiguous";
|
|
164
|
+
return CAUSE_DISPOSITION[cause] === "resend-after-repair" ? "permitted-after-repair" : "permitted";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True for the two permissions that allow a further send. */
|
|
168
|
+
export function permitsResend(permission: ResendPermission): boolean {
|
|
169
|
+
return permission === "permitted" || permission === "permitted-after-repair";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Which request-wide send budget class a resend for this cause draws on, or null when no resend
|
|
174
|
+
* of any kind makes sense.
|
|
175
|
+
*
|
|
176
|
+
* Funding is keyed on the DISPOSITION, not on the permission. A cause the table refuses to resend
|
|
177
|
+
* automatically may still be resent by a narrowly scoped recovery a maintainer opted into, and
|
|
178
|
+
* that send has to be bought from the same budget every other send comes from -- the opt-in reset
|
|
179
|
+
* replay and the bounded empty-completion rebuild both draw on the transient allowance. Keying on
|
|
180
|
+
* permission instead would leave exactly those paths unfunded, which is how a per-layer counter
|
|
181
|
+
* reappears.
|
|
182
|
+
*
|
|
183
|
+
* Only a futile cause is null. `quota-exhausted` is null rather than `account-failover` because
|
|
184
|
+
* moving accounts is a route decision this table does not make.
|
|
185
|
+
*/
|
|
186
|
+
const CAUSE_SEND_CLASS = {
|
|
187
|
+
"transport-unsent": "transient",
|
|
188
|
+
"transport-ambiguous": "transient",
|
|
189
|
+
"upstream-declined": "transient",
|
|
190
|
+
"rate-limit": "transient",
|
|
191
|
+
"quota-exhausted": null,
|
|
192
|
+
"credential-rejected": "auth-recovery",
|
|
193
|
+
"policy-refusal": null,
|
|
194
|
+
"parameter-rejected": "repair",
|
|
195
|
+
"ciphertext-refusal": "repair",
|
|
196
|
+
"payload-too-large": "repair",
|
|
197
|
+
"payload-rejected": null,
|
|
198
|
+
"upstream-fault": "transient",
|
|
199
|
+
"empty-output": "transient",
|
|
200
|
+
"client-cancelled": null,
|
|
201
|
+
"local-refusal": null,
|
|
202
|
+
} as const satisfies Record<RequestFailureCause, SendClass | null>;
|
|
203
|
+
|
|
204
|
+
export function resendSendClass(cause: RequestFailureCause): SendClass | null {
|
|
205
|
+
return CAUSE_SEND_CLASS[cause];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The cause behind each recovery this proxy already records.
|
|
210
|
+
*
|
|
211
|
+
* Total over `AttemptRecoveryKind` by construction, so a new recovery kind is a typecheck
|
|
212
|
+
* failure here rather than a row that quietly classifies as "other" in three projections.
|
|
213
|
+
*/
|
|
214
|
+
const RECOVERY_KIND_CAUSE = {
|
|
215
|
+
// The retried status set mixes 503, which declined, with 500, which may already have run the
|
|
216
|
+
// turn. One kind cannot say both, so it says the weaker thing.
|
|
217
|
+
"transient-5xx": "upstream-fault",
|
|
218
|
+
"connection-reset": "transport-ambiguous",
|
|
219
|
+
"oauth-401": "credential-rejected",
|
|
220
|
+
"key-401": "credential-rejected",
|
|
221
|
+
"key-429": "rate-limit",
|
|
222
|
+
"rate-limit-429": "rate-limit",
|
|
223
|
+
"anthropic-oauth-429": "rate-limit",
|
|
224
|
+
"oauth-account-429": "rate-limit",
|
|
225
|
+
"image-413": "payload-too-large",
|
|
226
|
+
// The gateway rejects a body it accepts seconds later and the replay is byte-identical, so
|
|
227
|
+
// nothing about the payload was wrong; the origin declined to take it at that moment.
|
|
228
|
+
"console-go-upload-retry": "upstream-declined",
|
|
229
|
+
"opaque-blob-rejection": "ciphertext-refusal",
|
|
230
|
+
"empty-completion": "empty-output",
|
|
231
|
+
"reasoning-effort-downgrade": "parameter-rejected",
|
|
232
|
+
} as const satisfies Record<AttemptRecoveryKind, RequestFailureCause>;
|
|
233
|
+
|
|
234
|
+
export function causeForRecoveryKind(kind: AttemptRecoveryKind): RequestFailureCause {
|
|
235
|
+
return RECOVERY_KIND_CAUSE[kind];
|
|
236
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether one leg of a logical request may send it again, asked in the #5266 vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Two pull requests arrived at this question from opposite sides of the response head. #4942
|
|
5
|
+
* asked it for a connection that died before any head; #4989 asked it for an SSE body that
|
|
6
|
+
* died after the head while carrying only control events. Both are the same row of the stage
|
|
7
|
+
* table: a stage the caller observed nothing at, with a cause that cannot prove the origin did
|
|
8
|
+
* not run the turn. `resendPermission` answers `refused-ambiguous` for both, and
|
|
9
|
+
* request-failure-model.ts already names the only thing that may override that answer -- a
|
|
10
|
+
* narrowly scoped recovery a maintainer opted into and bounded.
|
|
11
|
+
*
|
|
12
|
+
* One override, not two. The reason this module exists rather than a boolean in each caller is
|
|
13
|
+
* that a request which resets before the head and again after it would otherwise buy a
|
|
14
|
+
* replacement send on each side, and the second one is exactly the duplicated inference the
|
|
15
|
+
* refusal exists to prevent. The allowance is claimed HERE, at the moment of authorisation, so
|
|
16
|
+
* a caller cannot ask without paying.
|
|
17
|
+
*
|
|
18
|
+
* MUST stay a leaf. It imports the vocabulary as values and everything else as types, so it
|
|
19
|
+
* reaches no request path that did not already have it.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
causeForRecoveryKind,
|
|
23
|
+
permitsResend,
|
|
24
|
+
resendPermission,
|
|
25
|
+
resendSendClass,
|
|
26
|
+
type RequestFailureCause,
|
|
27
|
+
type RequestFailureStage,
|
|
28
|
+
type ResendPermission,
|
|
29
|
+
} from "./request-failure-model";
|
|
30
|
+
import type { SendClass } from "./request-execution-budget";
|
|
31
|
+
import type { AttemptRecoveryKind } from "../usage/telemetry-contract";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Why an authorisation was refused.
|
|
35
|
+
*
|
|
36
|
+
* The three ambiguous members are separate because they need different operator responses: no
|
|
37
|
+
* policy is a configuration choice, a request the proxy cannot judge is a property of the turn,
|
|
38
|
+
* and a spent allowance means the replacement already went somewhere else in this request.
|
|
39
|
+
*/
|
|
40
|
+
export const RESEND_REFUSALS = Object.freeze([
|
|
41
|
+
/** The caller already observed output, an effect, or the delivered answer. */
|
|
42
|
+
"committed",
|
|
43
|
+
/** Identical bytes would get the identical answer. */
|
|
44
|
+
"futile",
|
|
45
|
+
/** The origin's execution state is unknown and no operator policy overrides that. */
|
|
46
|
+
"ambiguous-no-policy",
|
|
47
|
+
/** An operator policy exists, but this request's second send could do more than re-infer. */
|
|
48
|
+
"ambiguous-request-not-replayable",
|
|
49
|
+
/** The operator policy exists and its replacement was already spent by this request. */
|
|
50
|
+
"ambiguous-allowance-spent",
|
|
51
|
+
] as const);
|
|
52
|
+
|
|
53
|
+
export type ResendRefusal = typeof RESEND_REFUSALS[number];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The operator-granted replacement for ONE logical request.
|
|
57
|
+
*
|
|
58
|
+
* `claim` is the single counter both stages draw on. It is a method rather than a number
|
|
59
|
+
* because the holder is the request's send ledger, which a combo child shares with its parent;
|
|
60
|
+
* a number passed down per leg is what let each leg hold its own.
|
|
61
|
+
*/
|
|
62
|
+
export interface AmbiguousResendAllowance {
|
|
63
|
+
/** True when a second send of this request's body can only repeat the inference. */
|
|
64
|
+
readonly selfContained: boolean;
|
|
65
|
+
/** Spend one replacement. False once the request has none left. */
|
|
66
|
+
claim(): boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ResendDecisionBase {
|
|
70
|
+
readonly stage: RequestFailureStage;
|
|
71
|
+
readonly cause: RequestFailureCause;
|
|
72
|
+
readonly permission: ResendPermission;
|
|
73
|
+
/**
|
|
74
|
+
* The recovery this send will be recorded as, when the caller asked in those terms. Carried
|
|
75
|
+
* back rather than re-chosen at the call site: the cause was derived from it, so recording a
|
|
76
|
+
* different kind would describe the send by a reason the gate never evaluated.
|
|
77
|
+
*/
|
|
78
|
+
readonly recoveryKind?: AttemptRecoveryKind;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type ResendDecision =
|
|
82
|
+
| ResendDecisionBase & {
|
|
83
|
+
readonly allowed: true;
|
|
84
|
+
/** Which request-wide send class funds it, or null when the cause funds no resend. */
|
|
85
|
+
readonly sendClass: SendClass | null;
|
|
86
|
+
/** True when the table refused and an operator allowance was spent to proceed. */
|
|
87
|
+
readonly spentOperatorAllowance: boolean;
|
|
88
|
+
}
|
|
89
|
+
| ResendDecisionBase & { readonly allowed: false; readonly refusal: ResendRefusal };
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Decide whether this proxy may send the request again after a failure at `stage` caused by
|
|
93
|
+
* `cause`, spending `allowance` when the table refuses only because the upstream state is
|
|
94
|
+
* unknown.
|
|
95
|
+
*
|
|
96
|
+
* The allowance is touched on exactly one path: a decision the table would otherwise refuse as
|
|
97
|
+
* ambiguous, for a request whose body the caller has judged replayable. A committed or futile
|
|
98
|
+
* failure never reaches it, so a turn that already produced output cannot quietly drain the
|
|
99
|
+
* replacement a later ambiguous reset would have been entitled to.
|
|
100
|
+
*/
|
|
101
|
+
export function authorizeResend(
|
|
102
|
+
stage: RequestFailureStage,
|
|
103
|
+
cause: RequestFailureCause,
|
|
104
|
+
allowance?: AmbiguousResendAllowance,
|
|
105
|
+
recoveryKind?: AttemptRecoveryKind,
|
|
106
|
+
): ResendDecision {
|
|
107
|
+
const permission = resendPermission(stage, cause);
|
|
108
|
+
const base = { stage, cause, permission, ...(recoveryKind ? { recoveryKind } : {}) };
|
|
109
|
+
if (permitsResend(permission)) {
|
|
110
|
+
return { ...base, allowed: true, sendClass: resendSendClass(cause), spentOperatorAllowance: false };
|
|
111
|
+
}
|
|
112
|
+
if (permission === "refused-committed") return { ...base, allowed: false, refusal: "committed" };
|
|
113
|
+
if (permission === "refused-futile") return { ...base, allowed: false, refusal: "futile" };
|
|
114
|
+
if (!allowance) return { ...base, allowed: false, refusal: "ambiguous-no-policy" };
|
|
115
|
+
if (!allowance.selfContained) {
|
|
116
|
+
return { ...base, allowed: false, refusal: "ambiguous-request-not-replayable" };
|
|
117
|
+
}
|
|
118
|
+
// Claimed last, and only here. Asking earlier would spend the request's one replacement on a
|
|
119
|
+
// question whose answer was already no.
|
|
120
|
+
if (!allowance.claim()) return { ...base, allowed: false, refusal: "ambiguous-allowance-spent" };
|
|
121
|
+
return { ...base, allowed: true, sendClass: resendSendClass(cause), spentOperatorAllowance: true };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The same decision, asked in terms of the recovery this proxy will RECORD for the send.
|
|
126
|
+
*
|
|
127
|
+
* Deriving the cause from the recorded kind is what keeps the log honest: the reason an
|
|
128
|
+
* operator reads beside a send count is the reason the gate weighed, because it is the same
|
|
129
|
+
* value. A call site that recorded one kind and reasoned about another is how a send count
|
|
130
|
+
* stops meaning anything.
|
|
131
|
+
*/
|
|
132
|
+
export function authorizeResendForRecovery(
|
|
133
|
+
stage: RequestFailureStage,
|
|
134
|
+
kind: AttemptRecoveryKind,
|
|
135
|
+
allowance?: AmbiguousResendAllowance,
|
|
136
|
+
): ResendDecision {
|
|
137
|
+
return authorizeResend(stage, causeForRecoveryKind(kind), allowance, kind);
|
|
138
|
+
}
|