@bitkyc08/opencodex 2.56.0 → 2.58.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/ocx.mjs +10 -0
- package/gui/dist/assets/{index-D4zuyIxQ.js → index-BbrHOIY0.js} +21 -21
- package/gui/dist/assets/{index-BBOZWGB6.css → index-C5-RdDmD.css} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/codebuddy/adapter.ts +2 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +249 -0
- package/src/adapters/command-code.ts +12 -3
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- package/src/adapters/cursor/envelope-echo.ts +8 -2
- package/src/adapters/cursor/live-transport.ts +5 -1
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +19 -1
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +67 -0
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +55 -5
- package/src/adapters/google-http.ts +38 -13
- package/src/adapters/google.ts +7 -7
- package/src/adapters/kiro/payload.ts +17 -3
- package/src/adapters/kiro/reasoning.ts +70 -7
- package/src/adapters/kiro/stream.ts +8 -2
- package/src/adapters/kiro/wire.ts +2 -1
- package/src/adapters/kiro-events.ts +21 -13
- package/src/adapters/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/tool-name-registry.ts +166 -0
- package/src/adapters/openai-chat/tool-schema.ts +25 -7
- package/src/adapters/openai-chat.ts +8 -8
- package/src/adapters/openai-responses/passthrough.ts +62 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -0
- package/src/adapters/physical-send.ts +50 -0
- package/src/bridge/errors.ts +26 -2
- package/src/bridge/response-json.ts +8 -2
- package/src/bridge/sse.ts +20 -2
- package/src/claude/desktop-profile.ts +66 -9
- package/src/claude/outbound.ts +32 -4
- package/src/cli/account-main.ts +1 -1
- package/src/cli/capabilities.ts +2 -2
- package/src/cli/combo.ts +10 -1
- package/src/cli/config-command.ts +35 -18
- package/src/cli/dispatch.ts +17 -4
- package/src/cli/index.ts +92 -7
- package/src/cli/registry.ts +2 -1
- package/src/cli/system-command.ts +74 -5
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/clients/config-export.ts +7 -3
- package/src/codex/account-label.ts +14 -3
- package/src/codex/account-store.ts +113 -26
- package/src/codex/account-usability.ts +21 -0
- package/src/codex/auth-api/login-flow.ts +14 -2
- package/src/codex/auth-api/reset-credit-service.ts +11 -2
- package/src/codex/auth-context.ts +199 -15
- package/src/codex/catalog/aggregation.ts +80 -1
- package/src/codex/catalog/model-visibility.ts +1 -0
- package/src/codex/catalog/remote.ts +30 -0
- package/src/codex/catalog/retained-sync.ts +9 -1
- package/src/codex/catalog/routed-gather.ts +38 -1
- package/src/codex/cli-install-provenance.ts +7 -1
- package/src/codex/convergence.ts +7 -2
- package/src/codex/desktop-app/types.ts +11 -2
- package/src/codex/desktop-app/windows.ts +5 -5
- package/src/codex/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +33 -4
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +231 -32
- package/src/codex/inject.ts +12 -16
- package/src/codex/loopback-target.ts +9 -0
- package/src/codex/model-entitlements.ts +152 -15
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/pool-refresh-backoff.ts +12 -3
- package/src/codex/quota-rejection.ts +104 -15
- package/src/codex/routing/cache-affinity.ts +70 -0
- package/src/codex/routing/cooldown-math.ts +10 -0
- package/src/codex/routing/selection.ts +79 -2
- package/src/codex/routing/thread-affinity.ts +50 -2
- package/src/codex/routing/transient-hold-dispatch.ts +141 -0
- package/src/codex/routing.ts +29 -49
- package/src/codex/warmup.ts +1 -1
- package/src/combos/failover.ts +85 -0
- package/src/combos/request.ts +17 -10
- package/src/combos/types.ts +23 -2
- package/src/config/atomic-write.ts +83 -8
- package/src/config/pending-teardown.ts +31 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +272 -180
- package/src/images/loop.ts +1 -1
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/errors.ts +17 -0
- package/src/lib/request-execution-budget.ts +147 -21
- package/src/lib/spend-reservation-ledger.ts +18 -0
- package/src/lib/state-store-registrations.ts +6 -2
- package/src/lib/test-home-guard.ts +85 -1
- package/src/lib/upstream-retry.ts +77 -10
- package/src/lib/windows-elevation.ts +76 -14
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/oauth/index.ts +2 -2
- package/src/oauth/key-providers.ts +2 -2
- package/src/providers/derive.ts +6 -0
- package/src/providers/kiro-models.ts +4 -3
- package/src/providers/label.ts +19 -1
- package/src/providers/model-discovery.ts +35 -7
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +59 -28
- package/src/providers/registry/model-seeds.ts +71 -17
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/reasoning-envelope.ts +6 -3
- 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/routing/identity-domains.ts +21 -14
- package/src/routing/probe-lease.ts +103 -1
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +3 -1
- package/src/server/chat-native.ts +37 -9
- package/src/server/index/live-sideband.ts +37 -1
- package/src/server/index/websocket-handler.ts +54 -3
- package/src/server/index.ts +5 -5
- package/src/server/inspection-tee.ts +107 -0
- package/src/server/live.ts +46 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +27 -5
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/relay-eager.ts +2 -0
- package/src/server/relay.ts +14 -19
- package/src/server/request-log.ts +127 -3
- package/src/server/response-log-body.ts +153 -0
- package/src/server/responses/account-change-state.ts +74 -0
- package/src/server/responses/adapter-continuation.ts +33 -7
- package/src/server/responses/adapter-delivery.ts +5 -11
- package/src/server/responses/adapter-dispatch.ts +84 -13
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- package/src/server/responses/codex-ws-wire.ts +5 -0
- package/src/server/responses/collaboration.ts +74 -4
- package/src/server/responses/combo-session-recall.ts +68 -8
- package/src/server/responses/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +54 -13
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-codex-account.ts +51 -3
- package/src/server/responses/core-combo.ts +129 -23
- package/src/server/responses/core-errors.ts +18 -0
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/core-replay.ts +105 -32
- package/src/server/responses/core.ts +3 -3
- package/src/server/responses/encrypted-payload.ts +0 -1
- package/src/server/responses/fetch-helpers.ts +4 -1
- package/src/server/responses/input-admission.ts +126 -6
- 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 +30 -6
- package/src/server/responses/passthrough-dispatch.ts +61 -11
- package/src/server/responses/passthrough-error.ts +38 -2
- package/src/server/responses/request-prepare.ts +173 -22
- package/src/server/responses/request-send-budget.ts +97 -2
- package/src/server/responses/request-spend.ts +147 -0
- package/src/server/responses/request-transport.ts +62 -3
- package/src/server/responses/run-turn-execution.ts +59 -31
- package/src/server/responses/sidecar-execution.ts +7 -13
- package/src/server/responses/terminal-guard.ts +65 -4
- package/src/server/responses/ws-upstream.ts +21 -1
- package/src/server/responses-undeclared-tool-guard.ts +9 -5
- package/src/server/stop-teardown.ts +8 -1
- package/src/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +13 -1
- package/src/service/windows-ops.ts +210 -16
- package/src/service/windows-scheduler.ts +28 -21
- package/src/service.ts +1 -1
- package/src/types/config.ts +8 -1
- package/src/types/provider.ts +13 -0
- package/src/types/request.ts +8 -5
- package/src/types/tools.ts +24 -0
- package/src/types.ts +2 -0
- package/src/update/index.ts +10 -0
- package/src/update/stop-contract.d.mts +1 -0
- package/src/update/stop-contract.mjs +19 -0
- package/src/update/stop-decision.d.mts +1 -1
- package/src/update/stop-decision.mjs +12 -3
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +1 -1
- package/src/vision/describe.ts +5 -5
- package/src/web-search/anthropic-executor.ts +1 -1
- package/src/web-search/exa-executor.ts +1 -1
- package/src/web-search/executor.ts +1 -1
- package/src/web-search/gemini-executor.ts +1 -1
- package/src/web-search/loop.ts +1 -1
- package/src/web-search/ollama-executor.ts +1 -1
- package/src/web-search/parse.ts +67 -14
- package/src/web-search/passthrough-bridge.ts +64 -31
- package/src/web-search/xai-executor.ts +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isNonReplayableResponse } from "../../lib/upstream-retry";
|
|
1
2
|
import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
|
|
2
3
|
import type { PreparedResponsesRequest } from "./request-prepare";
|
|
3
4
|
import type { ResponsesTransport } from "./request-transport";
|
|
@@ -11,7 +12,6 @@ import { trackStreamLifetime } from "../lifecycle";
|
|
|
11
12
|
import {
|
|
12
13
|
recordAdapterReasoning,
|
|
13
14
|
recordAdapterTier,
|
|
14
|
-
noteAttemptSend,
|
|
15
15
|
sealRequestAttemptIdentity,
|
|
16
16
|
recordAttemptCredentialSource,
|
|
17
17
|
} from "../request-log";
|
|
@@ -72,7 +72,12 @@ import { consumeComboFailure } from "./core-combo-failure";
|
|
|
72
72
|
import { streamingContextOverflowResponse, jsonContextOverflowResponse } from "./context-overflow";
|
|
73
73
|
import { isFixedCodexAccount } from "./core-codex-account";
|
|
74
74
|
import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback";
|
|
75
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
isCyberPolicyCode,
|
|
77
|
+
CYBER_POLICY_FALLBACK_MESSAGE,
|
|
78
|
+
CYBER_POLICY_ERROR_CODE,
|
|
79
|
+
SEND_BUDGET_EXHAUSTED_CODE,
|
|
80
|
+
} from "../../lib/errors";
|
|
76
81
|
import { resolveClientRetryAfter } from "../../lib/retry-after";
|
|
77
82
|
import { cancelBodyOnAbort } from "../../lib/abort";
|
|
78
83
|
|
|
@@ -115,11 +120,12 @@ export async function prepareAdapterExchange(
|
|
|
115
120
|
| "genericFailoverAccountId"
|
|
116
121
|
| "genericFailovers"
|
|
117
122
|
| "applyFailoverSnapshot"
|
|
123
|
+
| "noteRoutedAttemptSend"
|
|
118
124
|
>,
|
|
119
125
|
responseEffects: Pick<ResponsesEffects, "cancelResponseCompletion" | "notifyResponseComplete" | "refreshRequestToolAliases">,
|
|
120
126
|
sendBudgetState: Pick<
|
|
121
127
|
ResponsesSendBudget,
|
|
122
|
-
| "
|
|
128
|
+
| "adapterDispatchBudget"
|
|
123
129
|
| "noteAdapterPhysicalSend"
|
|
124
130
|
| "remainingTransientSendBudget"
|
|
125
131
|
| "noteTransientSends"
|
|
@@ -127,6 +133,7 @@ export async function prepareAdapterExchange(
|
|
|
127
133
|
| "recoveryClassFor"
|
|
128
134
|
| "sendBudgetExhausted"
|
|
129
135
|
| "reserveCredentialHop"
|
|
136
|
+
| "pendingHopPermit"
|
|
130
137
|
>,
|
|
131
138
|
) {
|
|
132
139
|
const { options, config, logCtx, req } = requestContext;
|
|
@@ -151,7 +158,7 @@ export async function prepareAdapterExchange(
|
|
|
151
158
|
} = requestState;
|
|
152
159
|
const { cancelResponseCompletion, notifyResponseComplete, refreshRequestToolAliases } = responseEffects;
|
|
153
160
|
const {
|
|
154
|
-
|
|
161
|
+
adapterDispatchBudget,
|
|
155
162
|
noteAdapterPhysicalSend,
|
|
156
163
|
remainingTransientSendBudget,
|
|
157
164
|
noteTransientSends,
|
|
@@ -272,15 +279,16 @@ export async function prepareAdapterExchange(
|
|
|
272
279
|
let upstreamResponse: Response;
|
|
273
280
|
try {
|
|
274
281
|
if (transportState.activeAdapter.fetchResponse) {
|
|
275
|
-
|
|
282
|
+
transportState.noteRoutedAttemptSend(inputTokenEstimate);
|
|
276
283
|
await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
|
|
277
284
|
upstreamResponse = await transportState.activeAdapter.fetchResponse(builtInitialRequest, {
|
|
278
285
|
abortSignal: upstream.signal,
|
|
279
286
|
timeoutMs: connectMs,
|
|
280
|
-
sendBudget:
|
|
287
|
+
sendBudget: adapterDispatchBudget,
|
|
281
288
|
onPhysicalSend: send => noteAdapterPhysicalSend(inputTokenEstimate, send),
|
|
282
289
|
stream: parsed.stream,
|
|
283
290
|
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
291
|
+
pacingSlotAcquired: true,
|
|
284
292
|
dispatchOverride: oauthDispatch(builtInitialRequest),
|
|
285
293
|
providerName: route.providerName,
|
|
286
294
|
modelId: route.modelId,
|
|
@@ -300,7 +308,7 @@ export async function prepareAdapterExchange(
|
|
|
300
308
|
: fetchWithResetRetry;
|
|
301
309
|
upstreamResponse = await fetchWithRetryPolicy(
|
|
302
310
|
recovery => {
|
|
303
|
-
|
|
311
|
+
transportState.noteRoutedAttemptSend(inputTokenEstimate, recovery);
|
|
304
312
|
return fetchWithHeaderTimeout(builtInitialRequest.url, applyUpstreamRecoveryInit({
|
|
305
313
|
method: builtInitialRequest.method,
|
|
306
314
|
headers: builtInitialRequest.headers,
|
|
@@ -331,6 +339,13 @@ export async function prepareAdapterExchange(
|
|
|
331
339
|
cleanupUpstreamAbort();
|
|
332
340
|
upstream.abort();
|
|
333
341
|
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
342
|
+
// A budget refusal is a decision this process made, not an upstream fault. Reporting it as
|
|
343
|
+
// 502 does more than mislabel it: the Codex client retries 5xx and does not retry a 429, so
|
|
344
|
+
// blaming the provider makes the caller send the whole turn again -- the amplification this
|
|
345
|
+
// budget exists to stop. The passthrough path has answered 429 here since #4546.
|
|
346
|
+
if (err instanceof SendBudgetExhaustedError) {
|
|
347
|
+
return formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, err.message);
|
|
348
|
+
}
|
|
334
349
|
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
335
350
|
return formatErrorResponse(502, "upstream_error", msg);
|
|
336
351
|
} finally {
|
|
@@ -412,10 +427,10 @@ export async function prepareAdapterExchange(
|
|
|
412
427
|
logCtx.providerAdapter = transportState.activeAdapter.name;
|
|
413
428
|
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
|
|
414
429
|
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
|
|
415
|
-
noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery);
|
|
416
430
|
try {
|
|
417
431
|
try {
|
|
418
432
|
if (transportState.activeAdapter.fetchResponse) {
|
|
433
|
+
transportState.noteRoutedAttemptSend(retryEstimate, recovery);
|
|
419
434
|
await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal);
|
|
420
435
|
// The dispatch boundary is HERE, not before the pacing wait: that wait can reject for
|
|
421
436
|
// an abort, a saturated queue, an expired slot or a removed provider, and none of
|
|
@@ -425,10 +440,11 @@ export async function prepareAdapterExchange(
|
|
|
425
440
|
return await transportState.activeAdapter.fetchResponse(retryRequest, {
|
|
426
441
|
abortSignal: upstream.signal,
|
|
427
442
|
timeoutMs: connectMs,
|
|
428
|
-
sendBudget:
|
|
443
|
+
sendBudget: adapterDispatchBudget,
|
|
429
444
|
onPhysicalSend: send => noteAdapterPhysicalSend(retryEstimate, send),
|
|
430
445
|
stream: parsed.stream,
|
|
431
446
|
executor: providerFetch(route.provider, options.codexWsRuntimeIdentity, {
|
|
447
|
+
pacingSlotAcquired: true,
|
|
432
448
|
dispatchOverride: oauthDispatch(retryRequest),
|
|
433
449
|
providerName: route.providerName,
|
|
434
450
|
modelId: route.modelId,
|
|
@@ -461,6 +477,7 @@ export async function prepareAdapterExchange(
|
|
|
461
477
|
if (refetchAllowance?.permit && !refetchAllowance.permit.use()) {
|
|
462
478
|
throw new SendBudgetExhaustedError(safeHostLabel(retryRequest.url));
|
|
463
479
|
}
|
|
480
|
+
transportState.noteRoutedAttemptSend(retryEstimate, recoveryKind ?? recovery);
|
|
464
481
|
// Same boundary on the helper path: the thunk is what reaches the wire, and it
|
|
465
482
|
// can be refused above before it does. use() past the first attempt is a no-op.
|
|
466
483
|
onDispatch?.();
|
|
@@ -499,12 +516,23 @@ export async function prepareAdapterExchange(
|
|
|
499
516
|
if (options.abortSignal?.aborted) {
|
|
500
517
|
return { failed: clientCancelledResponse() };
|
|
501
518
|
}
|
|
519
|
+
// Same rule on the recovery leg: the ladder refused to send again, so the answer names
|
|
520
|
+
// this proxy rather than the provider it never reached.
|
|
521
|
+
if (err instanceof SendBudgetExhaustedError) {
|
|
522
|
+
return { failed: formatErrorResponse(429, SEND_BUDGET_EXHAUSTED_CODE, err.message) };
|
|
523
|
+
}
|
|
502
524
|
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
503
525
|
return { failed: formatErrorResponse(502, "upstream_error", msg) };
|
|
504
526
|
}
|
|
505
527
|
};
|
|
506
528
|
// Keep recovery kinds in sync with the native Responses `passthroughRecovery:` loop above.
|
|
507
529
|
recovery: for (;;) {
|
|
530
|
+
// Preserve the terminal verdict through adapter and combo error formatting.
|
|
531
|
+
// This also covers a reset reached by a 401/429/413 recovery refetch.
|
|
532
|
+
if (isNonReplayableResponse(upstreamResponse)) {
|
|
533
|
+
cleanupUpstreamAbort();
|
|
534
|
+
return upstreamResponse;
|
|
535
|
+
}
|
|
508
536
|
if (
|
|
509
537
|
upstreamResponse.status === 401
|
|
510
538
|
&& isOAuth401ReplayProvider
|
|
@@ -593,6 +621,11 @@ export async function prepareAdapterExchange(
|
|
|
593
621
|
const result = await rebuildAndRefetch("key-401");
|
|
594
622
|
if ("failed" in result) return result.failed;
|
|
595
623
|
upstreamResponse = result;
|
|
624
|
+
// A recovery refetch can itself die on an ambiguous pre-header reset, and the refusal
|
|
625
|
+
// that answers it is a 429. Every arm below keys on 429, so letting it fall through
|
|
626
|
+
// hands the marked refusal to the next waiting arm and replays the send it exists to
|
|
627
|
+
// stop. Re-enter the loop guard instead, which returns it unchanged.
|
|
628
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
596
629
|
}
|
|
597
630
|
|
|
598
631
|
// Same-target 429 wait-and-retry (opt-in `retryOn429`, issue #487). Codex never retries
|
|
@@ -633,6 +666,9 @@ export async function prepareAdapterExchange(
|
|
|
633
666
|
const result = await rebuildAndRefetch("rate-limit-429");
|
|
634
667
|
if ("failed" in result) return result.failed;
|
|
635
668
|
upstreamResponse = result;
|
|
669
|
+
// The refusal is a 429 too: without this the while condition is still true and the
|
|
670
|
+
// next configured attempt replays it on the same target.
|
|
671
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
636
672
|
}
|
|
637
673
|
|
|
638
674
|
// Multi-key 429 failover: rotate to the next pool key (cooldown-aware) and retry the
|
|
@@ -664,6 +700,9 @@ export async function prepareAdapterExchange(
|
|
|
664
700
|
const result = await rebuildAndRefetch("key-429");
|
|
665
701
|
if ("failed" in result) return result.failed;
|
|
666
702
|
upstreamResponse = result;
|
|
703
|
+
// Rotating on the refusal would also write a cooldown against a key that rate-limited
|
|
704
|
+
// nothing, which outlives the request.
|
|
705
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
667
706
|
}
|
|
668
707
|
|
|
669
708
|
// Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
|
|
@@ -700,6 +739,7 @@ export async function prepareAdapterExchange(
|
|
|
700
739
|
const result = await rebuildAndRefetch("anthropic-oauth-429");
|
|
701
740
|
if ("failed" in result) return result.failed;
|
|
702
741
|
upstreamResponse = result;
|
|
742
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
703
743
|
} catch {
|
|
704
744
|
break;
|
|
705
745
|
}
|
|
@@ -722,9 +762,23 @@ export async function prepareAdapterExchange(
|
|
|
722
762
|
// rebuildAndRefetch, so the roster cap alone would let one request walk the roster on
|
|
723
763
|
// an allowance the rest of the request cannot see. A refusal ends the ladder with the
|
|
724
764
|
// real 429 already in hand, which is the decided exhaustion contract.
|
|
765
|
+
//
|
|
766
|
+
// Who settles this reservation depends on who dispatches the replay (#4709). An
|
|
767
|
+
// adapter that owns its ladder -- Kiro's reset loop, Cursor's transport loop --
|
|
768
|
+
// reserves once per physical send and would charge the same replay again; the helper
|
|
769
|
+
// path reports it again through `onSendsConsumed`. Both turned one physical send into
|
|
770
|
+
// two charges, and once the allowance was spent, into a synthetic error in place of
|
|
771
|
+
// the 429 this hop was recovering from. The wire protocol is resolved from the
|
|
772
|
+
// provider and model, not from the account, so an account rotation cannot move the
|
|
773
|
+
// replay between these two shapes.
|
|
774
|
+
const adapterOwnsDispatch = transportState.activeAdapter.fetchResponse !== undefined;
|
|
725
775
|
const hop = reserveCredentialHop(
|
|
726
776
|
"auth-recovery",
|
|
727
777
|
`${route.providerName}|${route.modelId}|adapter-recovery-oauth-429`,
|
|
778
|
+
// Only a helper-routed replay reports this send back. A reset-only refetch reports
|
|
779
|
+
// nothing and an adapter ladder settles the booking itself, so promising an external
|
|
780
|
+
// report on either would leave a booking pending until it swallowed a later charge.
|
|
781
|
+
!adapterOwnsDispatch && transientRetryPolicyFor(route.provider) !== null,
|
|
728
782
|
);
|
|
729
783
|
if (!hop.allowed) break;
|
|
730
784
|
const nextAccountId = rotateGenericOAuthAccountOn429(
|
|
@@ -755,16 +809,33 @@ export async function prepareAdapterExchange(
|
|
|
755
809
|
);
|
|
756
810
|
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
|
|
757
811
|
recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
|
|
758
|
-
//
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
|
|
812
|
+
// The replay IS this hop's send, so hand the reservation down and let the layer that
|
|
813
|
+
// dispatches settle it: `adapterDispatchBudget` spends it on the adapter's first
|
|
814
|
+
// reservation, and the retry helper's reporter settles the external booking.
|
|
815
|
+
sendBudgetState.pendingHopPermit = hop.permit;
|
|
816
|
+
let result: Response | { failed: Response };
|
|
817
|
+
try {
|
|
818
|
+
// Confirm at the dispatch boundary, not here: a rebuild can fail while shaping the
|
|
819
|
+
// request and return `{ failed }` without reaching the wire, and a permit confirmed
|
|
820
|
+
// before that would hold the charge for a send that never happened. An
|
|
821
|
+
// adapter-owned ladder is the exception -- its own reservation is the confirmation,
|
|
822
|
+
// and settling here first would hand it a dead permit, which it reads as an
|
|
823
|
+
// exhausted request and stops sending on.
|
|
824
|
+
result = await rebuildAndRefetch("oauth-account-429", () => {
|
|
825
|
+
if (!adapterOwnsDispatch) hop.permit?.use();
|
|
826
|
+
});
|
|
827
|
+
} finally {
|
|
828
|
+
sendBudgetState.pendingHopPermit = undefined;
|
|
829
|
+
}
|
|
762
830
|
if ("failed" in result) {
|
|
763
831
|
// A no-op if the boundary was reached; a refund if the rebuild died before it.
|
|
764
832
|
hop.permit?.release();
|
|
765
833
|
return result.failed;
|
|
766
834
|
}
|
|
767
835
|
upstreamResponse = result;
|
|
836
|
+
// The hop's permit is already settled by the dispatch boundary above; continuing
|
|
837
|
+
// only skips the remaining arms, it does not abandon a reservation.
|
|
838
|
+
if (isNonReplayableResponse(upstreamResponse)) continue recovery;
|
|
768
839
|
} catch {
|
|
769
840
|
// A throw before the send — snapshot fetch, credential application, adapter
|
|
770
841
|
// resolution — must hand the reservation back. Without this the ladder charges the
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { mergeSteeringContinuation } from "./native-steering-settings";
|
|
2
|
+
import { markNativeControlResponse } from "./native-response-control";
|
|
3
|
+
import type { NativeResponseControl } from "./native-response-control";
|
|
1
4
|
import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
|
|
2
5
|
import { isSafeResponseHeader } from "../safe-response-headers";
|
|
3
6
|
import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata";
|
|
@@ -6,10 +9,12 @@ import { CodexWsCorrelation } from "./codex-ws-correlation";
|
|
|
6
9
|
import type { CodexWsSession } from "./codex-ws-session";
|
|
7
10
|
import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
|
|
8
11
|
MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
|
|
9
|
-
codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
|
|
12
|
+
codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
|
|
10
13
|
type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
|
|
11
14
|
|
|
12
15
|
interface ExchangeOptions {
|
|
16
|
+
nativeControl?: NativeResponseControl;
|
|
17
|
+
beforeContinuation?: () => Promise<void>;
|
|
13
18
|
session: CodexWsSession;
|
|
14
19
|
url: string;
|
|
15
20
|
init: RequestInit;
|
|
@@ -86,7 +91,7 @@ function wrappedRejectionResponse(payload: Record<string, unknown>, prelude: Hea
|
|
|
86
91
|
|
|
87
92
|
/** The sole SSE exchange state machine for both one-shot and retained sockets. */
|
|
88
93
|
export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
89
|
-
const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch, bunVersion } = options;
|
|
94
|
+
const { session, url, init, prepared, sseFallback, onQuota, beforeDispatch, bunVersion, nativeControl, beforeContinuation } = options;
|
|
90
95
|
const { frameText, headers } = prepared;
|
|
91
96
|
const signal = init.signal ?? undefined;
|
|
92
97
|
return new Promise<Response>((resolve, reject) => {
|
|
@@ -116,6 +121,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
116
121
|
const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
|
|
117
122
|
const correlation = session.retainable ? new CodexWsCorrelation(session.reused, id => session.hasCompleted(id)) : null;
|
|
118
123
|
let detachOwner = () => {};
|
|
124
|
+
let detachSteering = () => {};
|
|
125
|
+
let continuationBase: Record<string, unknown> | undefined;
|
|
119
126
|
// Liveness while waiting for the first response event (metadata path only): the
|
|
120
127
|
// silence timer is re-armed by every inbound frame or pong; the pinger runs on a fixed
|
|
121
128
|
// interval so a peer that answers pings can never trip the silence bound while alive.
|
|
@@ -142,6 +149,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
142
149
|
metadata?.finish();
|
|
143
150
|
correlation?.finish();
|
|
144
151
|
detachOwner();
|
|
152
|
+
detachSteering();
|
|
145
153
|
ws.removeEventListener("open", onOpen);
|
|
146
154
|
ws.removeEventListener("message", onMessage);
|
|
147
155
|
ws.removeEventListener("close", onClose);
|
|
@@ -198,6 +206,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
198
206
|
const response = new Response(stream, { status: 200, headers: responseHeaders });
|
|
199
207
|
metadata?.commit();
|
|
200
208
|
markCodexWsResponse(response, Boolean(metadata && onQuota));
|
|
209
|
+
if (nativeControl) markNativeControlResponse(response);
|
|
201
210
|
markCodexWsStage(response, stageRecord(null));
|
|
202
211
|
committedResponse = response;
|
|
203
212
|
resolve(response);
|
|
@@ -329,6 +338,54 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
329
338
|
}
|
|
330
339
|
if (terminal || settledPreOpen || signal?.aborted) return;
|
|
331
340
|
sent = true;
|
|
341
|
+
try {
|
|
342
|
+
if (nativeControl) {
|
|
343
|
+
// Parsed once: the base body is immutable for this exchange, and a
|
|
344
|
+
// full-replay frame runs to megabytes. It seeds continuationBase on
|
|
345
|
+
// the first create frame.
|
|
346
|
+
let base: Record<string, unknown> | undefined;
|
|
347
|
+
detachSteering = nativeControl.attach(frame => {
|
|
348
|
+
const sendControl = () => {
|
|
349
|
+
if (terminal || signal?.aborted || session.closed || ws.readyState !== WebSocket.OPEN) {
|
|
350
|
+
throw new Error("Native steering connection is no longer available");
|
|
351
|
+
}
|
|
352
|
+
beforeDispatch?.(new Headers(headers));
|
|
353
|
+
let outgoing = frame;
|
|
354
|
+
if (frame.type === "response.create") {
|
|
355
|
+
// Generation overrides have passed route policy; identity/tools remain pinned.
|
|
356
|
+
// Keep the last explicit wire settings for later explicit and automatic successors.
|
|
357
|
+
base ??= JSON.parse(frameText) as Record<string, unknown>;
|
|
358
|
+
continuationBase ??= base;
|
|
359
|
+
outgoing = nativeControl.kind === "steering"
|
|
360
|
+
? mergeSteeringContinuation(continuationBase, frame)
|
|
361
|
+
: { ...continuationBase, input: frame.input, previous_response_id: frame.previous_response_id };
|
|
362
|
+
}
|
|
363
|
+
const text = JSON.stringify(outgoing);
|
|
364
|
+
if (codexWsCreateFrameExceedsLimit(text)) {
|
|
365
|
+
throw new Error("Native steering frame exceeds the transport byte limit");
|
|
366
|
+
}
|
|
367
|
+
if (frame.type === "response.create") continuationBase = outgoing;
|
|
368
|
+
try { ws.send(text); } catch {
|
|
369
|
+
// A send failure has unknown delivery. Never replay or fall back.
|
|
370
|
+
failStream("Native steering send failed; delivery is unknown");
|
|
371
|
+
throw new Error("Native steering send failed; delivery is unknown");
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
if (frame.type === "response.create" && beforeContinuation) {
|
|
375
|
+
// Explicit tool-result continuations are physical request starts;
|
|
376
|
+
// they keep provider pacing and revalidate auth AFTER the wait.
|
|
377
|
+
void beforeContinuation().then(sendControl).catch(() => failStream("Native steering continuation could not be dispatched; do not automatically replay queued input"));
|
|
378
|
+
} else sendControl();
|
|
379
|
+
}, error => failStream(error));
|
|
380
|
+
}
|
|
381
|
+
} catch (error) {
|
|
382
|
+
// An attach failure is an ownership conflict, not a failed send: no frame
|
|
383
|
+
// left the process, but the channel can never bind, so resolving the HTTP
|
|
384
|
+
// fallback here would silently degrade a multi-agent turn into an ordinary
|
|
385
|
+
// one. Fail the turn visibly instead.
|
|
386
|
+
failStream(error);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
332
389
|
try {
|
|
333
390
|
ws.send(frameText);
|
|
334
391
|
sentAt = Date.now();
|
|
@@ -400,8 +457,12 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
400
457
|
return;
|
|
401
458
|
}
|
|
402
459
|
if (!controlFrame && !type.startsWith("response.") && type !== "error") return;
|
|
460
|
+
let steeringEnded = false;
|
|
403
461
|
if (!controlFrame) {
|
|
404
|
-
try {
|
|
462
|
+
try {
|
|
463
|
+
if (nativeControl) steeringEnded = nativeControl.observe(normalized.payload);
|
|
464
|
+
else correlation?.accept(normalized.payload);
|
|
465
|
+
} catch (error) { failStream(error); return; }
|
|
405
466
|
// Correlation must run first: a reused socket's foreign-stream error settles as a
|
|
406
467
|
// non-replayable 502 above, never as the refused-create 4xx projection below, which
|
|
407
468
|
// is the one status family that could authorize an account replay.
|
|
@@ -443,7 +504,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
|
|
|
443
504
|
return;
|
|
444
505
|
}
|
|
445
506
|
if (!controlFrame) relayedEvents += 1;
|
|
446
|
-
if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {
|
|
507
|
+
if (nativeControl ? steeringEnded : (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error")) {
|
|
447
508
|
const completedId = correlation?.completed(normalized.payload) ?? null;
|
|
448
509
|
terminal = true;
|
|
449
510
|
cleanup();
|
|
@@ -108,6 +108,11 @@ export type CodexWsStageRecord = Omit<CodexWsFailureStage, "requestBytes"> & {
|
|
|
108
108
|
const codexWsStageByResponse = new WeakMap<Response, CodexWsStageRecord>();
|
|
109
109
|
|
|
110
110
|
export function markCodexWsStage(response: Response, record: CodexWsStageRecord): void {
|
|
111
|
+
const current = codexWsStageByResponse.get(response);
|
|
112
|
+
if (current) {
|
|
113
|
+
Object.assign(current, record);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
111
116
|
codexWsStageByResponse.set(response, record);
|
|
112
117
|
}
|
|
113
118
|
|
|
@@ -30,7 +30,7 @@ import {
|
|
|
30
30
|
} from "../../combos";
|
|
31
31
|
import { isInjectionDebugEnabled } from "../../lib/debug-settings";
|
|
32
32
|
import { injectionDebugLog } from "../../lib/injection-debug-log";
|
|
33
|
-
import { dottedToolName, modelInList, namespacedToolName, toolChoiceToolPredicate } from "../../types";
|
|
33
|
+
import { dottedToolName, modelInList, namespacedToolName, NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES, toolChoiceToolPredicate } from "../../types";
|
|
34
34
|
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig, OcxProviderContinuationState, OcxUsage } from "../../types";
|
|
35
35
|
import {
|
|
36
36
|
forceRefreshOAuthAccessSnapshot,
|
|
@@ -83,7 +83,6 @@ import {
|
|
|
83
83
|
catalogModelSupportsServiceTier,
|
|
84
84
|
finishRequestAttempt,
|
|
85
85
|
inspectResponseLogJson,
|
|
86
|
-
noteAttemptSend,
|
|
87
86
|
readConfiguredCodexServiceTier,
|
|
88
87
|
requestLogSpeedLabel,
|
|
89
88
|
sealRequestAttemptIdentity,
|
|
@@ -152,6 +151,36 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
152
151
|
dottedAliasOwners.set(t.name, null);
|
|
153
152
|
}
|
|
154
153
|
}
|
|
154
|
+
// Bare echo alias (`name` with no namespace spelling, #4679): some providers — observed
|
|
155
|
+
// on the muse family via Command Code — echo a namespaced tool by its bare name. The
|
|
156
|
+
// bare spelling is only a safe alias while it names ONE tool and cannot be read as
|
|
157
|
+
// another identity's canonical or dotted spelling.
|
|
158
|
+
// Code-mode helper spellings never gain a bare alias (#4679 review), whatever namespace
|
|
159
|
+
// declares them and whatever put the alias there. The list is owned by `src/types/tools.ts`,
|
|
160
|
+
// beside the names it protects, because the copy that used to live here drifted to a single
|
|
161
|
+
// namespace and had to be widened twice.
|
|
162
|
+
const bareAliasOwners = new Map<string, string | null>();
|
|
163
|
+
for (const t of authorizedTools) {
|
|
164
|
+
// Bare (no-namespace) declarations participate as owners too: a namespaced tool whose
|
|
165
|
+
// bare name equals a bare-declared function must not gain the bare alias, mirroring how
|
|
166
|
+
// the tool_choice bare path refuses ambiguous owners across the whole request catalog.
|
|
167
|
+
const identity = JSON.stringify([t.namespace ?? null, t.name]);
|
|
168
|
+
const owner = bareAliasOwners.get(t.name);
|
|
169
|
+
if (owner === undefined) bareAliasOwners.set(t.name, identity);
|
|
170
|
+
else if (owner !== identity) bareAliasOwners.set(t.name, null);
|
|
171
|
+
}
|
|
172
|
+
for (const t of authorizedTools) {
|
|
173
|
+
const canonical = namespacedToolName(t.namespace, t.name);
|
|
174
|
+
const owner = bareAliasOwners.get(canonical);
|
|
175
|
+
if (owner !== undefined && owner !== JSON.stringify([t.namespace, t.name])) {
|
|
176
|
+
bareAliasOwners.set(canonical, null);
|
|
177
|
+
}
|
|
178
|
+
const dotted = dottedToolName(t.namespace, t.name);
|
|
179
|
+
const dottedOwner = bareAliasOwners.get(dotted);
|
|
180
|
+
if (dottedOwner !== undefined && dottedOwner !== JSON.stringify([t.namespace, t.name])) {
|
|
181
|
+
bareAliasOwners.set(dotted, null);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
155
184
|
for (const t of authorizedTools) {
|
|
156
185
|
// Upstream output is untrusted: only restore calls for tools the caller authorized.
|
|
157
186
|
const wireName = namespacedToolName(t.namespace, t.name);
|
|
@@ -175,6 +204,30 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
175
204
|
toolNsMap.set(dottedName, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
|
|
176
205
|
if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(dottedName, t.parameters);
|
|
177
206
|
}
|
|
207
|
+
// Bare echo alias (`name` with no namespace spelling, #4679): same tool identity as
|
|
208
|
+
// the flattened wire name, so a provider that drops the namespace prefix still
|
|
209
|
+
// restores against this entry. Ambiguous bare names were resolved to null above;
|
|
210
|
+
// skipping them falls back to the spellings every provider can still echo.
|
|
211
|
+
// Code-mode helper spellings never gain a bare alias, from ANY namespace (#4792 review).
|
|
212
|
+
// Scoping this to `collaboration` was too narrow to be a boundary: a declaration such as
|
|
213
|
+
// `mcp__remote.exec` donated bare `exec` to the declared set, and normalizeDeclaredToolName
|
|
214
|
+
// then rewrote an undeclared `apply_patch`, `exec_command` or `write_stdin` onto it
|
|
215
|
+
// (src/types/tools.ts). No namespace may turn helper normalization on for a catalog that
|
|
216
|
+
// never declared the code-mode shell.
|
|
217
|
+
//
|
|
218
|
+
// The namespaced tool stays usable AS ITSELF: `ns__name` is added unconditionally above
|
|
219
|
+
// and `ns.name` whenever it is unambiguous, so only the namespace-dropping echo fallback
|
|
220
|
+
// is withdrawn, and only for these six spellings.
|
|
221
|
+
if (
|
|
222
|
+
bareAliasOwners.get(t.name) === JSON.stringify([t.namespace, t.name])
|
|
223
|
+
&& !NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)
|
|
224
|
+
) {
|
|
225
|
+
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
|
|
226
|
+
declaredToolNames.add(t.name);
|
|
227
|
+
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
|
|
228
|
+
toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
|
|
229
|
+
if (t.parameters && typeof t.parameters === "object") toolParameterSchemas.set(t.name, t.parameters);
|
|
230
|
+
}
|
|
178
231
|
}
|
|
179
232
|
if (t.freeform) {
|
|
180
233
|
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
|
|
@@ -201,6 +254,20 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
201
254
|
// Some routed providers echo a bare tool_choice selector instead of the flattened catalog
|
|
202
255
|
// name. Accept only selectors the client actually sent and only when the full request catalog
|
|
203
256
|
// contains one tool with that logical name.
|
|
257
|
+
//
|
|
258
|
+
// A helper spelling selected this way is split rather than refused (#4819). The two things a
|
|
259
|
+
// bare alias does are separable, and passthrough already relies on that: identity RESTORATION
|
|
260
|
+
// runs before authorization there, rewriting the echoed bare name to the namespaced identity
|
|
261
|
+
// the caller declared, and the guard then authorizes `ns__name`. DECLARATION is the part that
|
|
262
|
+
// is unsafe, because a declared-name set carrying bare `exec` is what makes
|
|
263
|
+
// `normalizeDeclaredToolName` rewrite an undeclared `apply_patch`, `exec_command` or
|
|
264
|
+
// `write_stdin` onto the selected tool (src/types/tools.ts).
|
|
265
|
+
//
|
|
266
|
+
// So a helper spelling gets the `toolNsMap` entry and not the `declaredToolNames` entry. The
|
|
267
|
+
// caller nominated exactly one tool by name, `bareNameCounts` proves nothing else answers to
|
|
268
|
+
// it, and restoring it authorizes nothing the request did not already declare. The echo path
|
|
269
|
+
// above withholds both, because a bare echo is a guess rather than a nomination and #4679
|
|
270
|
+
// pinned that shape (`tests/responses/bare-echo-alias.test.ts`).
|
|
204
271
|
const choice = parsed.options.toolChoice;
|
|
205
272
|
const bareChoiceNames = new Set(
|
|
206
273
|
choice && typeof choice === "object"
|
|
@@ -213,8 +280,11 @@ export function buildToolBridgeMaps(parsed: OcxParsedRequest, budget?: Translato
|
|
|
213
280
|
}
|
|
214
281
|
for (const t of authorizedTools) {
|
|
215
282
|
if (!t.namespace || !bareChoiceNames.has(t.name) || bareNameCounts.get(t.name) !== 1 || declaredToolNames.has(t.name)) continue;
|
|
216
|
-
|
|
217
|
-
|
|
283
|
+
// Restore the identity; declare the name only when it is not a helper spelling.
|
|
284
|
+
if (!NAMESPACED_BARE_ALIAS_EXCLUDED_NAMES.has(t.name)) {
|
|
285
|
+
budget?.chargeRetained(new TextEncoder().encode(t.name).byteLength, { kind: "retained_collectors" });
|
|
286
|
+
declaredToolNames.add(t.name);
|
|
287
|
+
}
|
|
218
288
|
budget?.chargeRetained(new TextEncoder().encode(JSON.stringify([t.name, t.namespace, t.name])).byteLength, { kind: "retained_collectors" });
|
|
219
289
|
toolNsMap.set(t.name, { namespace: t.namespace, name: t.name, ...(t.freeform ? { freeform: true } : {}) });
|
|
220
290
|
if (t.parameters && typeof t.parameters === "object") {
|
|
@@ -8,14 +8,46 @@ interface ComboRecallEntry {
|
|
|
8
8
|
target: Pick<OcxComboTarget, "provider" | "model">;
|
|
9
9
|
responseModel: string;
|
|
10
10
|
at: number;
|
|
11
|
+
/** UTF-8 size of `responseModel`, the only client-influenced field of unbounded length. */
|
|
12
|
+
bytes: number;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
const RECALL_CAPACITY = 256;
|
|
14
16
|
const RECALL_TTL_MS = 30 * 60 * 1000;
|
|
17
|
+
/**
|
|
18
|
+
* A model id is provider-reported and arrives on the response, so nothing upstream of here
|
|
19
|
+
* bounds its length. Lane keys are already SHA-256 digests, so the model string is the only
|
|
20
|
+
* field that can grow, and 256 lanes alone do not bound the bytes they hold.
|
|
21
|
+
*/
|
|
22
|
+
const RECALL_MODEL_BYTES_MAX = 1024;
|
|
23
|
+
const RECALL_TOTAL_BYTES_MAX = 64 * 1024;
|
|
15
24
|
const recall = new Map<string, ComboRecallEntry>();
|
|
25
|
+
let recallBytes = 0;
|
|
16
26
|
let lastReconciledGeneration = 0;
|
|
17
27
|
let liveOwners: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames"> | undefined;
|
|
18
28
|
|
|
29
|
+
/** Every removal path goes through here so the byte counter can never drift from the map. */
|
|
30
|
+
function deleteEntry(lane: string): boolean {
|
|
31
|
+
const entry = recall.get(lane);
|
|
32
|
+
if (!entry) return false;
|
|
33
|
+
recall.delete(lane);
|
|
34
|
+
recallBytes -= entry.bytes;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* UTF-8 size of a remembered model id, or null when it is too large to retain.
|
|
40
|
+
*
|
|
41
|
+
* The code-unit test runs first and is the part that matters: a UTF-8 encoding is never smaller
|
|
42
|
+
* than the code-unit count, so an oversized string is rejected without encoding it, and the
|
|
43
|
+
* bound cannot be defeated by paying the allocation it exists to prevent.
|
|
44
|
+
*/
|
|
45
|
+
function boundedModelBytes(responseModel: string): number | null {
|
|
46
|
+
if (responseModel.length > RECALL_MODEL_BYTES_MAX) return null;
|
|
47
|
+
const bytes = Buffer.byteLength(responseModel, "utf8");
|
|
48
|
+
return bytes > RECALL_MODEL_BYTES_MAX ? null : bytes;
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
function ownsEntry(context: Pick<GenerationContext, "comboIds" | "comboTargets" | "providerNames">, entry: ComboRecallEntry): boolean {
|
|
20
52
|
return context.comboIds.has(entry.comboId)
|
|
21
53
|
&& context.providerNames.has(entry.target.provider)
|
|
@@ -32,14 +64,29 @@ export function rememberComboForLane(
|
|
|
32
64
|
if (!lane || !comboId || !responseModel.trim()) return;
|
|
33
65
|
// Reject even a same-named recreated owner: its previous in-flight turn is obsolete.
|
|
34
66
|
if (writerGeneration < Math.max(lastReconciledGeneration, captureConfigGeneration())) return;
|
|
35
|
-
|
|
67
|
+
// An unretainable model id DECLINES the write; it must not clear the lane. Every other
|
|
68
|
+
// rejection above returns the same way, and clearing here would let a late completion erase
|
|
69
|
+
// a newer selection that this function has no ordering information to compare against.
|
|
70
|
+
const bytes = boundedModelBytes(responseModel);
|
|
71
|
+
if (bytes === null) return;
|
|
72
|
+
const entry = {
|
|
73
|
+
comboId,
|
|
74
|
+
target: { provider: target.provider, model: target.model },
|
|
75
|
+
responseModel,
|
|
76
|
+
at: Date.now(),
|
|
77
|
+
bytes,
|
|
78
|
+
};
|
|
36
79
|
if (liveOwners && !ownsEntry(liveOwners, entry)) return;
|
|
37
|
-
|
|
80
|
+
deleteEntry(lane);
|
|
38
81
|
recall.set(lane, entry);
|
|
39
|
-
|
|
82
|
+
recallBytes += bytes;
|
|
83
|
+
// Insertion order is recency order, because every write re-inserts its lane at the back.
|
|
84
|
+
// Evicting from the front therefore drops the least recently written lane, never this one:
|
|
85
|
+
// a single entry is capped well below the aggregate budget, so it always fits.
|
|
86
|
+
while (recall.size > RECALL_CAPACITY || recallBytes > RECALL_TOTAL_BYTES_MAX) {
|
|
40
87
|
const oldest = recall.keys().next().value;
|
|
41
|
-
if (oldest === undefined) break;
|
|
42
|
-
|
|
88
|
+
if (oldest === undefined || oldest === lane) break;
|
|
89
|
+
deleteEntry(oldest);
|
|
43
90
|
}
|
|
44
91
|
}
|
|
45
92
|
|
|
@@ -57,12 +104,25 @@ export function recallComboForLane(
|
|
|
57
104
|
|| !Object.hasOwn(config.providers, entry.target.provider)
|
|
58
105
|
|| !provider || provider.disabled === true
|
|
59
106
|
|| !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
|
|
60
|
-
|
|
107
|
+
deleteEntry(lane);
|
|
61
108
|
return undefined;
|
|
62
109
|
}
|
|
63
110
|
return entry.responseModel === model ? entry.comboId : undefined;
|
|
64
111
|
}
|
|
65
112
|
|
|
113
|
+
/**
|
|
114
|
+
* Periodic expiry. Without it a lane that is never read again and never touched by a config
|
|
115
|
+
* reconciliation holds its entry for the life of the process: the existing TTL is only
|
|
116
|
+
* evaluated on read or on generation change.
|
|
117
|
+
*/
|
|
118
|
+
export function sweepExpiredComboRecall(now: number): number {
|
|
119
|
+
let removed = 0;
|
|
120
|
+
for (const [lane, entry] of recall) {
|
|
121
|
+
if (now - entry.at >= RECALL_TTL_MS && deleteEntry(lane)) removed += 1;
|
|
122
|
+
}
|
|
123
|
+
return removed;
|
|
124
|
+
}
|
|
125
|
+
|
|
66
126
|
export function reconcileComboRecall(context: GenerationContext): number {
|
|
67
127
|
if (context.generation <= lastReconciledGeneration) return 0;
|
|
68
128
|
lastReconciledGeneration = context.generation;
|
|
@@ -74,8 +134,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
|
|
|
74
134
|
let removed = 0;
|
|
75
135
|
for (const [lane, entry] of recall) {
|
|
76
136
|
if (!ownsEntry(context, entry) || Date.now() - entry.at >= RECALL_TTL_MS) {
|
|
77
|
-
|
|
78
|
-
removed += 1;
|
|
137
|
+
if (deleteEntry(lane)) removed += 1;
|
|
79
138
|
}
|
|
80
139
|
}
|
|
81
140
|
return removed;
|
|
@@ -84,6 +143,7 @@ export function reconcileComboRecall(context: GenerationContext): number {
|
|
|
84
143
|
/** Test-only reset, alongside the combo rotation/cooldown resets. */
|
|
85
144
|
export function clearComboRecallForTests(): void {
|
|
86
145
|
recall.clear();
|
|
146
|
+
recallBytes = 0;
|
|
87
147
|
lastReconciledGeneration = 0;
|
|
88
148
|
liveOwners = undefined;
|
|
89
149
|
}
|