@bitkyc08/opencodex 2.35.0 → 2.36.0-preview.20260830
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/gui/dist/assets/index-Cy7Z_pl0.css +1 -0
- package/gui/dist/assets/index-DPl4nBMA.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/AGENTS.md +2 -1
- package/src/adapters/agentrouter.ts +50 -0
- package/src/adapters/anthropic.ts +1 -51
- package/src/adapters/cursor/call-id.ts +76 -8
- package/src/adapters/cursor/checkpoint-store.ts +6 -1
- package/src/adapters/cursor/cursor-errors.ts +44 -0
- package/src/adapters/cursor/native-exec.ts +13 -0
- package/src/adapters/cursor/protobuf-request.ts +651 -29
- package/src/adapters/cursor/tool-result-normalize.ts +3 -3
- package/src/adapters/cursor/transport-retry.ts +5 -1
- package/src/adapters/cursor.ts +15 -1
- package/src/adapters/empty-tool-output-annotation.ts +43 -0
- package/src/adapters/exec-tool-result-normalize.ts +70 -5
- package/src/adapters/google.ts +22 -2
- package/src/adapters/kiro.ts +26 -2
- package/src/adapters/ollama-native-url.ts +111 -0
- package/src/adapters/ollama-native.ts +1131 -0
- package/src/adapters/openai-chat.ts +30 -7
- package/src/adapters/openai-responses.ts +72 -4
- package/src/adapters/registry.ts +7 -0
- package/src/adapters/xai-web-search.ts +58 -0
- package/src/claude/desktop-3p.ts +21 -1
- package/src/claude/desktop-policy.ts +149 -0
- package/src/cli/account.ts +16 -2
- package/src/cli/claude-desktop.ts +13 -3
- package/src/cli/combo.ts +8 -5
- package/src/cli/doctor.ts +77 -11
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +16 -0
- package/src/cli/models.ts +20 -3
- package/src/cli/registry.ts +2 -1
- package/src/cli/status.ts +140 -2
- package/src/cli/storage.ts +10 -1
- package/src/codex/account-runtime-state.ts +39 -5
- package/src/codex/account-store.ts +393 -13
- package/src/codex/account-usability.ts +11 -4
- package/src/codex/app-server-processes.ts +46 -5
- package/src/codex/auth-context.ts +160 -32
- package/src/codex/catalog/bundled.ts +7 -5
- package/src/codex/catalog/metadata.ts +1 -1
- package/src/codex/catalog/parsing.ts +57 -1
- package/src/codex/catalog/provider-fetch.ts +61 -4
- package/src/codex/catalog/sync.ts +4 -3
- package/src/codex/convergence.ts +3 -2
- package/src/codex/data/upstream-models.json +40 -8
- package/src/codex/inject-coordination.ts +111 -14
- package/src/codex/integration-record.ts +12 -2
- package/src/codex/main-account.ts +225 -1
- package/src/codex/model-entitlements.ts +339 -27
- package/src/codex/prompt-layers.ts +346 -7
- package/src/codex/prompt-text-probe.ts +272 -21
- package/src/codex/routing.ts +693 -132
- package/src/codex/runtime.ts +12 -0
- package/src/codex/subagent-model-fallback.ts +62 -24
- package/src/codex/user-identity.ts +33 -25
- package/src/combos/index.ts +1 -0
- package/src/combos/reset-window.ts +46 -0
- package/src/combos/resolve.ts +84 -2
- package/src/combos/types.ts +5 -2
- package/src/config/atomic-write.ts +104 -22
- package/src/config/provider-validation.ts +11 -0
- package/src/config.ts +75 -3
- package/src/generated/compatibility-version.json +207 -131
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/catalog.ts +71 -0
- package/src/grok/effort.ts +83 -0
- package/src/grok/inject.ts +952 -127
- package/src/grok/models.ts +56 -0
- package/src/grok/status.ts +21 -8
- package/src/grok/sync.ts +10 -18
- package/src/images/loop.ts +6 -3
- package/src/integrations/native/ownership-preflight.ts +4 -1
- package/src/lab/fabric/producer-isolate.ts +36 -3
- package/src/lib/destination-policy.ts +93 -7
- package/src/lib/redact.ts +6 -1
- package/src/lib/shadow-call.ts +38 -3
- package/src/lib/test-home-guard.ts +18 -3
- package/src/lib/upstream-retry.ts +43 -6
- package/src/lib/windows-secret-acl.ts +66 -0
- package/src/lib/windows-text.ts +28 -2
- package/src/lib/windows-user-principal.ts +35 -23
- package/src/oauth/account-quota-rank.ts +107 -0
- package/src/oauth/anthropic-routing.ts +125 -30
- package/src/oauth/chatgpt.ts +5 -1
- package/src/oauth/generic-account-failover.ts +114 -7
- package/src/oauth/index.ts +15 -8
- package/src/oauth/store.ts +16 -0
- package/src/providers/account-quota-disk.ts +79 -0
- package/src/providers/command-code-efforts.ts +24 -0
- package/src/providers/derive.ts +6 -0
- package/src/providers/key-failover.ts +33 -1
- package/src/providers/kiro-usage.ts +272 -0
- package/src/providers/ollama-show.ts +311 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota-routing-cache.ts +32 -0
- package/src/providers/quota-types.ts +36 -0
- package/src/providers/quota-wire.ts +102 -0
- package/src/providers/quota.ts +208 -147
- package/src/providers/registry.ts +68 -8
- package/src/providers/slug-codec.ts +12 -4
- package/src/providers/vercel-gateway-routing.ts +108 -0
- package/src/router.ts +22 -12
- package/src/server/auth-cors.ts +26 -0
- package/src/server/catalog-download.ts +73 -0
- package/src/server/chat-native.ts +12 -2
- package/src/server/gui-static.ts +4 -1
- package/src/server/index.ts +132 -9
- package/src/server/management/agent-settings-routes.ts +38 -5
- package/src/server/management/codex-prompt-routes.ts +7 -1
- package/src/server/management/combo-routes.ts +10 -1
- package/src/server/management/config-routes.ts +9 -1
- package/src/server/management/context.ts +5 -0
- package/src/server/management/model-routes.ts +16 -6
- package/src/server/management/native-integration-routes.ts +12 -17
- package/src/server/management/oauth-account-routes.ts +13 -0
- package/src/server/management/provider-routes.ts +32 -5
- package/src/server/management/routing-profile-routes.ts +15 -0
- package/src/server/management/shadow-call-validation.ts +29 -0
- package/src/server/management-api.ts +7 -3
- package/src/server/request-log.ts +3 -5
- package/src/server/responses/agent-task-recovery-cache.ts +8 -0
- package/src/server/responses/agent-task-recovery.ts +52 -20
- package/src/server/responses/codex-auth-error.ts +26 -0
- package/src/server/responses/compact.ts +345 -10
- package/src/server/responses/core.ts +736 -108
- package/src/server/responses/empty-completion-guard.ts +16 -0
- package/src/server/responses/fetch-helpers.ts +42 -0
- package/src/server/responses/policy-fallback.ts +11 -6
- package/src/server/responses-undeclared-tool-guard.ts +16 -3
- package/src/server/startup-health-cache.ts +59 -13
- package/src/service-manager-probe.ts +115 -9
- package/src/service.ts +139 -40
- package/src/storage/cleanup.ts +10 -0
- package/src/storage/storage-mutation-coordinator.ts +14 -3
- package/src/tray/windows-tray.ps1 +10 -4
- package/src/tray/windows.ts +30 -2
- package/src/types/config.ts +27 -14
- package/src/types/provider.ts +54 -0
- package/src/types/tools.ts +13 -3
- package/src/types.ts +4 -0
- package/src/usage/summary.ts +421 -177
- package/src/vision/anthropic-describe.ts +3 -3
- package/src/vision/describe.ts +5 -3
- package/src/web-search/anthropic-executor.ts +9 -2
- package/src/web-search/exa-executor.ts +3 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/gemini-executor.ts +3 -3
- package/src/web-search/loop.ts +11 -3
- package/src/web-search/xai-executor.ts +3 -3
- package/gui/dist/assets/index-DNdRKXK9.js +0 -112
- package/gui/dist/assets/index-DQ-Ie18T.css +0 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
filterCatalogVisibleModels,
|
|
3
|
+
nativeContextLimits,
|
|
4
|
+
nativeDefaultReasoningEffort,
|
|
5
|
+
nativeOpenAiContextWindow,
|
|
6
|
+
nativeReasoningEfforts,
|
|
7
|
+
visibleNativeSlugs,
|
|
8
|
+
type CatalogModel,
|
|
9
|
+
} from "../codex/catalog";
|
|
10
|
+
import type { OcxConfig } from "../types";
|
|
11
|
+
import type { GrokInjectModel } from "./inject";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Catalog → inject payload used by both `syncGrokConfig` and the dashboard enable
|
|
15
|
+
* path. Native rows carry the pinned ladder; routed rows carry
|
|
16
|
+
* `reasoningEfforts` / `defaultReasoningEffort`. The writer sanitizes Grok-invalid
|
|
17
|
+
* rungs — this function forwards the same lists `/v1/models` already advertises.
|
|
18
|
+
*/
|
|
19
|
+
export function buildGrokInjectModels(
|
|
20
|
+
config: Pick<OcxConfig, "disabledModels" | "combos" | "providers" | "providerContextCaps">,
|
|
21
|
+
routed: CatalogModel[],
|
|
22
|
+
): GrokInjectModel[] {
|
|
23
|
+
const contextLimits = nativeContextLimits(config);
|
|
24
|
+
return [
|
|
25
|
+
...visibleNativeSlugs(config).map(id => {
|
|
26
|
+
const contextWindow = nativeOpenAiContextWindow(id, contextLimits);
|
|
27
|
+
const reasoningEfforts = nativeReasoningEfforts(id);
|
|
28
|
+
const defaultReasoningEffort = nativeDefaultReasoningEffort(id);
|
|
29
|
+
return {
|
|
30
|
+
id,
|
|
31
|
+
...(contextWindow !== undefined ? { contextWindow } : {}),
|
|
32
|
+
...(reasoningEfforts.length > 0 ? { reasoningEfforts } : {}),
|
|
33
|
+
...(defaultReasoningEffort !== undefined ? { defaultReasoningEffort } : {}),
|
|
34
|
+
};
|
|
35
|
+
}),
|
|
36
|
+
...routed.map(model => {
|
|
37
|
+
const efforts = model.reasoningEfforts ?? [];
|
|
38
|
+
return {
|
|
39
|
+
id: model.alias ?? `${model.provider}/${model.id}`,
|
|
40
|
+
...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}),
|
|
41
|
+
...(efforts.length > 0 ? { reasoningEfforts: efforts } : {}),
|
|
42
|
+
...(model.defaultReasoningEffort !== undefined
|
|
43
|
+
? { defaultReasoningEffort: model.defaultReasoningEffort }
|
|
44
|
+
: {}),
|
|
45
|
+
};
|
|
46
|
+
}),
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Visible routed catalog, then the shared inject list. */
|
|
51
|
+
export function grokInjectModelsFromCatalog(
|
|
52
|
+
config: OcxConfig,
|
|
53
|
+
catalog: CatalogModel[],
|
|
54
|
+
): GrokInjectModel[] {
|
|
55
|
+
return buildGrokInjectModels(config, filterCatalogVisibleModels(catalog, config));
|
|
56
|
+
}
|
package/src/grok/status.ts
CHANGED
|
@@ -65,22 +65,35 @@ export function readGrokStatus(opts: { grokHome?: string } = {}): GrokStatus {
|
|
|
65
65
|
let baseUrl: string | null = null;
|
|
66
66
|
let current: GrokStatusModel | null = null;
|
|
67
67
|
|
|
68
|
+
// The provider block carries base_url in the current shape; per-model base_url is the
|
|
69
|
+
// legacy fallback for fences written before the model_providers migration.
|
|
70
|
+
let inProviderBlock = false;
|
|
71
|
+
|
|
68
72
|
for (const rawLine of region.split("\n")) {
|
|
69
73
|
const line = rawLine.trim();
|
|
74
|
+
const providerHeader = /^\[model_providers\.([^\]]+)\]$/.exec(line);
|
|
75
|
+
if (providerHeader) {
|
|
76
|
+
inProviderBlock = true;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
70
79
|
const header = /^\[model\.([^\]]+)\]$/.exec(line);
|
|
71
80
|
if (header) {
|
|
81
|
+
inProviderBlock = false;
|
|
72
82
|
current = { alias: header[1]!, id: "" };
|
|
73
83
|
models.push(current);
|
|
74
84
|
continue;
|
|
75
85
|
}
|
|
76
|
-
if (
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (
|
|
86
|
+
if (line.startsWith("base_url =")) {
|
|
87
|
+
// Prefer the provider block's base_url; fall back to per-model (legacy shape).
|
|
88
|
+
if (inProviderBlock) baseUrl ??= tomlStringValue(line) ?? null;
|
|
89
|
+
else if (current && baseUrl === null) baseUrl = tomlStringValue(line) ?? null;
|
|
90
|
+
} else if (!inProviderBlock && current) {
|
|
91
|
+
if (line.startsWith("model =")) {
|
|
92
|
+
current.id = tomlStringValue(line) ?? "";
|
|
93
|
+
} else if (line.startsWith("context_window =")) {
|
|
94
|
+
const value = Number(line.slice(line.indexOf("=") + 1).trim());
|
|
95
|
+
if (Number.isFinite(value) && value > 0) current.contextWindow = value;
|
|
96
|
+
}
|
|
84
97
|
}
|
|
85
98
|
}
|
|
86
99
|
|
package/src/grok/sync.ts
CHANGED
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Deps are injectable (mirrors src/codex/sync.ts) so tests can run without a live proxy.
|
|
8
8
|
*/
|
|
9
|
-
import {
|
|
9
|
+
import type { CatalogModel } from "../codex/catalog";
|
|
10
10
|
import type { OcxConfig } from "../types";
|
|
11
|
-
import {
|
|
11
|
+
import { projectGrokCatalog } from "./catalog";
|
|
12
|
+
import { injectGrokConfig, type GrokInjectResult } from "./inject";
|
|
12
13
|
|
|
13
14
|
export interface GrokSyncDeps {
|
|
14
15
|
fetchAllModels: (config: OcxConfig) => Promise<CatalogModel[]>;
|
|
@@ -32,22 +33,10 @@ export async function syncGrokConfig(
|
|
|
32
33
|
opts: { hostname?: string; grokHome?: string } = {},
|
|
33
34
|
deps: GrokSyncDeps = { fetchAllModels: defaultFetchAllModels, injectGrokConfig },
|
|
34
35
|
): Promise<GrokInjectResult> {
|
|
35
|
-
let
|
|
36
|
+
let projection: ReturnType<typeof projectGrokCatalog>;
|
|
36
37
|
try {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
// Native slugs carry their context window too. Without it Grok falls back to its own
|
|
40
|
-
// default (200k) and understates models like gpt-5.6-sol, which is 372k. This is the same
|
|
41
|
-
// accessor the dashboard's native rows use, so the two cannot disagree.
|
|
42
|
-
...visibleNativeSlugs(config).map(id => {
|
|
43
|
-
const contextWindow = nativeOpenAiContextWindow(id, nativeContextLimits(config));
|
|
44
|
-
return { id, ...(contextWindow !== undefined ? { contextWindow } : {}) };
|
|
45
|
-
}),
|
|
46
|
-
...routed.map(m => ({
|
|
47
|
-
id: m.alias ?? `${m.provider}/${m.id}`,
|
|
48
|
-
...(m.contextWindow !== undefined ? { contextWindow: m.contextWindow } : {}),
|
|
49
|
-
})),
|
|
50
|
-
];
|
|
38
|
+
const allRouted = await deps.fetchAllModels(config);
|
|
39
|
+
projection = projectGrokCatalog(allRouted, config);
|
|
51
40
|
} catch (err) {
|
|
52
41
|
return {
|
|
53
42
|
ok: false,
|
|
@@ -58,9 +47,12 @@ export async function syncGrokConfig(
|
|
|
58
47
|
// Pass the FULL list plus the exclusion set: the writer allocates aliases over
|
|
59
48
|
// everything and emits only what is switched on, so a model's alias never depends on
|
|
60
49
|
// its neighbours' switches. Absent/empty selection keeps today's behaviour exactly.
|
|
61
|
-
return deps.injectGrokConfig(port, models, {
|
|
50
|
+
return deps.injectGrokConfig(port, projection.models, {
|
|
62
51
|
...(opts.hostname !== undefined ? { hostname: opts.hostname } : {}),
|
|
63
52
|
...(opts.grokHome !== undefined ? { grokHome: opts.grokHome } : {}),
|
|
64
53
|
excluded: new Set(config.grokExcludedModels ?? []),
|
|
54
|
+
catalogModelIds: projection.catalogModelIds,
|
|
55
|
+
disabledProviderNamespaces: projection.disabledProviderNamespaces,
|
|
56
|
+
comboPublicModelIds: projection.comboPublicModelIds,
|
|
65
57
|
});
|
|
66
58
|
}
|
package/src/images/loop.ts
CHANGED
|
@@ -21,7 +21,7 @@ import type { AttemptRecoveryKind } from "../usage/log";
|
|
|
21
21
|
import { bridgeToResponsesSSE } from "../bridge";
|
|
22
22
|
import { clearableDeadline, idleDeadline } from "../lib/abort";
|
|
23
23
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
24
|
-
import { fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
|
|
24
|
+
import { applyUpstreamRecoveryInit, fetchWithResetRetry, prepareSameTarget429Wait } from "../lib/upstream-retry";
|
|
25
25
|
import { rateLimitRetryDelayMs } from "../providers/key-failover";
|
|
26
26
|
import {
|
|
27
27
|
isTranslatorBudgetExceededError,
|
|
@@ -521,12 +521,15 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
|
|
|
521
521
|
deps.onAttemptSend?.(retryRecovery ?? recovery);
|
|
522
522
|
const h = new Headers(request.headers);
|
|
523
523
|
if (!h.has("accept-encoding")) h.set("accept-encoding", "identity");
|
|
524
|
-
|
|
524
|
+
// Same reset-recovery parity as the web-search loop: the replay needs
|
|
525
|
+
// `keepalive: false` to abandon the pooled socket, because Bun has ignored the
|
|
526
|
+
// hop-by-hop header alone (oven-sh/bun#20492).
|
|
527
|
+
return fetchImpl(request.url, applyUpstreamRecoveryInit({
|
|
525
528
|
method: request.method,
|
|
526
529
|
headers: h,
|
|
527
530
|
body: request.body,
|
|
528
531
|
signal: headerDeadline.signal,
|
|
529
|
-
});
|
|
532
|
+
}, retryRecovery));
|
|
530
533
|
},
|
|
531
534
|
{ abortSignal: headerDeadline.signal, label: "image-bridge-loop" },
|
|
532
535
|
);
|
|
@@ -22,11 +22,14 @@ import {
|
|
|
22
22
|
type ServiceStateEvidence,
|
|
23
23
|
} from "../../service";
|
|
24
24
|
import {
|
|
25
|
+
createWindowsTaskListingCache,
|
|
25
26
|
inspectServiceManagerInstallation,
|
|
26
27
|
type ProbeDeps,
|
|
27
28
|
type ServiceManagerClaim,
|
|
28
29
|
} from "../../service-manager-probe";
|
|
29
30
|
|
|
31
|
+
export { createWindowsTaskListingCache };
|
|
32
|
+
|
|
30
33
|
export type NativeTeardownOwnership = { ok: true } | { ok: false; message: string };
|
|
31
34
|
|
|
32
35
|
export function assertNativeTeardownOwned(): NativeTeardownOwnership {
|
|
@@ -199,4 +202,4 @@ export function inspectNativeCodexOwnership(deps: OwnershipDeps = {}): Ownership
|
|
|
199
202
|
return valid.length === 0
|
|
200
203
|
? { ownership: "owned", reason: "no service state and no service manager claim" }
|
|
201
204
|
: { ownership: "owned", reason: "the recorded service state names these homes" };
|
|
202
|
-
}
|
|
205
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { ensureRestrictedDir } from "../paths";
|
|
4
5
|
import { FABRIC_LIMITS } from "./constants";
|
|
5
6
|
import {
|
|
6
7
|
FABRIC_PRODUCER_PROTOCOL_MAX_BYTES,
|
|
@@ -31,12 +32,44 @@ interface IsolateRequest {
|
|
|
31
32
|
now?: () => number;
|
|
32
33
|
}
|
|
33
34
|
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
/**
|
|
36
|
+
* The environment an isolated producer child runs with.
|
|
37
|
+
*
|
|
38
|
+
* Exported so a test that spawns `producer-child.ts` directly cannot drift from
|
|
39
|
+
* the environment production actually uses. The Windows loader state and the
|
|
40
|
+
* scratch-owned temp paths below are load-bearing, and a test carrying its own
|
|
41
|
+
* literal copy of this object silently loses them.
|
|
42
|
+
*/
|
|
43
|
+
export function minimalFabricChildEnv(scratchRoot: string): Record<string, string> {
|
|
44
|
+
const childTempDir = join(scratchRoot, ".tmp");
|
|
45
|
+
ensureRestrictedDir(childTempDir, scratchRoot);
|
|
46
|
+
const env: Record<string, string> = {
|
|
36
47
|
TZ: "UTC",
|
|
37
48
|
NO_COLOR: "1",
|
|
38
49
|
OCX_FABRIC_SCRATCH_ROOT: scratchRoot,
|
|
50
|
+
// Executors commonly use os.tmpdir() through libraries they import. Keep
|
|
51
|
+
// those writes inside the same scratch boundary instead of forwarding the
|
|
52
|
+
// user's ambient temp directory (Windows) or falling back to /tmp (POSIX).
|
|
53
|
+
TEMP: childTempDir,
|
|
54
|
+
TMP: childTempDir,
|
|
55
|
+
TMPDIR: childTempDir,
|
|
39
56
|
};
|
|
57
|
+
if (process.platform !== "win32") return env;
|
|
58
|
+
// Windows has no equivalent of "run with an (almost) empty environment". A
|
|
59
|
+
// CreateProcess child inherits nothing here, and the loader itself reads the
|
|
60
|
+
// environment: without SystemRoot it cannot resolve the system DLLs the Bun
|
|
61
|
+
// executable links against, so the child dies before its entry module runs.
|
|
62
|
+
// The parent then sees an immediate non-zero close with no protocol line and
|
|
63
|
+
// reports harness_failure -- which is what turned every CL-07 producer case
|
|
64
|
+
// into "inconclusive" on the Windows leg while POSIX stayed green.
|
|
65
|
+
//
|
|
66
|
+
// These are OS-owned loader state, not caller-supplied configuration. Temp
|
|
67
|
+
// state is deliberately not forwarded; it is rooted in scratch above.
|
|
68
|
+
for (const name of ["SystemRoot", "windir"] as const) {
|
|
69
|
+
const value = process.env[name];
|
|
70
|
+
if (value) env[name] = value;
|
|
71
|
+
}
|
|
72
|
+
return env;
|
|
40
73
|
}
|
|
41
74
|
|
|
42
75
|
function killChild(child: ChildProcess): void {
|
|
@@ -56,7 +89,7 @@ export async function runIsolatedFabricProducer(request: IsolateRequest): Promis
|
|
|
56
89
|
let child: ChildProcess;
|
|
57
90
|
try {
|
|
58
91
|
child = spawn(process.execPath, ["run", CHILD_ENTRY], {
|
|
59
|
-
env:
|
|
92
|
+
env: minimalFabricChildEnv(request.scratchRoot),
|
|
60
93
|
stdio: ["pipe", "pipe", "pipe"],
|
|
61
94
|
});
|
|
62
95
|
} catch (error) {
|
|
@@ -68,6 +68,83 @@ function classifyIpv4(hostname: string): DestinationAssessment {
|
|
|
68
68
|
return { kind: "public", detail: "public IP" };
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
/**
|
|
72
|
+
* Expand an IPv6 literal into its eight hextets, or null when it is not one this can parse.
|
|
73
|
+
* `firstIpv6Hextet` below only needs the leading group; prefix matching needs the whole address,
|
|
74
|
+
* and `::` compression plus the RFC 4291 trailing dotted-quad form both have to be handled.
|
|
75
|
+
*/
|
|
76
|
+
function ipv6Hextets(hostname: string): number[] | null {
|
|
77
|
+
let text = hostname;
|
|
78
|
+
const dotted = text.match(/(\d{1,3}(?:\.\d{1,3}){3})$/);
|
|
79
|
+
if (dotted?.index !== undefined) {
|
|
80
|
+
const octets = dotted[1].split(".").map(Number);
|
|
81
|
+
if (octets.some(octet => !Number.isInteger(octet) || octet < 0 || octet > 255)) return null;
|
|
82
|
+
text = text.slice(0, dotted.index)
|
|
83
|
+
+ ((octets[0]! << 8) | octets[1]!).toString(16)
|
|
84
|
+
+ ":"
|
|
85
|
+
+ ((octets[2]! << 8) | octets[3]!).toString(16);
|
|
86
|
+
}
|
|
87
|
+
const halves = text.split("::");
|
|
88
|
+
if (halves.length > 2) return null;
|
|
89
|
+
const parseGroups = (part: string): number[] | null => {
|
|
90
|
+
if (!part) return [];
|
|
91
|
+
const out: number[] = [];
|
|
92
|
+
for (const piece of part.split(":")) {
|
|
93
|
+
if (!/^[0-9a-f]{1,4}$/i.test(piece)) return null;
|
|
94
|
+
out.push(Number.parseInt(piece, 16));
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
};
|
|
98
|
+
const head = parseGroups(halves[0] ?? "");
|
|
99
|
+
const tail = halves.length === 2 ? parseGroups(halves[1] ?? "") : [];
|
|
100
|
+
if (!head || !tail) return null;
|
|
101
|
+
if (halves.length === 1) return head.length === 8 ? head : null;
|
|
102
|
+
const fill = 8 - head.length - tail.length;
|
|
103
|
+
if (fill < 1) return null;
|
|
104
|
+
return [...head, ...Array<number>(fill).fill(0), ...tail];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** RFC 6052 §2.1 well-known NAT64 prefix, 64:ff9b::/96, as its six leading hextets. */
|
|
108
|
+
const NAT64_WELL_KNOWN_PREFIX = [0x64, 0xff9b, 0, 0, 0, 0] as const;
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* `0:0:0:0:ffff:0::/96` — the explicit-zero spelling of a mapped IPv4 that some DNS resolvers
|
|
112
|
+
* return, e.g. `::ffff:0:c612:1b` for `198.18.0.27`.
|
|
113
|
+
*
|
|
114
|
+
* This is deliberately NOT taught to `classifyIpv6`. Under RFC 4291 the mapped prefix is
|
|
115
|
+
* `::ffff:0:0/96`, so `::ffff:0:c612:1b` is a reserved address whose tail merely LOOKS like an
|
|
116
|
+
* IPv4 — it is not equivalent to `198.18.0.27`. Treating the two as equal in the general
|
|
117
|
+
* classifier would admit `::ffff:0:5db8:d822` (tail `93.184.216.34`) as a public destination,
|
|
118
|
+
* which is the merge blocker a maintainer raised on #2812.
|
|
119
|
+
*
|
|
120
|
+
* The reported symptom is narrower than that equivalence: on a fake-IP resolver the answer
|
|
121
|
+
* assesses as `non-global address`, so the `allowBenchmarkAddresses` exception — which exists
|
|
122
|
+
* precisely for Clash/Surge/Mihomo fake-IP — could never be reached for this spelling. The fix
|
|
123
|
+
* therefore lives inside that opt-in, and only for a tail that is itself in `198.18.0.0/15`.
|
|
124
|
+
*/
|
|
125
|
+
const EXPLICIT_ZERO_MAPPED_PREFIX = [0, 0, 0, 0, 0xffff, 0] as const;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* True when this DNS answer may pass the `allowBenchmarkAddresses` opt-in.
|
|
129
|
+
*
|
|
130
|
+
* Ordinary benchmark answers (IPv4 `198.18/19`, canonical `::ffff:198.18.0.27`, and the NAT64
|
|
131
|
+
* form) already carry `detail: "benchmark address"` and pass through the first branch. The
|
|
132
|
+
* second branch adds ONLY the explicit-zero spelling, and only when its embedded quad is itself
|
|
133
|
+
* a benchmark address — so a public, loopback, private, or metadata-looking tail is refused.
|
|
134
|
+
*/
|
|
135
|
+
function isBenchmarkDnsAnswer(address: string, assessment: DestinationAssessment | null): boolean {
|
|
136
|
+
if (assessment?.kind === "private" && assessment.detail === "benchmark address") return true;
|
|
137
|
+
if (isIP(address) !== 6) return false;
|
|
138
|
+
if (assessment?.kind !== "private" || assessment.detail !== "non-global address") return false;
|
|
139
|
+
const hextets = ipv6Hextets(normalizeHostname(address));
|
|
140
|
+
if (!hextets) return false;
|
|
141
|
+
if (!EXPLICIT_ZERO_MAPPED_PREFIX.every((group, index) => hextets[index] === group)) return false;
|
|
142
|
+
const hi = hextets[6]!;
|
|
143
|
+
const lo = hextets[7]!;
|
|
144
|
+
const embedded = classifyIpv4(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`);
|
|
145
|
+
return embedded.kind === "private" && embedded.detail === "benchmark address";
|
|
146
|
+
}
|
|
147
|
+
|
|
71
148
|
function firstIpv6Hextet(hostname: string): number | null {
|
|
72
149
|
const head = hostname.split(":")[0];
|
|
73
150
|
if (!head) return 0;
|
|
@@ -89,6 +166,20 @@ function classifyIpv6(hostname: string): DestinationAssessment {
|
|
|
89
166
|
const ipv4 = `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;
|
|
90
167
|
return classifyIpv4(ipv4);
|
|
91
168
|
}
|
|
169
|
+
// NAT64 (RFC 6052): on an IPv6-only/DNS64 network every IPv4-only peer is synthesized into
|
|
170
|
+
// 64:ff9b::<ipv4>, whose leading hextet (0x64) is below the 2000::/3 global-unicast window and
|
|
171
|
+
// so fell through to "non-global address". That rejected ordinary public destinations for any
|
|
172
|
+
// user behind NAT64 — two tests already worked around it with `allowPrivateNetwork: true`.
|
|
173
|
+
// Classify the EMBEDDED IPv4 instead, exactly as the ::ffff: forms above do, so a wrapped
|
|
174
|
+
// 127.0.0.1 or 10/8 stays blocked rather than becoming an SSRF bypass. Only the well-known
|
|
175
|
+
// prefix is decoded; RFC 8215's 64:ff9b:1::/48 is reserved for local-use translation and keeps
|
|
176
|
+
// its non-global treatment.
|
|
177
|
+
const hextets = ipv6Hextets(hostname);
|
|
178
|
+
if (hextets && NAT64_WELL_KNOWN_PREFIX.every((group, index) => hextets[index] === group)) {
|
|
179
|
+
const hi = hextets[6]!;
|
|
180
|
+
const lo = hextets[7]!;
|
|
181
|
+
return classifyIpv4(`${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`);
|
|
182
|
+
}
|
|
92
183
|
if (hostname === "::1") return { kind: "loopback", detail: "loopback address" };
|
|
93
184
|
if (hostname === "::") return { kind: "unspecified", detail: "unspecified address" };
|
|
94
185
|
const hextet = firstIpv6Hextet(hostname);
|
|
@@ -250,11 +341,7 @@ export async function providerDestinationResolvedError(
|
|
|
250
341
|
const assessment = ipKind === 4 ? classifyIpv4(address) : ipKind === 6 ? classifyIpv6(normalizeHostname(address)) : null;
|
|
251
342
|
if (!assessment || assessment.kind === "public") continue;
|
|
252
343
|
// Clash fake-IP only: 198.18/19 benchmark detail. Mixed dangerous sets still reject.
|
|
253
|
-
if (
|
|
254
|
-
options?.allowBenchmarkAddresses
|
|
255
|
-
&& assessment.kind === "private"
|
|
256
|
-
&& assessment.detail === "benchmark address"
|
|
257
|
-
) {
|
|
344
|
+
if (options?.allowBenchmarkAddresses && isBenchmarkDnsAnswer(address, assessment)) {
|
|
258
345
|
continue;
|
|
259
346
|
}
|
|
260
347
|
if (assessment.kind === "metadata") return `baseUrl hostname ${hostname} resolves to a blocked metadata endpoint (${address})`;
|
|
@@ -353,7 +440,7 @@ export async function resolvePublicAddresses(
|
|
|
353
440
|
// fake-IP DNS, not a LAN provider. Accept it without allowPrivateNetwork and
|
|
354
441
|
// do not mark the destination private, so the caller's HTTP(S)_PROXY path
|
|
355
442
|
// still applies (credit #1748).
|
|
356
|
-
if (benchmarkAllowed &&
|
|
443
|
+
if (benchmarkAllowed && isBenchmarkDnsAnswer(address, assessment)) {
|
|
357
444
|
validatedAddresses.push({ address, family: ipKind === 4 || ipKind === 6 ? ipKind : (family || 4) });
|
|
358
445
|
continue;
|
|
359
446
|
}
|
|
@@ -377,4 +464,3 @@ export async function resolvePublicAddresses(
|
|
|
377
464
|
export async function assertUrlResolvesPublic(url: string): Promise<void> {
|
|
378
465
|
await resolvePublicAddresses(url);
|
|
379
466
|
}
|
|
380
|
-
|
package/src/lib/redact.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export const REDACTED_SECRET = "[REDACTED]";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Credential-bearing header/field names. Exported for transports that must refuse to send
|
|
5
|
+
* credentials over an unsafe channel (e.g. plaintext non-loopback HTTP) rather than
|
|
6
|
+
* re-deriving a narrower local list.
|
|
7
|
+
*/
|
|
8
|
+
export const SENSITIVE_KEY_PATTERN = /^(?:authorization|proxy-authorization|cookie|set-cookie|set-cookie2|api[-_]?key|x-api-key|x-goog-api-key|x-amz-security-token|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|client[-_]?secret|password|profile[-_]?arn|exa[-_]?api[-_]?key)$/i;
|
|
4
9
|
|
|
5
10
|
/**
|
|
6
11
|
* Colon-labelled credential headers echoed back inside an error body
|
package/src/lib/shadow-call.ts
CHANGED
|
@@ -9,6 +9,23 @@
|
|
|
9
9
|
*/
|
|
10
10
|
export const DEFAULT_SHADOW_SOURCE_MODELS = ["gpt-5.6-luna"] as const;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Optional blocked model redirects at the shared routing layer.
|
|
14
|
+
* When `blockedModelRedirects` is configured (e.g. `{ "gpt-5.6-terra": "gpt-5.6-luna" }`),
|
|
15
|
+
* requests targeting those models are rewritten to the substitute model with
|
|
16
|
+
* routeReason "blocked-model-redirect".
|
|
17
|
+
* Returns undefined when not configured or the model is not in the redirect map.
|
|
18
|
+
*/
|
|
19
|
+
export function resolveBlockedModelRedirect(
|
|
20
|
+
config: { blockedModelRedirects?: Record<string, string> } | undefined,
|
|
21
|
+
modelId: string,
|
|
22
|
+
): string | undefined {
|
|
23
|
+
if (!config?.blockedModelRedirects || typeof config.blockedModelRedirects !== "object") {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return config.blockedModelRedirects[modelId];
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
/** Normalize a persisted `sourceModels` override; falls back to the defaults. */
|
|
13
30
|
export function shadowSourceModels(configured?: unknown): string[] {
|
|
14
31
|
const configuredStrings = Array.isArray(configured)
|
|
@@ -44,18 +61,36 @@ export function shadowSourceModelPrefix(modelId: string, configured?: unknown):
|
|
|
44
61
|
return shadowSourceModels(configured).find(prefix => modelId.startsWith(prefix));
|
|
45
62
|
}
|
|
46
63
|
|
|
64
|
+
export interface ShadowCallModelIdentity {
|
|
65
|
+
providerName: string;
|
|
66
|
+
modelId: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Match a source prefix and replacement as a provider+model pair, never by slug alone. */
|
|
70
|
+
export function shadowCallTargetsIntersect(
|
|
71
|
+
source: ShadowCallModelIdentity,
|
|
72
|
+
target: ShadowCallModelIdentity,
|
|
73
|
+
): boolean {
|
|
74
|
+
return source.providerName === target.providerName
|
|
75
|
+
&& target.modelId.startsWith(source.modelId);
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
/**
|
|
48
79
|
* Decide whether a matching source model should use the opt-in intercept.
|
|
49
80
|
*
|
|
50
81
|
* Before Codex 0.147.0 this checked x-codex-turn-metadata and exempted
|
|
51
82
|
* request_kind "turn". Codex 0.147.0 can label background helper calls as
|
|
52
83
|
* "turn", causing them to bypass the intercept (#1684). The fix is to
|
|
53
|
-
* intercept every configured shadow source model
|
|
54
|
-
*
|
|
84
|
+
* intercept every configured shadow source model regardless of request kind.
|
|
85
|
+
* A replacement intersecting the same provider+model source set remains a
|
|
86
|
+
* no-op because rewriting it would only create self-interception (#2706).
|
|
55
87
|
*/
|
|
56
88
|
export function shouldInterceptShadowCall(
|
|
57
89
|
modelId: string,
|
|
58
90
|
configured: unknown,
|
|
91
|
+
source: ShadowCallModelIdentity,
|
|
92
|
+
target: ShadowCallModelIdentity,
|
|
59
93
|
): boolean {
|
|
60
|
-
return isShadowSourceModel(modelId, configured)
|
|
94
|
+
return isShadowSourceModel(modelId, configured)
|
|
95
|
+
&& !shadowCallTargetsIntersect(source, target);
|
|
61
96
|
}
|
|
@@ -58,15 +58,20 @@ function canonicalize(path: string): string {
|
|
|
58
58
|
* `homedir()` later would return the sandbox and leave the real home unprotected — the
|
|
59
59
|
* guard would be perfectly inverted while its tests still looked green.
|
|
60
60
|
*/
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
);
|
|
61
|
+
const REAL_HOME = process.env[REAL_HOME_ENV]?.trim() || homedir();
|
|
62
|
+
const PROTECTED_HOME = canonicalize(join(REAL_HOME, ".opencodex"));
|
|
63
|
+
const PROTECTED_CODEX_HOME = canonicalize(join(REAL_HOME, ".codex"));
|
|
64
64
|
|
|
65
65
|
/** The production home this process protects. Exported for the guard's own tests. */
|
|
66
66
|
export function protectedHomeForTests(): string {
|
|
67
67
|
return PROTECTED_HOME;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** The production Codex home this process protects when tests write native credentials. */
|
|
71
|
+
export function protectedCodexHomeForTests(): string {
|
|
72
|
+
return PROTECTED_CODEX_HOME;
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
export function isTestHomeGuardArmed(): boolean {
|
|
71
76
|
return process.env[GUARD_ENV] === "1";
|
|
72
77
|
}
|
|
@@ -88,3 +93,13 @@ export function assertNotRealHomeUnderTest(dir: string): void {
|
|
|
88
93
|
+ "instead of calling the global writer (see devlog 260730_codex_rs_upstream_v2_live_handoff/070).",
|
|
89
94
|
);
|
|
90
95
|
}
|
|
96
|
+
|
|
97
|
+
/** Throw when an armed test process is about to write the real native Codex home. */
|
|
98
|
+
export function assertNotRealCodexHomeUnderTest(dir: string): void {
|
|
99
|
+
if (!isTestHomeGuardArmed()) return;
|
|
100
|
+
if (canonicalize(dir) !== PROTECTED_CODEX_HOME) return;
|
|
101
|
+
throw new Error(
|
|
102
|
+
`refusing to write the real Codex home (${PROTECTED_CODEX_HOME}) from a test process. `
|
|
103
|
+
+ "Point CODEX_HOME at a temp directory for this test before writing native auth.json.",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -239,12 +239,19 @@ export interface ResetRetryOptions {
|
|
|
239
239
|
abortSignal?: AbortSignal;
|
|
240
240
|
/** Short host/path label for the retry warn log (no secrets/query strings). */
|
|
241
241
|
label?: string;
|
|
242
|
+
/** Total upstream sends allowed, including the first one. Not a per-layer retry count. */
|
|
242
243
|
attempts?: number;
|
|
243
244
|
}
|
|
244
245
|
|
|
245
246
|
export interface TransientRetryOptions extends ResetRetryOptions {
|
|
246
247
|
/** Test seam: per-attempt slow budget override (defaults to TRANSIENT_RETRY_SLOW_ATTEMPT_MS). */
|
|
247
248
|
slowAttemptMs?: number;
|
|
249
|
+
/**
|
|
250
|
+
* Reports how many upstream sends this call actually consumed, so a caller that spans
|
|
251
|
+
* several legs of one request (initial send, then a 429/account-recovery refetch) can
|
|
252
|
+
* keep them on ONE budget instead of handing each leg a fresh one.
|
|
253
|
+
*/
|
|
254
|
+
onSendsConsumed?: (sends: number) => void;
|
|
248
255
|
}
|
|
249
256
|
|
|
250
257
|
export type UpstreamSendRecovery = "connection-reset" | "transient-5xx";
|
|
@@ -353,23 +360,47 @@ export async function fetchWithResetRetry(
|
|
|
353
360
|
* its body intact. Honors Retry-After via retryBackoffDelayMs.
|
|
354
361
|
*
|
|
355
362
|
* A failed attempt slower than the slow budget is returned as-is (slow-502 shape);
|
|
356
|
-
*
|
|
363
|
+
* `opts.attempts` is ONE total-send budget covering this layer and the inner reset layer
|
|
364
|
+
* together, so it bounds the real number of upstream requests rather than multiplying.
|
|
357
365
|
*/
|
|
358
366
|
export async function fetchWithTransientRetry(
|
|
359
367
|
doFetch: ReplayableFetch,
|
|
360
368
|
opts: TransientRetryOptions = {},
|
|
361
369
|
): Promise<Response> {
|
|
362
|
-
const
|
|
370
|
+
const budget = Math.max(1, opts.attempts ?? TRANSIENT_RETRY_MAX_ATTEMPTS);
|
|
363
371
|
const slowAttemptMs = opts.slowAttemptMs ?? TRANSIENT_RETRY_SLOW_ATTEMPT_MS;
|
|
364
372
|
const transientStatuses: number[] = [];
|
|
373
|
+
// `attempts` is ONE total-send budget shared with the inner reset layer, not a per-layer
|
|
374
|
+
// count. Forwarding it into every `fetchWithResetRetry` made the two multiply: with
|
|
375
|
+
// `attempts: 3` the outer loop ran 3 transient rounds and each round independently retried
|
|
376
|
+
// 3 connection resets, so a single call could emit 9 upstream sends — and 10 could emit 100.
|
|
377
|
+
// That was harmless only because no caller passed `attempts`; the provider-level
|
|
378
|
+
// `transientRetryOn5xx` policy is the first one that does, and multiplying load against an
|
|
379
|
+
// already-failing provider is worse than not retrying at all.
|
|
380
|
+
let sent = 0;
|
|
381
|
+
const countedFetch: ReplayableFetch = (recovery) => {
|
|
382
|
+
// Incremented BEFORE the await so a rejected send still consumes budget; counting only
|
|
383
|
+
// successes would let a reset storm loop without bound.
|
|
384
|
+
sent += 1;
|
|
385
|
+
return doFetch(recovery);
|
|
386
|
+
};
|
|
387
|
+
// Floor of 1 keeps the inner call legal once the budget is spent; the loop condition, not a
|
|
388
|
+
// zero-attempt inner call, is what actually stops the retries.
|
|
389
|
+
const remaining = () => Math.max(1, budget - sent);
|
|
390
|
+
// Reported in `finally` rather than at each exit: this function returns from five places
|
|
391
|
+
// and throws from one, and a caller sharing the budget across request legs must be told the
|
|
392
|
+
// real count on every one of them.
|
|
393
|
+
try {
|
|
365
394
|
let attemptStart = Date.now();
|
|
366
|
-
let res = await fetchWithResetRetry(
|
|
367
|
-
for (let attempt = 0;
|
|
395
|
+
let res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() });
|
|
396
|
+
for (let attempt = 0; sent < budget; attempt++) {
|
|
368
397
|
if (res.ok || !isTransientUpstreamStatus(res.status)) return res;
|
|
398
|
+
// Checked before cancelResponseBodyBestEffort so an already-aborted caller never receives
|
|
399
|
+
// a response whose body we just cancelled.
|
|
369
400
|
if (opts.abortSignal?.aborted) return res;
|
|
370
401
|
if (Date.now() - attemptStart > slowAttemptMs) return res;
|
|
371
402
|
console.warn(
|
|
372
|
-
`[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${
|
|
403
|
+
`[upstream-retry] transient ${res.status}${opts.label ? ` (${opts.label})` : ""} — retrying (${sent + 1}/${budget})`,
|
|
373
404
|
);
|
|
374
405
|
const delay = retryBackoffDelayMs(attempt, {
|
|
375
406
|
baseDelayMs: TRANSIENT_RETRY_BASE_DELAY_MS,
|
|
@@ -377,16 +408,22 @@ export async function fetchWithTransientRetry(
|
|
|
377
408
|
headers: res.headers,
|
|
378
409
|
});
|
|
379
410
|
cancelResponseBodyBestEffort(res);
|
|
411
|
+
// Throws on abort (see sleepWithAbort): the rejection propagates, and the body we just
|
|
412
|
+
// cancelled belonged to a response we were discarding anyway.
|
|
380
413
|
await sleepWithAbort(delay, opts.abortSignal);
|
|
381
414
|
attemptStart = Date.now();
|
|
382
415
|
transientStatuses.push(res.status);
|
|
383
416
|
try {
|
|
384
|
-
res = await fetchWithResetRetry(
|
|
417
|
+
res = await fetchWithResetRetry(countedFetch, { ...opts, attempts: remaining() }, "transient-5xx");
|
|
385
418
|
} catch (err) {
|
|
386
419
|
// Keep the prior 5xx evidence attached: the origin already responded, so
|
|
387
420
|
// this rejection is not pre-connection and must not classify as neutral.
|
|
388
421
|
throw new UpstreamRetryEvidenceError(transientStatuses, err);
|
|
389
422
|
}
|
|
390
423
|
}
|
|
424
|
+
// Budget exhausted: the last response is returned with its body intact.
|
|
391
425
|
return res;
|
|
426
|
+
} finally {
|
|
427
|
+
opts.onSendsConsumed?.(sent);
|
|
428
|
+
}
|
|
392
429
|
}
|