@bitkyc08/opencodex 2.7.43-preview.20260728 → 2.8.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 +8 -1
- package/bin/ocx.mjs +47 -22
- package/gui/dist/assets/index-BDjpkcRN.js +67 -0
- package/gui/dist/assets/index-BHsKRFh9.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/AGENTS.md +28 -0
- package/src/adapters/anthropic.ts +15 -6
- package/src/adapters/cursor/discovery.ts +4 -1
- package/src/adapters/cursor/effort-map.ts +3 -0
- package/src/adapters/cursor/native-exec-shell.ts +18 -6
- package/src/adapters/cursor/protobuf-events.ts +24 -2
- package/src/adapters/cursor/protobuf-request.ts +1 -2
- package/src/adapters/cursor/tool-definitions.ts +68 -29
- package/src/adapters/google-wire-compiler.ts +4 -0
- package/src/adapters/google.ts +128 -2
- package/src/adapters/identity.ts +12 -2
- package/src/adapters/kiro.ts +64 -7
- package/src/adapters/mimo-free.ts +2 -0
- package/src/adapters/openai-responses.ts +246 -59
- package/src/claude/agents-inject.ts +5 -0
- package/src/claude/alias.ts +94 -14
- package/src/claude/inbound.ts +26 -9
- package/src/claude/outbound.ts +6 -3
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/catalog-prewarm.ts +24 -0
- package/src/cli/claude.ts +35 -10
- package/src/cli/doctor.ts +71 -19
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +93 -19
- package/src/cli/interactive-confirm.ts +133 -0
- package/src/cli/opencode.ts +701 -0
- package/src/cli/provider-runtime.ts +3 -0
- package/src/cli/provider.ts +31 -10
- package/src/cli/star-prompt.ts +79 -18
- package/src/cli/status.ts +47 -13
- package/src/cli/v2.ts +10 -1
- package/src/codex/account-id.ts +34 -0
- package/src/codex/account-lifecycle.ts +4 -1
- package/src/codex/account-namespace-match.ts +63 -0
- package/src/codex/account-namespaces.ts +149 -0
- package/src/codex/account-pause.ts +20 -0
- package/src/codex/account-store.ts +2 -0
- package/src/codex/account-usability.ts +6 -1
- package/src/codex/app-server-processes.ts +511 -0
- package/src/codex/auth-api.ts +293 -34
- package/src/codex/auth-collision.ts +2 -1
- package/src/codex/auth-context.ts +60 -17
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/parsing.ts +42 -2
- package/src/codex/catalog/provider-fetch.ts +264 -70
- package/src/codex/catalog/sync.ts +45 -8
- package/src/codex/catalog.ts +2 -2
- package/src/codex/features.ts +524 -5
- package/src/codex/history-provider.ts +145 -1
- package/src/codex/inject.ts +114 -14
- package/src/codex/main-account.ts +2 -8
- package/src/codex/pool-rotation.ts +186 -0
- package/src/codex/quota.ts +92 -2
- package/src/codex/routing.ts +695 -106
- package/src/codex/runtime.ts +10 -1
- package/src/codex/shim.ts +4 -1
- package/src/codex/subagent-defaults.ts +550 -0
- package/src/codex/subagent-model-fallback.ts +2 -0
- package/src/codex/sync.ts +3 -0
- package/src/config.ts +574 -25
- package/src/generated/jawcode-model-metadata.ts +12 -12
- package/src/github/star-state.ts +191 -0
- package/src/images/artifacts.ts +516 -0
- package/src/images/fulfill-video.ts +163 -0
- package/src/images/fulfill.ts +111 -0
- package/src/images/index.ts +4 -0
- package/src/images/loop.ts +789 -0
- package/src/images/plan.ts +133 -0
- package/src/images/synthetic-tool.ts +133 -0
- package/src/images/types.ts +41 -0
- package/src/images/xai-client.ts +141 -0
- package/src/images/xai-video-client.ts +163 -0
- package/src/lib/admin-secrets.ts +25 -0
- package/src/lib/bun-binary-validator.d.mts +3 -0
- package/src/lib/bun-binary-validator.mjs +18 -0
- package/src/lib/bun-runtime.ts +6 -20
- package/src/lib/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +132 -7
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +167 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- package/src/lib/shadow-call.ts +30 -0
- package/src/lib/test-home-guard.ts +90 -0
- package/src/lib/win-exec.ts +12 -2
- package/src/lib/windows-elevation.ts +81 -3
- package/src/lib/windows-secret-acl.ts +189 -12
- package/src/lib/winsw.ts +2 -0
- package/src/oauth/anthropic-routing.ts +570 -0
- package/src/oauth/health.ts +6 -0
- package/src/oauth/index.ts +310 -75
- package/src/oauth/key-providers.ts +38 -8
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +373 -12
- package/src/oauth/kiro.ts +424 -43
- package/src/oauth/login-cli.ts +33 -6
- package/src/oauth/store.ts +56 -4
- package/src/oauth/types.ts +11 -0
- package/src/providers/alibaba-region-migration.ts +16 -3
- package/src/providers/antigravity-models.ts +3 -0
- package/src/providers/api-keys.ts +13 -6
- package/src/providers/derive.ts +8 -2
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/model-discovery.ts +356 -0
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +125 -3
- package/src/responses/parser.ts +11 -0
- package/src/responses/state.ts +22 -8
- package/src/responses/tool-groups.ts +19 -0
- package/src/router.ts +19 -7
- package/src/server/auth-cors.ts +114 -24
- package/src/server/claude-messages.ts +8 -1
- package/src/server/gui-static.ts +30 -6
- package/src/server/images.ts +303 -9
- package/src/server/index.ts +77 -9
- package/src/server/lifecycle.ts +25 -1
- package/src/server/live.ts +75 -25
- package/src/server/management/agent-settings-routes.ts +106 -8
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +22 -7
- package/src/server/management/context.ts +11 -1
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/model-routes.ts +46 -13
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +73 -10
- package/src/server/management/shared.ts +2 -2
- package/src/server/management/sidebar-routes.ts +39 -0
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +5 -3
- package/src/server/management-auth.ts +216 -0
- package/src/server/proxy-liveness.ts +14 -3
- package/src/server/responses/compact.ts +21 -13
- package/src/server/responses/core.ts +614 -172
- package/src/server/responses/upstream-error.ts +48 -0
- package/src/server/responses-image-gen-repair.ts +118 -0
- package/src/server/responses-item-id-repair.ts +10 -85
- package/src/server/sse-payload-rewrite.ts +116 -0
- package/src/server/startup-action-control.ts +30 -14
- package/src/server/system-env.ts +28 -10
- package/src/service.ts +284 -19
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +387 -0
- package/src/storage/policy-scheduler.ts +40 -0
- package/src/storage/policy-worker.ts +53 -0
- package/src/storage/policy.ts +522 -0
- package/src/storage/restore-job.ts +253 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/storage/worker-lifecycle.ts +81 -0
- package/src/tray/windows.ts +34 -4
- package/src/types.ts +107 -1
- package/src/update/badge.ts +72 -0
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +111 -16
- package/src/update/npm-invocation.d.mts +23 -0
- package/src/update/npm-invocation.mjs +94 -0
- package/src/usage/debug.ts +2 -0
- package/src/usage/expected-prices.ts +6 -5
- package/src/usage/log.ts +12 -0
- package/src/web-search/loop.ts +57 -16
- package/gui/dist/assets/index-CjKFJHSC.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
2
|
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
|
|
3
3
|
import { formatPassthroughUpstreamError } from "./passthrough-error";
|
|
4
|
+
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
4
5
|
import {
|
|
5
6
|
getConfigPath,
|
|
6
7
|
multiAgentGuidanceEnabled,
|
|
@@ -34,11 +35,25 @@ import {
|
|
|
34
35
|
forceRefreshOAuthAccessSnapshot,
|
|
35
36
|
getOAuthCredentialApiBaseUrl,
|
|
36
37
|
getOAuthCredentialProjectId,
|
|
38
|
+
getValidAccessTokenForAccount,
|
|
37
39
|
getValidAccessTokenSnapshot,
|
|
38
40
|
type OAuthAccessSnapshot,
|
|
39
41
|
UnsupportedOAuthProviderError,
|
|
40
42
|
} from "../../oauth";
|
|
43
|
+
import {
|
|
44
|
+
ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
|
|
45
|
+
anthropicSessionKeyFromParts,
|
|
46
|
+
bindAnthropicSessionAffinity,
|
|
47
|
+
formatAnthropicProviderForLog,
|
|
48
|
+
getAnthropicPoolAccessToken,
|
|
49
|
+
getAnthropicPoolRetryAfterSeconds,
|
|
50
|
+
isAnthropicAccountPoolEnabled,
|
|
51
|
+
promoteAnthropicActiveAccount,
|
|
52
|
+
resolveAnthropicAccountForSession,
|
|
53
|
+
rotateAnthropicAccountOn429,
|
|
54
|
+
} from "../../oauth/anthropic-routing";
|
|
41
55
|
import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
|
|
56
|
+
import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
|
|
42
57
|
import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
43
58
|
import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
|
|
44
59
|
import {
|
|
@@ -53,11 +68,13 @@ import {
|
|
|
53
68
|
isCodexAuthContextUsable,
|
|
54
69
|
resolveCodexAuthContext,
|
|
55
70
|
codexProbeLeaseId,
|
|
71
|
+
codexProbeQuotaScope,
|
|
56
72
|
releaseCodexAuthContextProbeLease,
|
|
57
73
|
stripCodexRuntimeProviderFields,
|
|
58
74
|
type CodexAuthContext,
|
|
59
75
|
} from "../../codex/auth-context";
|
|
60
76
|
import {
|
|
77
|
+
computeQuotaCooldown,
|
|
61
78
|
formatCodexProviderForLog,
|
|
62
79
|
previewCodexAccountForRequest,
|
|
63
80
|
recordCodexUpstreamOutcome,
|
|
@@ -118,7 +135,16 @@ import {
|
|
|
118
135
|
import { relaySseEagerBounded } from "../relay-eager";
|
|
119
136
|
import { decideEagerRelay } from "../../lib/bun-stream-caps";
|
|
120
137
|
import { cancelBodyOnAbort } from "../../lib/abort";
|
|
121
|
-
import {
|
|
138
|
+
import {
|
|
139
|
+
createResponsesItemIdPayloadRewrite,
|
|
140
|
+
hasResponsesItemIdRepair,
|
|
141
|
+
} from "../responses-item-id-repair";
|
|
142
|
+
import {
|
|
143
|
+
createImageGenCallRestoreRewrite,
|
|
144
|
+
imageGenToolCallAliases,
|
|
145
|
+
restoreImageGenCallsInJson,
|
|
146
|
+
} from "../responses-image-gen-repair";
|
|
147
|
+
import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite";
|
|
122
148
|
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
|
|
123
149
|
|
|
124
150
|
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
|
|
@@ -148,16 +174,9 @@ export function sidecarOutcomeRecorder(
|
|
|
148
174
|
|
|
149
175
|
|
|
150
176
|
|
|
151
|
-
|
|
177
|
+
import { isShadowSourceModel } from "../../lib/shadow-call";
|
|
152
178
|
|
|
153
|
-
export
|
|
154
|
-
if (modelId.includes("/")) return false;
|
|
155
|
-
const configuredStrings = Array.isArray(configured)
|
|
156
|
-
? configured.filter((v): v is string => typeof v === "string" && v.trim() !== "")
|
|
157
|
-
: [];
|
|
158
|
-
const prefixes = configuredStrings.length > 0 ? configuredStrings : DEFAULT_SHADOW_SOURCE_MODELS;
|
|
159
|
-
return prefixes.some(prefix => modelId.startsWith(prefix.trim()));
|
|
160
|
-
}
|
|
179
|
+
export { DEFAULT_SHADOW_SOURCE_MODELS, isShadowSourceModel, shadowSourceModels } from "../../lib/shadow-call";
|
|
161
180
|
|
|
162
181
|
|
|
163
182
|
|
|
@@ -214,12 +233,174 @@ async function shouldRetryCodexPoolAccountModel400(
|
|
|
214
233
|
}
|
|
215
234
|
}
|
|
216
235
|
|
|
236
|
+
/** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
|
|
237
|
+
function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
|
|
238
|
+
return response.status === 429 || response.status === 402;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
interface CodexPoolAccountRetryArgs {
|
|
242
|
+
req: Request;
|
|
243
|
+
config: OcxConfig;
|
|
244
|
+
route: { providerName: string; modelId: string; provider: OcxProviderConfig };
|
|
245
|
+
parsed: OcxParsedRequest;
|
|
246
|
+
logCtx: RequestLogContext;
|
|
247
|
+
options: {
|
|
248
|
+
abortSignal?: AbortSignal;
|
|
249
|
+
onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void;
|
|
250
|
+
deferCodexResetDerivedCooldown?: boolean;
|
|
251
|
+
};
|
|
252
|
+
firstAuthCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
253
|
+
firstResponse: Response;
|
|
254
|
+
outcomeStatus: number;
|
|
255
|
+
upstream: AbortController;
|
|
256
|
+
connectMs: number;
|
|
257
|
+
passthroughEstimate?: number;
|
|
258
|
+
stream: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
type CodexPoolAccountRetryResult =
|
|
262
|
+
| {
|
|
263
|
+
kind: "retried";
|
|
264
|
+
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
265
|
+
request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>;
|
|
266
|
+
upstreamResponse: Response;
|
|
267
|
+
selectedForwardHeaders: Headers;
|
|
268
|
+
}
|
|
269
|
+
| { kind: "no-alternate" }
|
|
270
|
+
| {
|
|
271
|
+
kind: "transport";
|
|
272
|
+
error: unknown;
|
|
273
|
+
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
function codexQuotaOutcomeMeta(response: Response): {
|
|
277
|
+
retryAfter: string | null;
|
|
278
|
+
resetAt: string[];
|
|
279
|
+
} {
|
|
280
|
+
return {
|
|
281
|
+
retryAfter: response.headers.get("retry-after"),
|
|
282
|
+
resetAt: [
|
|
283
|
+
response.headers.get("x-codex-primary-reset-at"),
|
|
284
|
+
response.headers.get("x-codex-secondary-reset-at"),
|
|
285
|
+
response.headers.get("x-codex-tertiary-reset-at"),
|
|
286
|
+
].filter((value): value is string => !!value),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A reset timestamp describes a quota window, not an explicit instruction to
|
|
292
|
+
* stop using the whole account. A combo may therefore try a later model in the
|
|
293
|
+
* same request, while Retry-After and headerless quota failures remain blocking.
|
|
294
|
+
*/
|
|
295
|
+
function shouldDeferCodexResetDerivedCooldown(response: Response, enabled?: boolean): boolean {
|
|
296
|
+
return enabled === true
|
|
297
|
+
&& (response.status === 429 || response.status === 402)
|
|
298
|
+
&& computeQuotaCooldown(codexQuotaOutcomeMeta(response)).source === "reset-derived";
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* One bounded alternate-account retry for Codex pool auth. Used for allow-listed
|
|
303
|
+
* model-400 and for pre-stream 429/402 quota failures (#584).
|
|
304
|
+
*/
|
|
305
|
+
async function retryCodexPoolOnAlternateAccount(
|
|
306
|
+
args: CodexPoolAccountRetryArgs,
|
|
307
|
+
): Promise<CodexPoolAccountRetryResult> {
|
|
308
|
+
const {
|
|
309
|
+
req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
|
|
310
|
+
outcomeStatus, upstream, connectMs, passthroughEstimate, stream,
|
|
311
|
+
} = args;
|
|
312
|
+
let retryAuthCtx: CodexAuthContext | undefined;
|
|
313
|
+
try {
|
|
314
|
+
retryAuthCtx = await resolveCodexAuthContext(
|
|
315
|
+
req.headers,
|
|
316
|
+
config,
|
|
317
|
+
"pool",
|
|
318
|
+
{ excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId },
|
|
319
|
+
);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (
|
|
322
|
+
!(error instanceof CodexPoolAuthenticationError)
|
|
323
|
+
&& !(error instanceof CodexAuthContextError)
|
|
324
|
+
&& !(error instanceof CodexAccountCooldownError)
|
|
325
|
+
) throw error;
|
|
326
|
+
}
|
|
327
|
+
if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") {
|
|
328
|
+
return { kind: "no-alternate" };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const quotaMeta = codexQuotaOutcomeMeta(firstResponse);
|
|
332
|
+
if (outcomeStatus === 429 || outcomeStatus === 402) {
|
|
333
|
+
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
334
|
+
applyAccountQuotaFromUpstreamHeaders(firstAuthCtx.accountId, firstResponse.headers);
|
|
335
|
+
}
|
|
336
|
+
if (!shouldDeferCodexResetDerivedCooldown(firstResponse, options.deferCodexResetDerivedCooldown)) {
|
|
337
|
+
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
338
|
+
...quotaMeta,
|
|
339
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
340
|
+
modelId: route.modelId,
|
|
341
|
+
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
|
|
342
|
+
probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
|
|
343
|
+
// Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
|
|
344
|
+
...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
349
|
+
const retryProvider = applyCodexAuthContextToProvider(
|
|
350
|
+
stripCodexRuntimeProviderFields(route.provider),
|
|
351
|
+
retryAuthCtx,
|
|
352
|
+
"pool",
|
|
353
|
+
);
|
|
354
|
+
const retryAdapter = resolveAdapter(
|
|
355
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider),
|
|
356
|
+
config.cacheRetention,
|
|
357
|
+
);
|
|
358
|
+
const request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders });
|
|
359
|
+
recordAdapterReasoning(logCtx, request);
|
|
360
|
+
|
|
361
|
+
await firstResponse.body?.cancel().catch(() => undefined);
|
|
362
|
+
options.onCodexAuthContextResolved?.(retryAuthCtx);
|
|
363
|
+
route.provider = retryProvider;
|
|
364
|
+
logCtx.provider = formatCodexProviderForLog(
|
|
365
|
+
route.providerName,
|
|
366
|
+
retryAuthCtx.accountId,
|
|
367
|
+
config,
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
|
|
371
|
+
try {
|
|
372
|
+
const upstreamResponse = await fetchWithHeaderTimeout(
|
|
373
|
+
request.url,
|
|
374
|
+
{
|
|
375
|
+
method: request.method,
|
|
376
|
+
headers: request.headers,
|
|
377
|
+
body: request.body,
|
|
378
|
+
},
|
|
379
|
+
upstream.signal,
|
|
380
|
+
connectMs,
|
|
381
|
+
stream,
|
|
382
|
+
providerFetch(route.provider),
|
|
383
|
+
);
|
|
384
|
+
return {
|
|
385
|
+
kind: "retried",
|
|
386
|
+
authCtx: retryAuthCtx,
|
|
387
|
+
request,
|
|
388
|
+
upstreamResponse,
|
|
389
|
+
selectedForwardHeaders: retryHeaders,
|
|
390
|
+
};
|
|
391
|
+
} catch (error) {
|
|
392
|
+
// Attribute the transport failure to the alternate account (already selected).
|
|
393
|
+
return { kind: "transport", error, authCtx: retryAuthCtx };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
217
397
|
|
|
218
398
|
|
|
219
399
|
export function codexForwardTerminalOutcomeRecorder(
|
|
220
400
|
config: OcxConfig,
|
|
221
401
|
authCtx: CodexAuthContext,
|
|
222
402
|
provider: OcxProviderConfig,
|
|
403
|
+
modelId?: string,
|
|
223
404
|
logCtx?: RequestLogContext,
|
|
224
405
|
threadId?: string | null,
|
|
225
406
|
): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
|
|
@@ -231,7 +412,9 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
231
412
|
// prior soft-avoid so a healthy account isn't stuck avoided.
|
|
232
413
|
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
|
|
233
414
|
threadId,
|
|
415
|
+
modelId,
|
|
234
416
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
417
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
235
418
|
});
|
|
236
419
|
return;
|
|
237
420
|
}
|
|
@@ -249,7 +432,9 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
249
432
|
: (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
|
|
250
433
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
251
434
|
threadId,
|
|
435
|
+
modelId,
|
|
252
436
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
437
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
253
438
|
});
|
|
254
439
|
};
|
|
255
440
|
}
|
|
@@ -303,8 +488,15 @@ export interface HandleResponsesOptions {
|
|
|
303
488
|
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
|
|
304
489
|
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
305
490
|
onNativePassthroughCancel?: () => void;
|
|
491
|
+
/**
|
|
492
|
+
* When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort
|
|
493
|
+
* (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity.
|
|
494
|
+
*/
|
|
495
|
+
promptCacheKeyIsSharedCohort?: boolean;
|
|
306
496
|
/** Internal recursion guard; callers outside this module must not set it. */
|
|
307
497
|
comboAttempt?: boolean;
|
|
498
|
+
/** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
|
|
499
|
+
deferCodexResetDerivedCooldown?: boolean;
|
|
308
500
|
/** 030-owned handoff when a child consumed the original failure under bounds. */
|
|
309
501
|
onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
|
|
310
502
|
}
|
|
@@ -478,7 +670,7 @@ async function resolveResponsesCodexAuth(
|
|
|
478
670
|
if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
|
|
479
671
|
let authCtx: CodexAuthContext;
|
|
480
672
|
if (route.codexAccountMode) {
|
|
481
|
-
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
|
|
673
|
+
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: route.modelId });
|
|
482
674
|
options.onCodexAuthContextResolved?.(authCtx);
|
|
483
675
|
} else {
|
|
484
676
|
authCtx = { kind: "main", accountId: null };
|
|
@@ -738,9 +930,17 @@ export async function handleComboResponses(
|
|
|
738
930
|
const callbackGate = createChildPassthroughCallbackGate(options);
|
|
739
931
|
let response: Response;
|
|
740
932
|
try {
|
|
933
|
+
const currentTargetProvider = pick.target.provider;
|
|
934
|
+
const deferCodexResetDerivedCooldown = combo.strategy === "failover"
|
|
935
|
+
&& combo.targets.slice(pick.targetIndex + 1).some(target =>
|
|
936
|
+
target.provider === currentTargetProvider
|
|
937
|
+
&& payloadEligible(target)
|
|
938
|
+
&& !isComboTargetInCooldown(comboId, target),
|
|
939
|
+
);
|
|
741
940
|
response = await handleResponses(childRequest, config, childLog, {
|
|
742
941
|
...options,
|
|
743
942
|
comboAttempt: true,
|
|
943
|
+
deferCodexResetDerivedCooldown,
|
|
744
944
|
// Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
|
|
745
945
|
// Object.assign(logCtx, childLog) would overwrite the request-relative value).
|
|
746
946
|
onFirstOutput: () => {
|
|
@@ -937,10 +1137,6 @@ export async function handleResponses(
|
|
|
937
1137
|
}
|
|
938
1138
|
if (parsed._compactionRequest === true) parsed._cursorIsolateConversation = true;
|
|
939
1139
|
|
|
940
|
-
if (isThreadSpawnRequest(req.headers)) {
|
|
941
|
-
await maybePrimeSubagentQuota(config);
|
|
942
|
-
}
|
|
943
|
-
|
|
944
1140
|
let route: RouteResult;
|
|
945
1141
|
try {
|
|
946
1142
|
route = routeModel(config, parsed.modelId);
|
|
@@ -951,6 +1147,17 @@ export async function handleResponses(
|
|
|
951
1147
|
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
952
1148
|
}
|
|
953
1149
|
|
|
1150
|
+
const hasUnexpandedPreviousResponse = !!parsed.previousResponseId
|
|
1151
|
+
&& parsed._previousResponseInputExpanded !== true;
|
|
1152
|
+
// A canonical replay miss must not poll quota upstream before the final fail-closed decision.
|
|
1153
|
+
// Cached fallback state can still select a provider with native continuation support below.
|
|
1154
|
+
if (
|
|
1155
|
+
isThreadSpawnRequest(req.headers)
|
|
1156
|
+
&& !(hasUnexpandedPreviousResponse && isCanonicalOpenAiForwardProvider(route.provider))
|
|
1157
|
+
) {
|
|
1158
|
+
await maybePrimeSubagentQuota(config);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
954
1161
|
let authCtx: CodexAuthContext = { kind: "main", accountId: null };
|
|
955
1162
|
let selectedForwardHeaders = req.headers;
|
|
956
1163
|
let subagentFallbackAccountId = config.activeCodexAccountId ?? null;
|
|
@@ -999,6 +1206,20 @@ export async function handleResponses(
|
|
|
999
1206
|
return unreadableEncryptedAgentTaskResponse();
|
|
1000
1207
|
}
|
|
1001
1208
|
|
|
1209
|
+
// The canonical ChatGPT backend rejects previous_response_id, so a local replay miss leaves no
|
|
1210
|
+
// safe way to recover the omitted history. Fail before auth, adapter construction, or upstream
|
|
1211
|
+
// I/O instead of stripping the id and silently forwarding a context-free delta (#702).
|
|
1212
|
+
if (
|
|
1213
|
+
hasUnexpandedPreviousResponse
|
|
1214
|
+
&& isCanonicalOpenAiForwardProvider(route.provider)
|
|
1215
|
+
) {
|
|
1216
|
+
return formatErrorResponse(
|
|
1217
|
+
400,
|
|
1218
|
+
"invalid_request_error",
|
|
1219
|
+
"OpenAI forward continuation state is unavailable or expired; start a new session instead of reusing this previous_response_id.",
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1002
1223
|
await applyFinalRouteRequestNormalization({ parsed, route, config, req, logCtx });
|
|
1003
1224
|
|
|
1004
1225
|
{
|
|
@@ -1023,16 +1244,54 @@ export async function handleResponses(
|
|
|
1023
1244
|
const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro")
|
|
1024
1245
|
&& route.provider.authMode === "oauth";
|
|
1025
1246
|
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
|
|
1247
|
+
let anthropicPoolAccountId: string | null = null;
|
|
1248
|
+
let anthropicPoolFailovers = 0;
|
|
1249
|
+
const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
|
|
1250
|
+
? anthropicSessionKeyFromParts({
|
|
1251
|
+
sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
|
|
1252
|
+
threadIdHeader: req.headers.get("thread-id"),
|
|
1253
|
+
promptCacheKey: typeof parsed.options.promptCacheKey === "string" ? parsed.options.promptCacheKey : null,
|
|
1254
|
+
clientThreadId: typeof parsed._clientThreadId === "string" ? parsed._clientThreadId : null,
|
|
1255
|
+
promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort === true,
|
|
1256
|
+
})
|
|
1257
|
+
: null;
|
|
1026
1258
|
if (route.provider.authMode === "oauth") {
|
|
1027
1259
|
try {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1260
|
+
if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) {
|
|
1261
|
+
const selection = resolveAnthropicAccountForSession(anthropicSessionKey, config);
|
|
1262
|
+
if (!selection.accountId) {
|
|
1263
|
+
if (selection.reason === "all-cooled") {
|
|
1264
|
+
const retryAfterSec = getAnthropicPoolRetryAfterSeconds();
|
|
1265
|
+
return formatErrorResponse(
|
|
1266
|
+
429,
|
|
1267
|
+
"rate_limit_error",
|
|
1268
|
+
"All Anthropic OAuth accounts are temporarily rate-limited",
|
|
1269
|
+
retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined,
|
|
1270
|
+
);
|
|
1271
|
+
}
|
|
1272
|
+
return formatErrorResponse(401, "authentication_error", "No eligible Anthropic OAuth account available");
|
|
1273
|
+
}
|
|
1274
|
+
const accessToken = await getAnthropicPoolAccessToken(selection.accountId);
|
|
1275
|
+
anthropicPoolAccountId = selection.accountId;
|
|
1276
|
+
bindAnthropicSessionAffinity(anthropicSessionKey, selection.accountId);
|
|
1277
|
+
promoteAnthropicActiveAccount(selection.accountId);
|
|
1278
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
1279
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config);
|
|
1280
|
+
} else {
|
|
1281
|
+
const resolved = await getValidAccessTokenSnapshot(route.providerName);
|
|
1282
|
+
if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
|
|
1283
|
+
route.provider = { ...route.provider, apiKey: resolved.accessToken };
|
|
1284
|
+
if (route.providerName === "kiro") {
|
|
1285
|
+
// `{}` is intentional: this is an account-scoped request with no stored routing metadata.
|
|
1286
|
+
// Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
|
|
1287
|
+
parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) };
|
|
1288
|
+
}
|
|
1289
|
+
// Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
|
|
1290
|
+
// CCA envelope; the server injects only the bare token, so pull project from the credential.
|
|
1291
|
+
if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
|
|
1292
|
+
const projectId = getOAuthCredentialProjectId(route.providerName);
|
|
1293
|
+
if (projectId) route.provider = { ...route.provider, project: projectId };
|
|
1294
|
+
}
|
|
1036
1295
|
}
|
|
1037
1296
|
} catch (err) {
|
|
1038
1297
|
if (err instanceof UnsupportedOAuthProviderError) {
|
|
@@ -1146,6 +1405,9 @@ export async function handleResponses(
|
|
|
1146
1405
|
}
|
|
1147
1406
|
|
|
1148
1407
|
if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) {
|
|
1408
|
+
const imageGenCallAliases = route.provider.authMode === "forward"
|
|
1409
|
+
? new Map<string, { namespace: string; name: string }>()
|
|
1410
|
+
: imageGenToolCallAliases(buildToolBridgeMaps(parsed).toolNsMap, parsed._rawBody);
|
|
1149
1411
|
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
|
|
1150
1412
|
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
|
|
1151
1413
|
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
|
|
@@ -1189,12 +1451,14 @@ export async function handleResponses(
|
|
|
1189
1451
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1190
1452
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1191
1453
|
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1454
|
+
modelId: route.modelId,
|
|
1192
1455
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1456
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
1193
1457
|
});
|
|
1194
1458
|
}
|
|
1195
1459
|
const msg = outcome === "timeout"
|
|
1196
1460
|
? `Provider connect timeout after ${connectMs}ms`
|
|
1197
|
-
:
|
|
1461
|
+
: describeUpstreamConnectFailure(err, connectMs);
|
|
1198
1462
|
return formatErrorResponse(502, "upstream_error", msg);
|
|
1199
1463
|
};
|
|
1200
1464
|
try {
|
|
@@ -1216,77 +1480,46 @@ export async function handleResponses(
|
|
|
1216
1480
|
return transportFailureResponse(err);
|
|
1217
1481
|
}
|
|
1218
1482
|
|
|
1219
|
-
if (
|
|
1220
|
-
|
|
1221
|
-
|
|
1483
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1484
|
+
let poolRetryOutcome: number | undefined;
|
|
1485
|
+
if (await shouldRetryCodexPoolAccountModel400(
|
|
1222
1486
|
upstreamResponse,
|
|
1223
1487
|
route.modelId,
|
|
1224
1488
|
options.abortSignal,
|
|
1225
|
-
)
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
retryAuthCtx = await resolveCodexAuthContext(
|
|
1231
|
-
req.headers,
|
|
1232
|
-
config,
|
|
1233
|
-
"pool",
|
|
1234
|
-
{ excludeAccountId: firstAuthCtx.accountId },
|
|
1235
|
-
);
|
|
1236
|
-
} catch (error) {
|
|
1237
|
-
if (
|
|
1238
|
-
!(error instanceof CodexPoolAuthenticationError)
|
|
1239
|
-
&& !(error instanceof CodexAuthContextError)
|
|
1240
|
-
&& !(error instanceof CodexAccountCooldownError)
|
|
1241
|
-
) throw error;
|
|
1489
|
+
)) {
|
|
1490
|
+
poolRetryOutcome = 400;
|
|
1491
|
+
} else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
|
|
1492
|
+
// Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
|
|
1493
|
+
poolRetryOutcome = upstreamResponse.status;
|
|
1242
1494
|
}
|
|
1243
1495
|
|
|
1244
|
-
if (
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
|
|
1248
|
-
});
|
|
1249
|
-
|
|
1250
|
-
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
1251
|
-
const retryProvider = applyCodexAuthContextToProvider(
|
|
1252
|
-
stripCodexRuntimeProviderFields(route.provider),
|
|
1253
|
-
retryAuthCtx,
|
|
1254
|
-
"pool",
|
|
1255
|
-
);
|
|
1256
|
-
const retryAdapter = resolveAdapter(
|
|
1257
|
-
resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider),
|
|
1258
|
-
config.cacheRetention,
|
|
1259
|
-
);
|
|
1260
|
-
request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders });
|
|
1261
|
-
recordAdapterReasoning(logCtx, request);
|
|
1262
|
-
|
|
1263
|
-
await upstreamResponse.body?.cancel().catch(() => undefined);
|
|
1264
|
-
authCtx = retryAuthCtx;
|
|
1265
|
-
options.onCodexAuthContextResolved?.(retryAuthCtx);
|
|
1266
|
-
selectedForwardHeaders = retryHeaders;
|
|
1267
|
-
route.provider = retryProvider;
|
|
1268
|
-
logCtx.provider = formatCodexProviderForLog(
|
|
1269
|
-
route.providerName,
|
|
1270
|
-
retryAuthCtx.accountId,
|
|
1496
|
+
if (poolRetryOutcome !== undefined) {
|
|
1497
|
+
const retry = await retryCodexPoolOnAlternateAccount({
|
|
1498
|
+
req,
|
|
1271
1499
|
config,
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1500
|
+
route,
|
|
1501
|
+
parsed,
|
|
1502
|
+
logCtx,
|
|
1503
|
+
options,
|
|
1504
|
+
firstAuthCtx: authCtx,
|
|
1505
|
+
firstResponse: upstreamResponse,
|
|
1506
|
+
outcomeStatus: poolRetryOutcome,
|
|
1507
|
+
upstream,
|
|
1508
|
+
connectMs,
|
|
1509
|
+
passthroughEstimate,
|
|
1510
|
+
stream: parsed.stream,
|
|
1511
|
+
});
|
|
1512
|
+
if (retry.kind === "transport") {
|
|
1513
|
+
authCtx = retry.authCtx;
|
|
1514
|
+
return transportFailureResponse(retry.error);
|
|
1515
|
+
}
|
|
1516
|
+
if (retry.kind === "retried") {
|
|
1517
|
+
authCtx = retry.authCtx;
|
|
1518
|
+
request = retry.request;
|
|
1519
|
+
upstreamResponse = retry.upstreamResponse;
|
|
1520
|
+
selectedForwardHeaders = retry.selectedForwardHeaders;
|
|
1521
|
+
// Keep subagent quota-failure health keyed to the account that actually served.
|
|
1522
|
+
subagentFallbackAccountId = retry.authCtx.accountId;
|
|
1290
1523
|
}
|
|
1291
1524
|
}
|
|
1292
1525
|
}
|
|
@@ -1306,6 +1539,7 @@ export async function handleResponses(
|
|
|
1306
1539
|
config,
|
|
1307
1540
|
authCtx,
|
|
1308
1541
|
route.provider,
|
|
1542
|
+
route.modelId,
|
|
1309
1543
|
logCtx,
|
|
1310
1544
|
req.headers.get("x-codex-parent-thread-id"),
|
|
1311
1545
|
);
|
|
@@ -1314,7 +1548,7 @@ export async function handleResponses(
|
|
|
1314
1548
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1315
1549
|
// primary was the 5h window; it now carries weekly data for GPT plans.
|
|
1316
1550
|
// Prefer primary when present, fall back to secondary for compatibility.
|
|
1317
|
-
const
|
|
1551
|
+
const quotaMeta = codexQuotaOutcomeMeta(upstreamResponse);
|
|
1318
1552
|
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
1319
1553
|
applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers);
|
|
1320
1554
|
if (terminalBodyWillRecord) {
|
|
@@ -1338,16 +1572,16 @@ export async function handleResponses(
|
|
|
1338
1572
|
}
|
|
1339
1573
|
options.onNativePassthroughTerminal?.(status);
|
|
1340
1574
|
});
|
|
1341
|
-
} else
|
|
1575
|
+
} else if (!shouldDeferCodexResetDerivedCooldown(
|
|
1576
|
+
upstreamResponse,
|
|
1577
|
+
options.deferCodexResetDerivedCooldown,
|
|
1578
|
+
)) {
|
|
1342
1579
|
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
].filter(Boolean),
|
|
1349
|
-
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1350
|
-
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1580
|
+
...quotaMeta,
|
|
1581
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1582
|
+
modelId: route.modelId,
|
|
1583
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1584
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
1351
1585
|
});
|
|
1352
1586
|
}
|
|
1353
1587
|
}
|
|
@@ -1381,8 +1615,9 @@ export async function handleResponses(
|
|
|
1381
1615
|
// known-bad runtime remains the tee path below.
|
|
1382
1616
|
if (isEventStream && upstreamResponse.body) {
|
|
1383
1617
|
const repairConfig = route.provider.responsesItemIdRepair;
|
|
1384
|
-
const
|
|
1385
|
-
const
|
|
1618
|
+
const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig);
|
|
1619
|
+
const winNoClientRewrite = process.platform === "win32" && !needsClientRewrite;
|
|
1620
|
+
const eagerDecision = winNoClientRewrite ? decideEagerRelay(config.streamMode ?? "auto") : null;
|
|
1386
1621
|
if (eagerDecision?.useEagerRelay) {
|
|
1387
1622
|
const turnAc = new AbortController();
|
|
1388
1623
|
linkAbortSignal(upstream, turnAc.signal);
|
|
@@ -1433,6 +1668,8 @@ export async function handleResponses(
|
|
|
1433
1668
|
onClientCancel: () => options.onNativePassthroughCancel?.(),
|
|
1434
1669
|
onDone: () => unregisterTurn(turnAc),
|
|
1435
1670
|
});
|
|
1671
|
+
// The eager branch is reachable only through winNoClientRewrite, so it must stay a pure
|
|
1672
|
+
// native relay without an image-gen or item-id JS pull wrapper on win32 (Bun#32111).
|
|
1436
1673
|
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
1437
1674
|
return markNativePassthroughSseResponse(new Response(eagerBody, {
|
|
1438
1675
|
status: upstreamResponse.status,
|
|
@@ -1492,12 +1729,19 @@ export async function handleResponses(
|
|
|
1492
1729
|
// win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
|
|
1493
1730
|
// relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
|
|
1494
1731
|
// mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
|
|
1495
|
-
|
|
1496
|
-
|
|
1732
|
+
// Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
|
|
1733
|
+
const payloadRewrites = [
|
|
1734
|
+
createImageGenCallRestoreRewrite(imageGenCallAliases),
|
|
1735
|
+
hasResponsesItemIdRepair(repairConfig)
|
|
1736
|
+
? createResponsesItemIdPayloadRewrite(repairConfig!)
|
|
1737
|
+
: undefined,
|
|
1738
|
+
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
|
|
1739
|
+
const rewrittenBody = payloadRewrites.length > 0
|
|
1740
|
+
? relaySseWithPayloadRewrite(nativeBody, composeSsePayloadRewrites(...payloadRewrites))
|
|
1497
1741
|
: nativeBody;
|
|
1498
|
-
const clientBody = process.platform === "win32" && !
|
|
1742
|
+
const clientBody = process.platform === "win32" && !needsClientRewrite
|
|
1499
1743
|
? nativeBody
|
|
1500
|
-
: relaySseWithFailedTail(
|
|
1744
|
+
: relaySseWithFailedTail(rewrittenBody, upstream);
|
|
1501
1745
|
return markNativePassthroughSseResponse(new Response(clientBody, {
|
|
1502
1746
|
status: upstreamResponse.status,
|
|
1503
1747
|
headers,
|
|
@@ -1511,7 +1755,7 @@ export async function handleResponses(
|
|
|
1511
1755
|
rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown });
|
|
1512
1756
|
} catch { /* non-JSON despite content-type; recording is best-effort */ }
|
|
1513
1757
|
}
|
|
1514
|
-
return new Response(text, {
|
|
1758
|
+
return new Response(restoreImageGenCallsInJson(text, imageGenCallAliases), {
|
|
1515
1759
|
status: upstreamResponse.status,
|
|
1516
1760
|
statusText: upstreamResponse.statusText,
|
|
1517
1761
|
headers,
|
|
@@ -1526,6 +1770,195 @@ export async function handleResponses(
|
|
|
1526
1770
|
});
|
|
1527
1771
|
}
|
|
1528
1772
|
|
|
1773
|
+
// Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
|
|
1774
|
+
// Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
|
|
1775
|
+
// leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
|
|
1776
|
+
// completion instead of the synthetic compaction item Codex expects (#424).
|
|
1777
|
+
//
|
|
1778
|
+
// Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending
|
|
1779
|
+
// Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So:
|
|
1780
|
+
// - non-runTurn: web-search wins over image when both eligible (documented priority)
|
|
1781
|
+
// - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn
|
|
1782
|
+
// can proceed for web-search-only turns
|
|
1783
|
+
const wsPlan = !routedCompaction
|
|
1784
|
+
? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar)
|
|
1785
|
+
: undefined;
|
|
1786
|
+
const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
|
|
1787
|
+
const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
|
|
1788
|
+
const canRunWebSearch = !!wsPlan && !adapter.runTurn;
|
|
1789
|
+
if ((imgPlan || vidPlan) && canRunWebSearch) {
|
|
1790
|
+
// Web search takes priority when both are active — the media bridge cannot run
|
|
1791
|
+
// alongside runWithWebSearch. Surface a runtime signal so the user knows their
|
|
1792
|
+
// configured video/image bridge was skipped for this turn, rather than silently
|
|
1793
|
+
// dropping a paid capability.
|
|
1794
|
+
if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn");
|
|
1795
|
+
if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn");
|
|
1796
|
+
}
|
|
1797
|
+
if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) {
|
|
1798
|
+
// The image bridge detects a hosted image_generation tool and requires streaming.
|
|
1799
|
+
// The video bridge activates from config and injects a tool — it also needs streaming
|
|
1800
|
+
// (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip
|
|
1801
|
+
// the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic.
|
|
1802
|
+
if (!parsed.stream) {
|
|
1803
|
+
if (imgPlan) {
|
|
1804
|
+
return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
|
|
1805
|
+
}
|
|
1806
|
+
// Video-only: skip bridge for non-streaming requests
|
|
1807
|
+
} else {
|
|
1808
|
+
// Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names.
|
|
1809
|
+
const priorTools = parsed.context.tools ?? [];
|
|
1810
|
+
const bridgeTools = [...priorTools.filter(t => {
|
|
1811
|
+
if (t.imageGeneration) return false;
|
|
1812
|
+
if (t.videoGeneration) return false;
|
|
1813
|
+
if (imgPlan && imgPlan.toolNames.has(t.name)) return false;
|
|
1814
|
+
if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
|
|
1815
|
+
// Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone.
|
|
1816
|
+
if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false;
|
|
1817
|
+
return true;
|
|
1818
|
+
})];
|
|
1819
|
+
const existingNames = new Set(bridgeTools.map(t => t.name));
|
|
1820
|
+
if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool());
|
|
1821
|
+
if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool());
|
|
1822
|
+
parsed.context.tools = bridgeTools;
|
|
1823
|
+
// Hosted image_generation tool_choice / allowed_tools must target the synthetic function name.
|
|
1824
|
+
// Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting
|
|
1825
|
+
// image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject.
|
|
1826
|
+
const tc = parsed.options.toolChoice;
|
|
1827
|
+
if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) {
|
|
1828
|
+
const mapped = tc.allowedTools.map(name =>
|
|
1829
|
+
name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false)
|
|
1830
|
+
? IMAGE_GEN_TOOL_NAME
|
|
1831
|
+
: name,
|
|
1832
|
+
);
|
|
1833
|
+
parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] };
|
|
1834
|
+
} else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string"
|
|
1835
|
+
&& (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
|
|
1836
|
+
parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
|
|
1837
|
+
}
|
|
1838
|
+
const imgResponse = await runWithImageBridge({
|
|
1839
|
+
parsed, adapter,
|
|
1840
|
+
...(imgPlan ? { plan: imgPlan } : {}),
|
|
1841
|
+
...(vidPlan ? { videoPlan: vidPlan } : {}),
|
|
1842
|
+
forwardHeaders: selectedForwardHeaders,
|
|
1843
|
+
onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens),
|
|
1844
|
+
abortSignal: options.abortSignal,
|
|
1845
|
+
maxRounds: imgPlan && vidPlan
|
|
1846
|
+
? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
|
|
1847
|
+
: imgPlan
|
|
1848
|
+
? clampImageMaxRounds(config.images?.maxRounds)
|
|
1849
|
+
: clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
|
|
1850
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
1851
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
1852
|
+
fetchImpl: providerFetch(route.provider),
|
|
1853
|
+
onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
|
|
1854
|
+
...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
|
|
1855
|
+
onUsage: usage => {
|
|
1856
|
+
// Cursor may assign _cursorConversationId inside the image loop's first runTurn;
|
|
1857
|
+
// backfill so Logs can filter/total that opening request (parity with the normal
|
|
1858
|
+
// runTurn branch).
|
|
1859
|
+
if (!logCtx.conversationId && parsed._cursorConversationId) {
|
|
1860
|
+
logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
|
|
1861
|
+
}
|
|
1862
|
+
logCtx.usageFromBridge = true;
|
|
1863
|
+
if (usage) {
|
|
1864
|
+
logCtx.usage = usage;
|
|
1865
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
1866
|
+
}
|
|
1867
|
+
},
|
|
1868
|
+
on429: retryAfter => {
|
|
1869
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
1870
|
+
retryAfter,
|
|
1871
|
+
now: Date.now(),
|
|
1872
|
+
attemptedKey: route.provider.apiKey,
|
|
1873
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
1874
|
+
});
|
|
1875
|
+
if (!rotated) return null;
|
|
1876
|
+
route.provider = rotated;
|
|
1877
|
+
return resolveAdapter(
|
|
1878
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
1879
|
+
config.cacheRetention,
|
|
1880
|
+
);
|
|
1881
|
+
},
|
|
1882
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
1883
|
+
...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
|
|
1884
|
+
onCompletedResponse: (response, providerState) =>
|
|
1885
|
+
rememberResponseState(
|
|
1886
|
+
parsed._rawBody,
|
|
1887
|
+
response,
|
|
1888
|
+
continuationStateForResponse(providerState),
|
|
1889
|
+
adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
|
|
1890
|
+
),
|
|
1891
|
+
});
|
|
1892
|
+
if (imgResponse.body) {
|
|
1893
|
+
const imgTurnAc = new AbortController();
|
|
1894
|
+
return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc), {
|
|
1895
|
+
status: imgResponse.status,
|
|
1896
|
+
headers: imgResponse.headers,
|
|
1897
|
+
});
|
|
1898
|
+
}
|
|
1899
|
+
return imgResponse;
|
|
1900
|
+
} // end else (streaming bridge)
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
|
|
1904
|
+
// run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
|
|
1905
|
+
// through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
|
|
1906
|
+
// Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch
|
|
1907
|
+
// through web-search instead of being swallowed. runTurn adapters never enter this branch.
|
|
1908
|
+
if (canRunWebSearch && wsPlan) {
|
|
1909
|
+
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
1910
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
1911
|
+
const wsResponse = await runWithWebSearch({
|
|
1912
|
+
parsed, adapter,
|
|
1913
|
+
backend: wsPlan.backend,
|
|
1914
|
+
forwardProvider: wsPlan.forwardSidecar?.provider,
|
|
1915
|
+
anthropicSidecar: wsPlan.anthropicSidecar,
|
|
1916
|
+
hostedTool: wsPlan.hostedTool,
|
|
1917
|
+
selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
|
|
1918
|
+
settings: wsPlan.settings,
|
|
1919
|
+
maxSearches: wsPlan.maxSearches,
|
|
1920
|
+
forceEmptyResponseId: true,
|
|
1921
|
+
abortSignal: options.abortSignal,
|
|
1922
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
1923
|
+
onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
|
|
1924
|
+
onUsage: usage => {
|
|
1925
|
+
logCtx.usageFromBridge = true;
|
|
1926
|
+
if (usage) {
|
|
1927
|
+
logCtx.usage = usage;
|
|
1928
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
1929
|
+
}
|
|
1930
|
+
},
|
|
1931
|
+
recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
|
|
1932
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
1933
|
+
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
1934
|
+
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
1935
|
+
on429: retryAfter => {
|
|
1936
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
1937
|
+
retryAfter,
|
|
1938
|
+
now: Date.now(),
|
|
1939
|
+
attemptedKey: route.provider.apiKey,
|
|
1940
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
1941
|
+
});
|
|
1942
|
+
if (!rotated) return null;
|
|
1943
|
+
route.provider = rotated;
|
|
1944
|
+
return resolveAdapter(
|
|
1945
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
1946
|
+
config.cacheRetention,
|
|
1947
|
+
);
|
|
1948
|
+
},
|
|
1949
|
+
});
|
|
1950
|
+
// Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
|
|
1951
|
+
// in-flight web-search turns instead of skipping them during graceful shutdown.
|
|
1952
|
+
if (wsResponse.body) {
|
|
1953
|
+
const wsTurnAc = new AbortController();
|
|
1954
|
+
return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), {
|
|
1955
|
+
status: wsResponse.status,
|
|
1956
|
+
headers: wsResponse.headers,
|
|
1957
|
+
});
|
|
1958
|
+
}
|
|
1959
|
+
return wsResponse;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1529
1962
|
if (adapter.runTurn) {
|
|
1530
1963
|
const runTurnAbort = new AbortController();
|
|
1531
1964
|
linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
@@ -1646,64 +2079,6 @@ export async function handleResponses(
|
|
|
1646
2079
|
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
1647
2080
|
}
|
|
1648
2081
|
|
|
1649
|
-
// Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
|
|
1650
|
-
// run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
|
|
1651
|
-
// through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
|
|
1652
|
-
const wsPlan = planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar);
|
|
1653
|
-
if (wsPlan) {
|
|
1654
|
-
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
1655
|
-
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
1656
|
-
const wsResponse = await runWithWebSearch({
|
|
1657
|
-
parsed, adapter,
|
|
1658
|
-
backend: wsPlan.backend,
|
|
1659
|
-
forwardProvider: wsPlan.forwardSidecar?.provider,
|
|
1660
|
-
anthropicSidecar: wsPlan.anthropicSidecar,
|
|
1661
|
-
hostedTool: wsPlan.hostedTool,
|
|
1662
|
-
selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
|
|
1663
|
-
settings: wsPlan.settings,
|
|
1664
|
-
maxSearches: wsPlan.maxSearches,
|
|
1665
|
-
forceEmptyResponseId: true,
|
|
1666
|
-
abortSignal: options.abortSignal,
|
|
1667
|
-
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
1668
|
-
onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
|
|
1669
|
-
onUsage: usage => {
|
|
1670
|
-
logCtx.usageFromBridge = true;
|
|
1671
|
-
if (usage) {
|
|
1672
|
-
logCtx.usage = usage;
|
|
1673
|
-
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
1674
|
-
}
|
|
1675
|
-
},
|
|
1676
|
-
recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
|
|
1677
|
-
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
1678
|
-
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
1679
|
-
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
1680
|
-
on429: retryAfter => {
|
|
1681
|
-
const rotated = rotateProviderTransportOn429(config, route.providerName, {
|
|
1682
|
-
retryAfter,
|
|
1683
|
-
now: Date.now(),
|
|
1684
|
-
attemptedKey: route.provider.apiKey,
|
|
1685
|
-
promptCacheKey: parsed.options.promptCacheKey,
|
|
1686
|
-
});
|
|
1687
|
-
if (!rotated) return null;
|
|
1688
|
-
route.provider = rotated;
|
|
1689
|
-
return resolveAdapter(
|
|
1690
|
-
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
1691
|
-
config.cacheRetention,
|
|
1692
|
-
);
|
|
1693
|
-
},
|
|
1694
|
-
});
|
|
1695
|
-
// Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
|
|
1696
|
-
// in-flight web-search turns instead of skipping them during graceful shutdown.
|
|
1697
|
-
if (wsResponse.body) {
|
|
1698
|
-
const wsTurnAc = new AbortController();
|
|
1699
|
-
return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), {
|
|
1700
|
-
status: wsResponse.status,
|
|
1701
|
-
headers: wsResponse.headers,
|
|
1702
|
-
});
|
|
1703
|
-
}
|
|
1704
|
-
return wsResponse;
|
|
1705
|
-
}
|
|
1706
|
-
|
|
1707
2082
|
const upstream = new AbortController();
|
|
1708
2083
|
const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
|
|
1709
2084
|
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
@@ -1741,9 +2116,7 @@ export async function handleResponses(
|
|
|
1741
2116
|
cleanupUpstreamAbort();
|
|
1742
2117
|
upstream.abort();
|
|
1743
2118
|
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1744
|
-
const msg = err
|
|
1745
|
-
? `Provider connect timeout after ${connectMs}ms`
|
|
1746
|
-
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
2119
|
+
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
1747
2120
|
return formatErrorResponse(502, "upstream_error", msg);
|
|
1748
2121
|
}
|
|
1749
2122
|
|
|
@@ -1783,9 +2156,7 @@ export async function handleResponses(
|
|
|
1783
2156
|
if (options.abortSignal?.aborted) {
|
|
1784
2157
|
return { failed: clientCancelledResponse() };
|
|
1785
2158
|
}
|
|
1786
|
-
const msg = err
|
|
1787
|
-
? `Provider connect timeout after ${connectMs}ms`
|
|
1788
|
-
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
2159
|
+
const msg = describeUpstreamConnectFailure(err, connectMs);
|
|
1789
2160
|
return { failed: formatErrorResponse(502, "upstream_error", msg) };
|
|
1790
2161
|
}
|
|
1791
2162
|
};
|
|
@@ -1806,6 +2177,9 @@ export async function handleResponses(
|
|
|
1806
2177
|
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
1807
2178
|
}
|
|
1808
2179
|
sentOAuthSnapshot = refreshed;
|
|
2180
|
+
if (route.providerName === "kiro") {
|
|
2181
|
+
parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
|
|
2182
|
+
}
|
|
1809
2183
|
const refreshedProvider = resolveProviderTransport(
|
|
1810
2184
|
route.providerName,
|
|
1811
2185
|
{ ...route.provider, apiKey: refreshed.accessToken },
|
|
@@ -1827,7 +2201,7 @@ export async function handleResponses(
|
|
|
1827
2201
|
// SAME request once per remaining key. OAuth/forward providers and single-key pools
|
|
1828
2202
|
// return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
|
|
1829
2203
|
while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
1830
|
-
const rotated = rotateProviderTransportOn429(config, route.providerName, {
|
|
2204
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
1831
2205
|
retryAfter: upstreamResponse.headers.get("retry-after"),
|
|
1832
2206
|
now: Date.now(),
|
|
1833
2207
|
attemptedKey: route.provider.apiKey,
|
|
@@ -1846,6 +2220,42 @@ export async function handleResponses(
|
|
|
1846
2220
|
if ("failed" in result) return result.failed;
|
|
1847
2221
|
upstreamResponse = result;
|
|
1848
2222
|
}
|
|
2223
|
+
|
|
2224
|
+
// Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
|
|
2225
|
+
// with another eligible OAuth account (bounded per request). Disabled by default.
|
|
2226
|
+
while (
|
|
2227
|
+
upstreamResponse.status === 429
|
|
2228
|
+
&& anthropicPoolAccountId
|
|
2229
|
+
&& isAnthropicAccountPoolEnabled(config)
|
|
2230
|
+
&& anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
|
|
2231
|
+
) {
|
|
2232
|
+
const nextAccountId = rotateAnthropicAccountOn429(
|
|
2233
|
+
config,
|
|
2234
|
+
anthropicPoolAccountId,
|
|
2235
|
+
upstreamResponse.headers.get("retry-after"),
|
|
2236
|
+
anthropicSessionKey,
|
|
2237
|
+
);
|
|
2238
|
+
if (!nextAccountId) break;
|
|
2239
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
2240
|
+
try {
|
|
2241
|
+
const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
|
|
2242
|
+
anthropicPoolAccountId = nextAccountId;
|
|
2243
|
+
anthropicPoolFailovers += 1;
|
|
2244
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
2245
|
+
promoteAnthropicActiveAccount(nextAccountId);
|
|
2246
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
2247
|
+
activeAdapter = resolveAdapter(
|
|
2248
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
2249
|
+
config.cacheRetention,
|
|
2250
|
+
);
|
|
2251
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
2252
|
+
const result = await rebuildAndRefetch("anthropic-oauth-429");
|
|
2253
|
+
if ("failed" in result) return result.failed;
|
|
2254
|
+
upstreamResponse = result;
|
|
2255
|
+
} catch {
|
|
2256
|
+
break;
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
1849
2259
|
// Anthropic 413 request_too_large: rebuild once with every image one tier lower
|
|
1850
2260
|
// (spiral guard: single attempt). The biased response re-enters the 429 check above.
|
|
1851
2261
|
if (shouldAttemptImageTierRetry({
|
|
@@ -1954,7 +2364,7 @@ export async function handleResponses(
|
|
|
1954
2364
|
}
|
|
1955
2365
|
|
|
1956
2366
|
if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
1957
|
-
const rotated = rotateProviderTransportOn429(config, route.providerName, {
|
|
2367
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
1958
2368
|
retryAfter: response.headers.get("retry-after"),
|
|
1959
2369
|
now: Date.now(),
|
|
1960
2370
|
attemptedKey: route.provider.apiKey,
|
|
@@ -1970,6 +2380,38 @@ export async function handleResponses(
|
|
|
1970
2380
|
continue;
|
|
1971
2381
|
}
|
|
1972
2382
|
}
|
|
2383
|
+
if (
|
|
2384
|
+
response.status === 429
|
|
2385
|
+
&& anthropicPoolAccountId
|
|
2386
|
+
&& isAnthropicAccountPoolEnabled(config)
|
|
2387
|
+
&& anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
|
|
2388
|
+
) {
|
|
2389
|
+
const nextAccountId = rotateAnthropicAccountOn429(
|
|
2390
|
+
config,
|
|
2391
|
+
anthropicPoolAccountId,
|
|
2392
|
+
response.headers.get("retry-after"),
|
|
2393
|
+
anthropicSessionKey,
|
|
2394
|
+
);
|
|
2395
|
+
if (nextAccountId) {
|
|
2396
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
2397
|
+
try {
|
|
2398
|
+
const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
|
|
2399
|
+
anthropicPoolAccountId = nextAccountId;
|
|
2400
|
+
anthropicPoolFailovers += 1;
|
|
2401
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
2402
|
+
promoteAnthropicActiveAccount(nextAccountId);
|
|
2403
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
2404
|
+
activeAdapter = resolveAdapter(
|
|
2405
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
2406
|
+
config.cacheRetention,
|
|
2407
|
+
);
|
|
2408
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
2409
|
+
continue;
|
|
2410
|
+
} catch {
|
|
2411
|
+
// fall through to emit continuation error below
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
1973
2415
|
if (shouldAttemptImageTierRetry({
|
|
1974
2416
|
status: response.status,
|
|
1975
2417
|
adapterName: activeAdapter.name,
|