@bitkyc08/opencodex 2.57.0 → 2.59.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-C5IebErG.js +136 -0
- package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
- 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 +2 -2
- package/src/adapters/base.ts +11 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
- package/src/adapters/command-code.ts +13 -4
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- 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 +5 -1
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +27 -6
- package/src/adapters/cursor/request-builder.ts +14 -3
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +141 -0
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +97 -6
- package/src/adapters/devin/cloud-direct/chat.ts +11 -2
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
- package/src/adapters/devin.ts +75 -13
- package/src/adapters/google-antigravity-wire.ts +29 -2
- package/src/adapters/google-http.ts +45 -13
- package/src/adapters/google.ts +23 -4
- package/src/adapters/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat.ts +5 -10
- package/src/adapters/openai-responses/passthrough.ts +40 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -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/physical-send.ts +50 -0
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +166 -25
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +46 -5
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +22 -2
- 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/config-command.ts +35 -18
- package/src/cli/dispatch.ts +71 -4
- package/src/cli/doctor.ts +197 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +132 -22
- package/src/cli/models-runtime.ts +33 -4
- 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 +72 -1
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/client/machine-api.ts +4 -3
- package/src/client/machine-listener.ts +14 -1
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +81 -5
- package/src/codex/auth-api/pool-quota-probe.ts +14 -3
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +58 -20
- 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 +1 -0
- package/src/codex/catalog/model-hints.ts +37 -5
- package/src/codex/catalog/parsing.ts +83 -5
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +19 -0
- package/src/codex/catalog/routed-gather.ts +42 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +37 -5
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +204 -32
- package/src/codex/inject.ts +6 -9
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +40 -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-entitlements.ts +60 -1
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/observed-model-denials.ts +137 -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 +282 -12
- 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 +13 -15
- package/src/codex/subagent-model-fallback.ts +94 -0
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +122 -5
- package/src/config/atomic-write.ts +83 -8
- package/src/config/diagnostics.ts +21 -0
- package/src/config/load-degrade.ts +15 -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 +23 -5
- package/src/config/schema/config-schema.ts +23 -0
- package/src/config/schema/leaf-validators.ts +65 -17
- package/src/generated/compatibility-version.json +337 -201
- package/src/generated/model-metadata.ts +1 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +3 -15
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/provider-outbound.ts +3 -3
- 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 +631 -0
- package/src/lib/spend-reservation-ledger.ts +115 -9
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/generic-account-failover.ts +40 -27
- 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 +6 -0
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +261 -7
- package/src/providers/model-discovery.ts +19 -7
- package/src/providers/model-rename-migration.ts +1 -0
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/registry/entries-core.ts +11 -0
- package/src/providers/registry/entries-extended.ts +146 -28
- package/src/providers/registry/model-seeds.ts +136 -29
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/apply-patch-envelope.ts +44 -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/hosted-tool-policy.ts +85 -2
- package/src/responses/schema.ts +9 -2
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/server/auth-cors.ts +27 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +24 -2
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/websocket-handler.ts +48 -1
- package/src/server/index.ts +15 -19
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/config-routes.ts +57 -10
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/relay.ts +17 -24
- package/src/server/request-log.ts +25 -1
- package/src/server/responses/adapter-continuation.ts +71 -27
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +52 -24
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- package/src/server/responses/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +60 -11
- package/src/server/responses/core-codex-account.ts +83 -22
- package/src/server/responses/core-combo.ts +26 -0
- package/src/server/responses/core-normalize.ts +12 -5
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/fetch-helpers.ts +72 -3
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +21 -1
- package/src/server/responses/passthrough-dispatch.ts +146 -49
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/request-prepare.ts +70 -0
- package/src/server/responses/request-send-budget.ts +84 -7
- package/src/server/responses/request-sidecar-auth.ts +16 -8
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +13 -10
- package/src/server/responses/run-turn-execution.ts +20 -5
- package/src/server/responses/sidecar-execution.ts +2 -0
- package/src/server/responses/ws-upstream.ts +23 -2
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +36 -9
- package/src/server/stop-teardown.ts +8 -1
- 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/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +29 -7
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +104 -3
- package/src/types/provider.ts +32 -0
- package/src/types/request.ts +7 -1
- package/src/types/wire.ts +9 -1
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-Cz7CLdif.js +0 -128
|
@@ -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 =>
|
|
@@ -71,8 +113,28 @@ export function createResponsesSendBudget(
|
|
|
71
113
|
if (send.ordinal <= 1) return;
|
|
72
114
|
noteAttemptSend(logCtx.activeAttempt, inputTokens, send.recovery);
|
|
73
115
|
};
|
|
74
|
-
|
|
75
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Records a recovery an adapter was ready to make and the budget refused.
|
|
118
|
+
*
|
|
119
|
+
* No send happened, so this deliberately does not touch `sendCount`. It is the other half of
|
|
120
|
+
* the pair that makes a one-send log readable: no recovery kind AND no withheld reason means
|
|
121
|
+
* nothing was eligible; a withheld reason means something was (#5044).
|
|
122
|
+
*/
|
|
123
|
+
const noteAdapterRecoveryWithheld = (withheld: { reason: AttemptRecoveryWithheld }): void => {
|
|
124
|
+
noteAttemptRecoveryWithheld(logCtx.activeAttempt, withheld.reason);
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Whether this request has any base send left under `cap`.
|
|
128
|
+
*
|
|
129
|
+
* The cap is a parameter because a lane that reads a provider's configured
|
|
130
|
+
* `transientRetryOn5xx` ladder has to ask this question at the SAME cap its sends use.
|
|
131
|
+
* Asking at the constant while dispatching at a configured value lets a provider with
|
|
132
|
+
* headroom be told it is exhausted, and lets one configured below the constant pass this
|
|
133
|
+
* check and then be refused at the send (#4893). Defaulted, so every existing caller keeps
|
|
134
|
+
* the constant it already used.
|
|
135
|
+
*/
|
|
136
|
+
const sendBudgetExhausted = (cap: number = TRANSIENT_RETRY_MAX_ATTEMPTS): boolean =>
|
|
137
|
+
remainingTransientSendBudget(cap) === 0;
|
|
76
138
|
/**
|
|
77
139
|
* A credential hop reserves the send its own replay will make, and that replay is a recovery
|
|
78
140
|
* leg. The leg must SPEND the hop's reservation instead of taking a second one: the
|
|
@@ -113,16 +175,22 @@ export function createResponsesSendBudget(
|
|
|
113
175
|
* The base allowance is spent first. Once it is gone a recovery class may still draw the
|
|
114
176
|
* single shared final-recovery reserve -- which is what keeps the validated sanitized rebuild
|
|
115
177
|
* after a 5xx streak alive at four total sends -- but an account move and a rebuild cannot
|
|
116
|
-
* each take one.
|
|
117
|
-
*
|
|
178
|
+
* each take one. A caller with an exact provider total can suppress that reserve. The
|
|
179
|
+
* `countedExternally` flag exists because these legs run through the retry helper, which reports
|
|
180
|
+
* the same send again through `onSendsConsumed`.
|
|
118
181
|
*/
|
|
119
182
|
const recoverySendAllowance = (
|
|
120
183
|
cap: number,
|
|
121
184
|
sendClass: SendClass,
|
|
122
185
|
targetKey: string,
|
|
186
|
+
options: { allowFinalRecoveryReserve?: boolean } = {},
|
|
123
187
|
): { attempts: number; permit?: SingleUseDispatchPermit } => {
|
|
124
188
|
const base = remainingTransientSendBudget(cap);
|
|
125
189
|
if (base > 0) return { attempts: base };
|
|
190
|
+
// A provider-configured transient total is an exact physical-send ceiling. Once it is
|
|
191
|
+
// exhausted, the request-wide recovery reserve must not silently widen it. The default stays
|
|
192
|
+
// permissive so unconfigured providers retain the guarded profile's fourth recovery send.
|
|
193
|
+
if (options.allowFinalRecoveryReserve === false) return { attempts: 0 };
|
|
126
194
|
if (pendingHopPermit) {
|
|
127
195
|
const hopPermit = pendingHopPermit;
|
|
128
196
|
pendingHopPermit = undefined;
|
|
@@ -178,9 +246,18 @@ export function createResponsesSendBudget(
|
|
|
178
246
|
workflowRootId,
|
|
179
247
|
noteTransientSends,
|
|
180
248
|
remainingTransientSendBudget,
|
|
249
|
+
/**
|
|
250
|
+
* Physical sends this logical request has already made.
|
|
251
|
+
*
|
|
252
|
+
* A live getter, not a snapshot: it is read once per dispatch leg to resolve a configured
|
|
253
|
+
* ladder, and a value frozen at construction would answer for a request that had sent
|
|
254
|
+
* nothing.
|
|
255
|
+
*/
|
|
256
|
+
get sendsUsed(): number { return sendBudget.used; },
|
|
181
257
|
adapterSendBudget,
|
|
182
258
|
adapterDispatchBudget,
|
|
183
259
|
noteAdapterPhysicalSend,
|
|
260
|
+
noteAdapterRecoveryWithheld,
|
|
184
261
|
sendBudgetExhausted,
|
|
185
262
|
get pendingHopPermit(): SingleUseDispatchPermit | undefined {
|
|
186
263
|
return pendingHopPermit;
|
|
@@ -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.
|
|
@@ -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
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
adapterNeedsForcedContinuation,
|
|
13
13
|
adapterResponseReachedServingTerminal,
|
|
14
14
|
} from "./core-replay";
|
|
15
|
-
import { sealRequestAttemptIdentity, recordAttemptCredentialSource } from "../request-log";
|
|
15
|
+
import { noteAttemptRecoveryWithheld, sealRequestAttemptIdentity, recordAttemptCredentialSource } from "../request-log";
|
|
16
16
|
import { waitForProviderRequestSlot, RequestPacingQueueOverloadError } from "../../providers/request-pacing";
|
|
17
17
|
import type { AdapterEventQueue } from "../../adapters/run-turn-queue";
|
|
18
18
|
import type { AttemptRecoveryKind } from "../../usage/log";
|
|
@@ -231,12 +231,21 @@ export async function executeResponsesRunTurn(
|
|
|
231
231
|
"auth-recovery",
|
|
232
232
|
`${route.providerName}|${route.modelId}|runturn-oauth-429`,
|
|
233
233
|
);
|
|
234
|
-
if (!hop.allowed)
|
|
234
|
+
if (!hop.allowed) {
|
|
235
|
+
// The roster bound above already said this credential set may rotate again; the shared
|
|
236
|
+
// request budget is what refused. Returning false lets the preflight 429 reach the
|
|
237
|
+
// client unchanged, which is right, but it used to leave a log indistinguishable from
|
|
238
|
+
// a request where no rotation was ever available (#5044).
|
|
239
|
+
noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
235
242
|
const nextAccountId = rotateGenericOAuthAccountOn429(
|
|
236
243
|
config,
|
|
237
244
|
route.providerName,
|
|
238
245
|
transportState.genericFailoverAccountId,
|
|
239
246
|
null,
|
|
247
|
+
Date.now(),
|
|
248
|
+
route.modelId,
|
|
240
249
|
);
|
|
241
250
|
if (!nextAccountId) {
|
|
242
251
|
hop.permit?.release();
|
|
@@ -245,7 +254,8 @@ export async function executeResponsesRunTurn(
|
|
|
245
254
|
try {
|
|
246
255
|
const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
|
|
247
256
|
transportState.genericFailovers += 1;
|
|
248
|
-
|
|
257
|
+
const admittedSnapshot = await applyFailoverSnapshot(snapshot);
|
|
258
|
+
if (!admittedSnapshot) {
|
|
249
259
|
hop.permit?.release();
|
|
250
260
|
return false;
|
|
251
261
|
}
|
|
@@ -275,7 +285,10 @@ export async function executeResponsesRunTurn(
|
|
|
275
285
|
providerName: route.providerName,
|
|
276
286
|
provider: rotatedProvider,
|
|
277
287
|
adapterName: rotatedAdapter.name,
|
|
278
|
-
oauthCredentialSnapshot: {
|
|
288
|
+
oauthCredentialSnapshot: {
|
|
289
|
+
accountId: admittedSnapshot.accountId,
|
|
290
|
+
generation: admittedSnapshot.generation,
|
|
291
|
+
},
|
|
279
292
|
codexAuthContext: admissionState.authCtx,
|
|
280
293
|
forwardHeaders: requestState.selectedForwardHeaders,
|
|
281
294
|
});
|
|
@@ -299,7 +312,9 @@ export async function executeResponsesRunTurn(
|
|
|
299
312
|
try {
|
|
300
313
|
while (true) {
|
|
301
314
|
const preflight = await preflightAdapterEvents(source);
|
|
302
|
-
if (
|
|
315
|
+
if (preflight.replayUnsafe
|
|
316
|
+
|| !preflight.error
|
|
317
|
+
|| !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
|
|
303
318
|
return preflight.stream;
|
|
304
319
|
}
|
|
305
320
|
const retryQueue = createAdapterEventQueue({
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { OPENAI_API_RESPONSES_URL } from "./native-response-control";
|
|
2
|
+
import { isInjectionRequest } from "./native-injection-protocol";
|
|
3
|
+
import type { NativeResponseControl } from "./native-response-control";
|
|
1
4
|
// Upstream WebSocket transport for the ChatGPT Codex backend.
|
|
2
5
|
//
|
|
3
6
|
// Why this exists: the Codex backend serves the responses_websockets path from
|
|
@@ -13,7 +16,7 @@
|
|
|
13
16
|
// (passthrough relay, adapter parsers, usage sniffing) is unchanged.
|
|
14
17
|
|
|
15
18
|
import { compareBunVersions } from "../../lib/bun-stream-caps";
|
|
16
|
-
import { resolveProxyRoute } from "../../lib/proxy-env";
|
|
19
|
+
import { resolveProxyRoute, socks5ProxyFromEnv } from "../../lib/proxy-env";
|
|
17
20
|
import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
|
|
18
21
|
import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request";
|
|
19
22
|
import { codexWsExchange } from "./codex-ws-exchange";
|
|
@@ -104,6 +107,7 @@ export function shouldUseCodexWsUpstream(
|
|
|
104
107
|
upstreamWebsocketConfigured = false,
|
|
105
108
|
): boolean {
|
|
106
109
|
if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
|
|
110
|
+
if (socks5ProxyFromEnv()) return false;
|
|
107
111
|
if (url !== CODEX_RESPONSES_HTTP_URL && !upstreamWebsocketConfigured) return false;
|
|
108
112
|
if (upstreamWebsocketConfigured && !isResponsesWebsocketEligibleUrl(url)) return false;
|
|
109
113
|
if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
|
|
@@ -129,6 +133,8 @@ export function codexWsUpstreamFetch(
|
|
|
129
133
|
runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
|
|
130
134
|
onQuota?: CodexWsQuotaObserver,
|
|
131
135
|
beforeDispatch?: (headers: Headers) => void,
|
|
136
|
+
nativeControl?: NativeResponseControl,
|
|
137
|
+
beforeContinuation?: () => Promise<void>,
|
|
132
138
|
): Promise<Response> {
|
|
133
139
|
const prepared = prepareCodexWsRequest(url, init);
|
|
134
140
|
if (!prepared) return sseFallback(url, prepareCodexHttpInit(url, init));
|
|
@@ -142,6 +148,17 @@ export function codexWsUpstreamFetch(
|
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
const { frameText, headers } = prepared;
|
|
151
|
+
// Never infer backend support from a model name or enable controls on a gateway.
|
|
152
|
+
const control = nativeControl?.kind === "injection"
|
|
153
|
+
? ((prepared.canonical || url === OPENAI_API_RESPONSES_URL) && isInjectionRequest(JSON.parse(frameText)) ? nativeControl : undefined)
|
|
154
|
+
: (prepared.canonical || url === OPENAI_API_RESPONSES_URL) ? nativeControl : undefined;
|
|
155
|
+
if (control?.kind === "injection" && url === OPENAI_API_RESPONSES_URL) {
|
|
156
|
+
const beta = headers["openai-beta"];
|
|
157
|
+
if (!beta?.split(",").some(value => value.trim() === "responses_multi_agent=v1")) {
|
|
158
|
+
headers["openai-beta"] = beta ? `${beta}, responses_multi_agent=v1` : "responses_multi_agent=v1";
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
145
162
|
|
|
146
163
|
// Decide before dialing. Once the socket is open the caller already holds a
|
|
147
164
|
// streaming Response, so the oversized close can only be surfaced as a stream
|
|
@@ -169,7 +186,9 @@ export function codexWsUpstreamFetch(
|
|
|
169
186
|
}
|
|
170
187
|
let session: CodexWsSession;
|
|
171
188
|
try {
|
|
172
|
-
|
|
189
|
+
// Steering keeps a private physical connection across successor responses; it
|
|
190
|
+
// must never enter the idle-socket pool or move to a different credential.
|
|
191
|
+
const identity = control ? null : codexWsReuseIdentity(url, headers, frameText, proxy);
|
|
173
192
|
session = (identity ? codexWsPool.acquire(identity, wsUrl, headers, proxy) : null)
|
|
174
193
|
?? new CodexWsSession(wsUrl, headers, false, undefined, proxy);
|
|
175
194
|
if (!session.busy && !session.reserve()) {
|
|
@@ -181,6 +200,8 @@ export function codexWsUpstreamFetch(
|
|
|
181
200
|
}
|
|
182
201
|
return codexWsExchange({
|
|
183
202
|
session, url, init, prepared, sseFallback, onQuota, beforeDispatch,
|
|
203
|
+
nativeControl: control,
|
|
204
|
+
beforeContinuation,
|
|
184
205
|
bunVersion: typeof runtime === "string" ? runtime : runtime.version,
|
|
185
206
|
});
|
|
186
207
|
}
|
|
@@ -298,7 +298,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
298
298
|
releaseCall(upstreamItemId);
|
|
299
299
|
return [replaceSseDataPayload(block, JSON.stringify({
|
|
300
300
|
...parsed,
|
|
301
|
-
input: helper ? compileCodeModeHelperInput(source, helper) : source,
|
|
301
|
+
input: helper ? compileCodeModeHelperInput(source, helper, name) : source,
|
|
302
302
|
}))];
|
|
303
303
|
}
|
|
304
304
|
if (
|
|
@@ -381,7 +381,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
|
|
|
381
381
|
type: nextType,
|
|
382
382
|
item_id: customToolItemId(upstreamItemId),
|
|
383
383
|
input: helper
|
|
384
|
-
? compileCodeModeHelperInput(source, helper)
|
|
384
|
+
? compileCodeModeHelperInput(source, helper, itemName?.name ?? "")
|
|
385
385
|
: unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace),
|
|
386
386
|
};
|
|
387
387
|
return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))];
|
|
@@ -2,6 +2,8 @@ import { isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
|
|
|
2
2
|
|
|
3
3
|
export const MAX_CLIENT_SSE_FRAME_BYTES = 4 * 1024 * 1024;
|
|
4
4
|
|
|
5
|
+
export const EMPTY_BYTES = new Uint8Array(0);
|
|
6
|
+
|
|
5
7
|
const LF_LF = Uint8Array.of(10, 10);
|
|
6
8
|
const LF_CR_LF = Uint8Array.of(10, 13, 10);
|
|
7
9
|
const CR_LF_LF = Uint8Array.of(13, 10, 10);
|
|
@@ -155,8 +157,8 @@ function isResponsesTerminalFrame(block: Uint8Array): boolean {
|
|
|
155
157
|
export class BoundedSseFrameBuffer {
|
|
156
158
|
private readonly maxFrameBytes: number;
|
|
157
159
|
private readonly maxFramesPerFeed: number;
|
|
158
|
-
private delimiterTail: Uint8Array =
|
|
159
|
-
private candidate: Uint8Array =
|
|
160
|
+
private delimiterTail: Uint8Array = EMPTY_BYTES;
|
|
161
|
+
private candidate: Uint8Array = EMPTY_BYTES;
|
|
160
162
|
private candidateBytes = 0;
|
|
161
163
|
private disposed = false;
|
|
162
164
|
|
|
@@ -172,8 +174,8 @@ export class BoundedSseFrameBuffer {
|
|
|
172
174
|
}
|
|
173
175
|
|
|
174
176
|
private clear(): void {
|
|
175
|
-
this.delimiterTail =
|
|
176
|
-
this.candidate =
|
|
177
|
+
this.delimiterTail = EMPTY_BYTES;
|
|
178
|
+
this.candidate = EMPTY_BYTES;
|
|
177
179
|
this.candidateBytes = 0;
|
|
178
180
|
}
|
|
179
181
|
|
|
@@ -209,12 +211,12 @@ export class BoundedSseFrameBuffer {
|
|
|
209
211
|
}
|
|
210
212
|
|
|
211
213
|
private takeCandidate(): Uint8Array {
|
|
212
|
-
if (this.candidateBytes === 0) return
|
|
214
|
+
if (this.candidateBytes === 0) return EMPTY_BYTES;
|
|
213
215
|
const block = this.candidate.slice(0, this.candidateBytes);
|
|
214
216
|
// Release the working allocation after each complete frame. This avoids
|
|
215
217
|
// retaining a rare multi-MiB frame allocation for the rest of a long-lived
|
|
216
218
|
// stream; normal small-frame allocation remains bounded by feed's frame cap.
|
|
217
|
-
this.candidate =
|
|
219
|
+
this.candidate = EMPTY_BYTES;
|
|
218
220
|
this.candidateBytes = 0;
|
|
219
221
|
return block;
|
|
220
222
|
}
|
|
@@ -225,7 +227,7 @@ export class BoundedSseFrameBuffer {
|
|
|
225
227
|
|
|
226
228
|
const frames: BoundedSseFrame[] = [];
|
|
227
229
|
const previousTail = this.delimiterTail;
|
|
228
|
-
this.delimiterTail =
|
|
230
|
+
this.delimiterTail = EMPTY_BYTES;
|
|
229
231
|
const tailLength = previousTail.byteLength;
|
|
230
232
|
const totalLength = tailLength + chunk.byteLength;
|
|
231
233
|
const byteAt = (index: number): number => index < tailLength
|
|
@@ -286,10 +288,10 @@ export class BoundedSseFrameBuffer {
|
|
|
286
288
|
|
|
287
289
|
/** Return the final unterminated block bytes and release all retained state. */
|
|
288
290
|
finish(): Uint8Array {
|
|
289
|
-
if (this.disposed) return
|
|
291
|
+
if (this.disposed) return EMPTY_BYTES;
|
|
290
292
|
try {
|
|
291
293
|
this.retain(this.delimiterTail);
|
|
292
|
-
this.delimiterTail =
|
|
294
|
+
this.delimiterTail = EMPTY_BYTES;
|
|
293
295
|
return this.takeCandidate();
|
|
294
296
|
} finally {
|
|
295
297
|
this.clear();
|
|
@@ -307,7 +309,7 @@ export class BoundedSseFrameBuffer {
|
|
|
307
309
|
export function joinSseFrameBytes(parts: readonly Uint8Array[]): Uint8Array {
|
|
308
310
|
let byteLength = 0;
|
|
309
311
|
for (const part of parts) byteLength += part.byteLength;
|
|
310
|
-
if (byteLength === 0) return
|
|
312
|
+
if (byteLength === 0) return EMPTY_BYTES;
|
|
311
313
|
if (parts.length === 1 && parts[0]!.byteLength === byteLength) return parts[0]!;
|
|
312
314
|
const joined = new Uint8Array(byteLength);
|
|
313
315
|
let offset = 0;
|
|
@@ -178,17 +178,44 @@ export function createSseBlockBuffer(
|
|
|
178
178
|
|
|
179
179
|
/** Join all data lines from one SSE event according to the event-stream field rules. */
|
|
180
180
|
export function sseDataPayload(block: string): string | null {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
181
|
+
let result = "";
|
|
182
|
+
let found = false;
|
|
183
|
+
let lineStart = 0;
|
|
184
|
+
const len = block.length;
|
|
185
|
+
|
|
186
|
+
while (lineStart < len) {
|
|
187
|
+
const nextNewline = block.indexOf("\n", lineStart);
|
|
188
|
+
let lineEnd = nextNewline === -1 ? len : nextNewline;
|
|
189
|
+
const nextStart = nextNewline === -1 ? len : nextNewline + 1;
|
|
190
|
+
if (lineEnd > lineStart && block.charCodeAt(lineEnd - 1) === 13) {
|
|
191
|
+
lineEnd -= 1;
|
|
186
192
|
}
|
|
187
|
-
|
|
188
|
-
const
|
|
189
|
-
|
|
193
|
+
|
|
194
|
+
const lineLen = lineEnd - lineStart;
|
|
195
|
+
if (lineLen === 4 && block.startsWith("data", lineStart)) {
|
|
196
|
+
if (found) {
|
|
197
|
+
result += "\n";
|
|
198
|
+
} else {
|
|
199
|
+
found = true;
|
|
200
|
+
}
|
|
201
|
+
} else if (lineLen >= 5 && block.startsWith("data:", lineStart)) {
|
|
202
|
+
let valueStart = lineStart + 5;
|
|
203
|
+
if (valueStart < lineEnd && block.charCodeAt(valueStart) === 32) {
|
|
204
|
+
valueStart += 1;
|
|
205
|
+
}
|
|
206
|
+
const value = block.slice(valueStart, lineEnd);
|
|
207
|
+
if (found) {
|
|
208
|
+
result += "\n" + value;
|
|
209
|
+
} else {
|
|
210
|
+
result = value;
|
|
211
|
+
found = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
lineStart = nextStart;
|
|
190
216
|
}
|
|
191
|
-
|
|
217
|
+
|
|
218
|
+
return found ? result : null;
|
|
192
219
|
}
|
|
193
220
|
|
|
194
221
|
/** Replace an SSE event's data field while preserving non-data fields and newline style. */
|
|
@@ -67,7 +67,14 @@ export async function performStopTeardown(url: URL, io: StopTeardownIo = {}): Pr
|
|
|
67
67
|
// undone (#3008).
|
|
68
68
|
const grokNote = grok.ok ? "" : ` Grok config cleanup failed: ${grok.message}`;
|
|
69
69
|
if (restore.success && grok.ok) {
|
|
70
|
-
|
|
70
|
+
// A degraded restore is a success — routing is out and the client is no longer aimed at
|
|
71
|
+
// a port that is about to disappear — but it left a provider table behind on purpose.
|
|
72
|
+
// Reporting a bare "restored" would put the caller in exactly the position #4812
|
|
73
|
+
// describes: a config they did not expect and no idea why it is there.
|
|
74
|
+
const retained = restore.retainedCodexProviderTable
|
|
75
|
+
? ` ${(await import("../codex/inject/restore")).describeRetainedCodexProviderTable(restore.retainedCodexProviderTable)}`
|
|
76
|
+
: "";
|
|
77
|
+
return { success: true, message: `Proxy stopping, native Codex restored.${retained}`, sharedTeardown: "performed" };
|
|
71
78
|
}
|
|
72
79
|
if (restore.success) {
|
|
73
80
|
return {
|