@bitkyc08/opencodex 2.7.42 → 2.7.43
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 +13 -14
- package/gui/dist/assets/index-Czw-jpTU.css +1 -0
- package/gui/dist/assets/index-cmds12BG.js +67 -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/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 +49 -6
- 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/inbound.ts +26 -9
- package/src/cli/account-auth.ts +1 -1
- package/src/cli/agent-driven.ts +37 -0
- package/src/cli/claude.ts +3 -3
- package/src/cli/doctor.ts +23 -18
- package/src/cli/help.ts +42 -6
- package/src/cli/index.ts +88 -19
- package/src/cli/interactive-confirm.ts +129 -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 +55 -16
- package/src/cli/status.ts +47 -13
- 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-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/parsing.ts +16 -1
- package/src/codex/catalog/provider-fetch.ts +48 -12
- package/src/codex/catalog/sync.ts +18 -3
- package/src/codex/catalog.ts +1 -1
- 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 +15 -0
- package/src/codex/routing.ts +695 -106
- 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 +566 -25
- 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/config-ownership.ts +327 -0
- package/src/lib/crash-guard.ts +2 -0
- package/src/lib/destination-policy.ts +122 -4
- package/src/lib/pinned-http.ts +151 -0
- package/src/lib/process-control.ts +2 -2
- package/src/lib/provider-outbound.ts +164 -0
- package/src/lib/provider-url.ts +14 -0
- package/src/lib/proxy-env.ts +18 -0
- 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 +283 -72
- package/src/oauth/key-providers.ts +17 -6
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro-credentials.ts +322 -10
- package/src/oauth/kiro.ts +423 -43
- package/src/oauth/login-cli.ts +32 -5
- package/src/oauth/store.ts +54 -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 +6 -0
- package/src/providers/key-failover.ts +24 -4
- package/src/providers/quota.ts +233 -29
- package/src/providers/registry.ts +11 -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 +14 -4
- package/src/server/auth-cors.ts +110 -22
- 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/management/agent-settings-routes.ts +28 -4
- package/src/server/management/combo-routes.ts +7 -0
- package/src/server/management/config-routes.ts +5 -2
- package/src/server/management/logs-usage-routes.ts +167 -3
- package/src/server/management/oauth-account-routes.ts +163 -17
- package/src/server/management/provider-routes.ts +35 -7
- package/src/server/management/system-restart.ts +172 -0
- package/src/server/management/system-routes.ts +33 -10
- package/src/server/management-api.ts +2 -2
- 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 +583 -152
- 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/system-env.ts +28 -10
- package/src/service.ts +49 -2
- package/src/storage/cleanup-job.ts +57 -0
- package/src/storage/cleanup.ts +1504 -28
- package/src/storage/policy-job.ts +366 -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 +242 -0
- package/src/storage/restore-worker.ts +52 -0
- package/src/storage/storage-mutation-coordinator.ts +109 -0
- package/src/tray/windows.ts +2 -0
- package/src/types.ts +96 -1
- package/src/update/index.ts +36 -18
- package/src/update/job.ts +103 -12
- 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/log.ts +4 -0
- package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
- package/gui/dist/assets/index-DfVGuN88.css +0 -1
|
@@ -34,11 +34,25 @@ import {
|
|
|
34
34
|
forceRefreshOAuthAccessSnapshot,
|
|
35
35
|
getOAuthCredentialApiBaseUrl,
|
|
36
36
|
getOAuthCredentialProjectId,
|
|
37
|
+
getValidAccessTokenForAccount,
|
|
37
38
|
getValidAccessTokenSnapshot,
|
|
38
39
|
type OAuthAccessSnapshot,
|
|
39
40
|
UnsupportedOAuthProviderError,
|
|
40
41
|
} from "../../oauth";
|
|
42
|
+
import {
|
|
43
|
+
ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
|
|
44
|
+
anthropicSessionKeyFromParts,
|
|
45
|
+
bindAnthropicSessionAffinity,
|
|
46
|
+
formatAnthropicProviderForLog,
|
|
47
|
+
getAnthropicPoolAccessToken,
|
|
48
|
+
getAnthropicPoolRetryAfterSeconds,
|
|
49
|
+
isAnthropicAccountPoolEnabled,
|
|
50
|
+
promoteAnthropicActiveAccount,
|
|
51
|
+
resolveAnthropicAccountForSession,
|
|
52
|
+
rotateAnthropicAccountOn429,
|
|
53
|
+
} from "../../oauth/anthropic-routing";
|
|
41
54
|
import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search";
|
|
55
|
+
import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images";
|
|
42
56
|
import { describeImagesInPlace, planVisionSidecar, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision";
|
|
43
57
|
import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue";
|
|
44
58
|
import {
|
|
@@ -53,11 +67,13 @@ import {
|
|
|
53
67
|
isCodexAuthContextUsable,
|
|
54
68
|
resolveCodexAuthContext,
|
|
55
69
|
codexProbeLeaseId,
|
|
70
|
+
codexProbeQuotaScope,
|
|
56
71
|
releaseCodexAuthContextProbeLease,
|
|
57
72
|
stripCodexRuntimeProviderFields,
|
|
58
73
|
type CodexAuthContext,
|
|
59
74
|
} from "../../codex/auth-context";
|
|
60
75
|
import {
|
|
76
|
+
computeQuotaCooldown,
|
|
61
77
|
formatCodexProviderForLog,
|
|
62
78
|
previewCodexAccountForRequest,
|
|
63
79
|
recordCodexUpstreamOutcome,
|
|
@@ -118,7 +134,16 @@ import {
|
|
|
118
134
|
import { relaySseEagerBounded } from "../relay-eager";
|
|
119
135
|
import { decideEagerRelay } from "../../lib/bun-stream-caps";
|
|
120
136
|
import { cancelBodyOnAbort } from "../../lib/abort";
|
|
121
|
-
import {
|
|
137
|
+
import {
|
|
138
|
+
createResponsesItemIdPayloadRewrite,
|
|
139
|
+
hasResponsesItemIdRepair,
|
|
140
|
+
} from "../responses-item-id-repair";
|
|
141
|
+
import {
|
|
142
|
+
createImageGenCallRestoreRewrite,
|
|
143
|
+
imageGenToolCallAliases,
|
|
144
|
+
restoreImageGenCallsInJson,
|
|
145
|
+
} from "../responses-image-gen-repair";
|
|
146
|
+
import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite";
|
|
122
147
|
import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog";
|
|
123
148
|
|
|
124
149
|
import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration";
|
|
@@ -214,12 +239,174 @@ async function shouldRetryCodexPoolAccountModel400(
|
|
|
214
239
|
}
|
|
215
240
|
}
|
|
216
241
|
|
|
242
|
+
/** Pre-stream quota/billing rejections that warrant one alternate-account attempt (#584). */
|
|
243
|
+
function shouldRetryCodexPoolAccountQuota(response: Response): boolean {
|
|
244
|
+
return response.status === 429 || response.status === 402;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
interface CodexPoolAccountRetryArgs {
|
|
248
|
+
req: Request;
|
|
249
|
+
config: OcxConfig;
|
|
250
|
+
route: { providerName: string; modelId: string; provider: OcxProviderConfig };
|
|
251
|
+
parsed: OcxParsedRequest;
|
|
252
|
+
logCtx: RequestLogContext;
|
|
253
|
+
options: {
|
|
254
|
+
abortSignal?: AbortSignal;
|
|
255
|
+
onCodexAuthContextResolved?: (ctx: CodexAuthContext) => void;
|
|
256
|
+
deferCodexResetDerivedCooldown?: boolean;
|
|
257
|
+
};
|
|
258
|
+
firstAuthCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
259
|
+
firstResponse: Response;
|
|
260
|
+
outcomeStatus: number;
|
|
261
|
+
upstream: AbortController;
|
|
262
|
+
connectMs: number;
|
|
263
|
+
passthroughEstimate?: number;
|
|
264
|
+
stream: boolean;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
type CodexPoolAccountRetryResult =
|
|
268
|
+
| {
|
|
269
|
+
kind: "retried";
|
|
270
|
+
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
271
|
+
request: Awaited<ReturnType<ReturnType<typeof resolveAdapter>["buildRequest"]>>;
|
|
272
|
+
upstreamResponse: Response;
|
|
273
|
+
selectedForwardHeaders: Headers;
|
|
274
|
+
}
|
|
275
|
+
| { kind: "no-alternate" }
|
|
276
|
+
| {
|
|
277
|
+
kind: "transport";
|
|
278
|
+
error: unknown;
|
|
279
|
+
authCtx: Extract<CodexAuthContext, { kind: "pool" | "main-pool" }>;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
function codexQuotaOutcomeMeta(response: Response): {
|
|
283
|
+
retryAfter: string | null;
|
|
284
|
+
resetAt: string[];
|
|
285
|
+
} {
|
|
286
|
+
return {
|
|
287
|
+
retryAfter: response.headers.get("retry-after"),
|
|
288
|
+
resetAt: [
|
|
289
|
+
response.headers.get("x-codex-primary-reset-at"),
|
|
290
|
+
response.headers.get("x-codex-secondary-reset-at"),
|
|
291
|
+
response.headers.get("x-codex-tertiary-reset-at"),
|
|
292
|
+
].filter((value): value is string => !!value),
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* A reset timestamp describes a quota window, not an explicit instruction to
|
|
298
|
+
* stop using the whole account. A combo may therefore try a later model in the
|
|
299
|
+
* same request, while Retry-After and headerless quota failures remain blocking.
|
|
300
|
+
*/
|
|
301
|
+
function shouldDeferCodexResetDerivedCooldown(response: Response, enabled?: boolean): boolean {
|
|
302
|
+
return enabled === true
|
|
303
|
+
&& (response.status === 429 || response.status === 402)
|
|
304
|
+
&& computeQuotaCooldown(codexQuotaOutcomeMeta(response)).source === "reset-derived";
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* One bounded alternate-account retry for Codex pool auth. Used for allow-listed
|
|
309
|
+
* model-400 and for pre-stream 429/402 quota failures (#584).
|
|
310
|
+
*/
|
|
311
|
+
async function retryCodexPoolOnAlternateAccount(
|
|
312
|
+
args: CodexPoolAccountRetryArgs,
|
|
313
|
+
): Promise<CodexPoolAccountRetryResult> {
|
|
314
|
+
const {
|
|
315
|
+
req, config, route, parsed, logCtx, options, firstAuthCtx, firstResponse,
|
|
316
|
+
outcomeStatus, upstream, connectMs, passthroughEstimate, stream,
|
|
317
|
+
} = args;
|
|
318
|
+
let retryAuthCtx: CodexAuthContext | undefined;
|
|
319
|
+
try {
|
|
320
|
+
retryAuthCtx = await resolveCodexAuthContext(
|
|
321
|
+
req.headers,
|
|
322
|
+
config,
|
|
323
|
+
"pool",
|
|
324
|
+
{ excludeAccountId: firstAuthCtx.accountId, modelId: route.modelId },
|
|
325
|
+
);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (
|
|
328
|
+
!(error instanceof CodexPoolAuthenticationError)
|
|
329
|
+
&& !(error instanceof CodexAuthContextError)
|
|
330
|
+
&& !(error instanceof CodexAccountCooldownError)
|
|
331
|
+
) throw error;
|
|
332
|
+
}
|
|
333
|
+
if (retryAuthCtx?.kind !== "pool" && retryAuthCtx?.kind !== "main-pool") {
|
|
334
|
+
return { kind: "no-alternate" };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const quotaMeta = codexQuotaOutcomeMeta(firstResponse);
|
|
338
|
+
if (outcomeStatus === 429 || outcomeStatus === 402) {
|
|
339
|
+
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
340
|
+
applyAccountQuotaFromUpstreamHeaders(firstAuthCtx.accountId, firstResponse.headers);
|
|
341
|
+
}
|
|
342
|
+
if (!shouldDeferCodexResetDerivedCooldown(firstResponse, options.deferCodexResetDerivedCooldown)) {
|
|
343
|
+
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
344
|
+
...quotaMeta,
|
|
345
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
346
|
+
modelId: route.modelId,
|
|
347
|
+
probeLeaseId: codexProbeLeaseId(firstAuthCtx),
|
|
348
|
+
probeQuotaScope: codexProbeQuotaScope(firstAuthCtx),
|
|
349
|
+
// Retry already advanced the RR ring via excludeAccountId — reuse for promotion.
|
|
350
|
+
...(retryAuthCtx.accountId ? { promoteAccountId: retryAuthCtx.accountId } : {}),
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const retryHeaders = headersForCodexAuthContext(req.headers, retryAuthCtx);
|
|
355
|
+
const retryProvider = applyCodexAuthContextToProvider(
|
|
356
|
+
stripCodexRuntimeProviderFields(route.provider),
|
|
357
|
+
retryAuthCtx,
|
|
358
|
+
"pool",
|
|
359
|
+
);
|
|
360
|
+
const retryAdapter = resolveAdapter(
|
|
361
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, retryProvider),
|
|
362
|
+
config.cacheRetention,
|
|
363
|
+
);
|
|
364
|
+
const request = await retryAdapter.buildRequest(parsed, { headers: retryHeaders });
|
|
365
|
+
recordAdapterReasoning(logCtx, request);
|
|
366
|
+
|
|
367
|
+
await firstResponse.body?.cancel().catch(() => undefined);
|
|
368
|
+
options.onCodexAuthContextResolved?.(retryAuthCtx);
|
|
369
|
+
route.provider = retryProvider;
|
|
370
|
+
logCtx.provider = formatCodexProviderForLog(
|
|
371
|
+
route.providerName,
|
|
372
|
+
retryAuthCtx.accountId,
|
|
373
|
+
config,
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate);
|
|
377
|
+
try {
|
|
378
|
+
const upstreamResponse = await fetchWithHeaderTimeout(
|
|
379
|
+
request.url,
|
|
380
|
+
{
|
|
381
|
+
method: request.method,
|
|
382
|
+
headers: request.headers,
|
|
383
|
+
body: request.body,
|
|
384
|
+
},
|
|
385
|
+
upstream.signal,
|
|
386
|
+
connectMs,
|
|
387
|
+
stream,
|
|
388
|
+
providerFetch(route.provider),
|
|
389
|
+
);
|
|
390
|
+
return {
|
|
391
|
+
kind: "retried",
|
|
392
|
+
authCtx: retryAuthCtx,
|
|
393
|
+
request,
|
|
394
|
+
upstreamResponse,
|
|
395
|
+
selectedForwardHeaders: retryHeaders,
|
|
396
|
+
};
|
|
397
|
+
} catch (error) {
|
|
398
|
+
// Attribute the transport failure to the alternate account (already selected).
|
|
399
|
+
return { kind: "transport", error, authCtx: retryAuthCtx };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
217
403
|
|
|
218
404
|
|
|
219
405
|
export function codexForwardTerminalOutcomeRecorder(
|
|
220
406
|
config: OcxConfig,
|
|
221
407
|
authCtx: CodexAuthContext,
|
|
222
408
|
provider: OcxProviderConfig,
|
|
409
|
+
modelId?: string,
|
|
223
410
|
logCtx?: RequestLogContext,
|
|
224
411
|
threadId?: string | null,
|
|
225
412
|
): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
|
|
@@ -231,7 +418,9 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
231
418
|
// prior soft-avoid so a healthy account isn't stuck avoided.
|
|
232
419
|
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, {
|
|
233
420
|
threadId,
|
|
421
|
+
modelId,
|
|
234
422
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
423
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
235
424
|
});
|
|
236
425
|
return;
|
|
237
426
|
}
|
|
@@ -249,7 +438,9 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
249
438
|
: (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
|
|
250
439
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
251
440
|
threadId,
|
|
441
|
+
modelId,
|
|
252
442
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
443
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
253
444
|
});
|
|
254
445
|
};
|
|
255
446
|
}
|
|
@@ -303,8 +494,15 @@ export interface HandleResponsesOptions {
|
|
|
303
494
|
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
|
|
304
495
|
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
305
496
|
onNativePassthroughCancel?: () => void;
|
|
497
|
+
/**
|
|
498
|
+
* When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort
|
|
499
|
+
* (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity.
|
|
500
|
+
*/
|
|
501
|
+
promptCacheKeyIsSharedCohort?: boolean;
|
|
306
502
|
/** Internal recursion guard; callers outside this module must not set it. */
|
|
307
503
|
comboAttempt?: boolean;
|
|
504
|
+
/** Internal combo handoff: allow a later same-provider model after a reset-derived 429/402. */
|
|
505
|
+
deferCodexResetDerivedCooldown?: boolean;
|
|
308
506
|
/** 030-owned handoff when a child consumed the original failure under bounds. */
|
|
309
507
|
onConsumedComboFailure?: (failure: ConsumedComboFailure) => void;
|
|
310
508
|
}
|
|
@@ -478,7 +676,7 @@ async function resolveResponsesCodexAuth(
|
|
|
478
676
|
if (route.codexAccountMode === "direct") validateForwardAdmissionCredential(req.headers, config);
|
|
479
677
|
let authCtx: CodexAuthContext;
|
|
480
678
|
if (route.codexAccountMode) {
|
|
481
|
-
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
|
|
679
|
+
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode, { modelId: route.modelId });
|
|
482
680
|
options.onCodexAuthContextResolved?.(authCtx);
|
|
483
681
|
} else {
|
|
484
682
|
authCtx = { kind: "main", accountId: null };
|
|
@@ -738,9 +936,17 @@ export async function handleComboResponses(
|
|
|
738
936
|
const callbackGate = createChildPassthroughCallbackGate(options);
|
|
739
937
|
let response: Response;
|
|
740
938
|
try {
|
|
939
|
+
const currentTargetProvider = pick.target.provider;
|
|
940
|
+
const deferCodexResetDerivedCooldown = combo.strategy === "failover"
|
|
941
|
+
&& combo.targets.slice(pick.targetIndex + 1).some(target =>
|
|
942
|
+
target.provider === currentTargetProvider
|
|
943
|
+
&& payloadEligible(target)
|
|
944
|
+
&& !isComboTargetInCooldown(comboId, target),
|
|
945
|
+
);
|
|
741
946
|
response = await handleResponses(childRequest, config, childLog, {
|
|
742
947
|
...options,
|
|
743
948
|
comboAttempt: true,
|
|
949
|
+
deferCodexResetDerivedCooldown,
|
|
744
950
|
// Attempt-relative TTFT is recorded HERE (not via childLog.firstOutputMs — a later
|
|
745
951
|
// Object.assign(logCtx, childLog) would overwrite the request-relative value).
|
|
746
952
|
onFirstOutput: () => {
|
|
@@ -1023,16 +1229,54 @@ export async function handleResponses(
|
|
|
1023
1229
|
const isOAuth401ReplayProvider = (route.providerName === "xai" || route.providerName === "github-copilot" || route.providerName === "kiro")
|
|
1024
1230
|
&& route.provider.authMode === "oauth";
|
|
1025
1231
|
let sentOAuthSnapshot: OAuthAccessSnapshot | undefined;
|
|
1232
|
+
let anthropicPoolAccountId: string | null = null;
|
|
1233
|
+
let anthropicPoolFailovers = 0;
|
|
1234
|
+
const anthropicSessionKey = route.providerName === "anthropic" && route.provider.authMode === "oauth"
|
|
1235
|
+
? anthropicSessionKeyFromParts({
|
|
1236
|
+
sessionIdHeader: sessionIdHeaderFromRequest(req.headers),
|
|
1237
|
+
threadIdHeader: req.headers.get("thread-id"),
|
|
1238
|
+
promptCacheKey: typeof parsed.options.promptCacheKey === "string" ? parsed.options.promptCacheKey : null,
|
|
1239
|
+
clientThreadId: typeof parsed._clientThreadId === "string" ? parsed._clientThreadId : null,
|
|
1240
|
+
promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort === true,
|
|
1241
|
+
})
|
|
1242
|
+
: null;
|
|
1026
1243
|
if (route.provider.authMode === "oauth") {
|
|
1027
1244
|
try {
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1245
|
+
if (route.providerName === "anthropic" && isAnthropicAccountPoolEnabled(config)) {
|
|
1246
|
+
const selection = resolveAnthropicAccountForSession(anthropicSessionKey, config);
|
|
1247
|
+
if (!selection.accountId) {
|
|
1248
|
+
if (selection.reason === "all-cooled") {
|
|
1249
|
+
const retryAfterSec = getAnthropicPoolRetryAfterSeconds();
|
|
1250
|
+
return formatErrorResponse(
|
|
1251
|
+
429,
|
|
1252
|
+
"rate_limit_error",
|
|
1253
|
+
"All Anthropic OAuth accounts are temporarily rate-limited",
|
|
1254
|
+
retryAfterSec !== null ? { retryAfter: String(retryAfterSec) } : undefined,
|
|
1255
|
+
);
|
|
1256
|
+
}
|
|
1257
|
+
return formatErrorResponse(401, "authentication_error", "No eligible Anthropic OAuth account available");
|
|
1258
|
+
}
|
|
1259
|
+
const accessToken = await getAnthropicPoolAccessToken(selection.accountId);
|
|
1260
|
+
anthropicPoolAccountId = selection.accountId;
|
|
1261
|
+
bindAnthropicSessionAffinity(anthropicSessionKey, selection.accountId);
|
|
1262
|
+
promoteAnthropicActiveAccount(selection.accountId);
|
|
1263
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
1264
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", selection.accountId, config);
|
|
1265
|
+
} else {
|
|
1266
|
+
const resolved = await getValidAccessTokenSnapshot(route.providerName);
|
|
1267
|
+
if (isOAuth401ReplayProvider) sentOAuthSnapshot = resolved;
|
|
1268
|
+
route.provider = { ...route.provider, apiKey: resolved.accessToken };
|
|
1269
|
+
if (route.providerName === "kiro") {
|
|
1270
|
+
// `{}` is intentional: this is an account-scoped request with no stored routing metadata.
|
|
1271
|
+
// Only genuinely accountless adapter calls leave the context undefined and use local/env fallback.
|
|
1272
|
+
parsed._kiroAuthContext = { ...(resolved.kiro ?? {}) };
|
|
1273
|
+
}
|
|
1274
|
+
// Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
|
|
1275
|
+
// CCA envelope; the server injects only the bare token, so pull project from the credential.
|
|
1276
|
+
if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
|
|
1277
|
+
const projectId = getOAuthCredentialProjectId(route.providerName);
|
|
1278
|
+
if (projectId) route.provider = { ...route.provider, project: projectId };
|
|
1279
|
+
}
|
|
1036
1280
|
}
|
|
1037
1281
|
} catch (err) {
|
|
1038
1282
|
if (err instanceof UnsupportedOAuthProviderError) {
|
|
@@ -1146,6 +1390,9 @@ export async function handleResponses(
|
|
|
1146
1390
|
}
|
|
1147
1391
|
|
|
1148
1392
|
if ("passthrough" in adapter && adapter.passthrough && !routedCompaction) {
|
|
1393
|
+
const imageGenCallAliases = route.provider.authMode === "forward"
|
|
1394
|
+
? new Map<string, { namespace: string; name: string }>()
|
|
1395
|
+
: imageGenToolCallAliases(buildToolBridgeMaps(parsed).toolNsMap, parsed._rawBody);
|
|
1149
1396
|
// Local continuation cache for the ChatGPT passthrough. Codex WS turns chain with
|
|
1150
1397
|
// previous_response_id, ocx converts them to internal HTTP requests, and the ChatGPT Codex
|
|
1151
1398
|
// REST backend rejects the parameter — the adapter strips it in forward mode, so the ONLY
|
|
@@ -1189,7 +1436,9 @@ export async function handleResponses(
|
|
|
1189
1436
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1190
1437
|
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1191
1438
|
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1439
|
+
modelId: route.modelId,
|
|
1192
1440
|
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1441
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
1193
1442
|
});
|
|
1194
1443
|
}
|
|
1195
1444
|
const msg = outcome === "timeout"
|
|
@@ -1216,77 +1465,46 @@ export async function handleResponses(
|
|
|
1216
1465
|
return transportFailureResponse(err);
|
|
1217
1466
|
}
|
|
1218
1467
|
|
|
1219
|
-
if (
|
|
1220
|
-
|
|
1221
|
-
|
|
1468
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1469
|
+
let poolRetryOutcome: number | undefined;
|
|
1470
|
+
if (await shouldRetryCodexPoolAccountModel400(
|
|
1222
1471
|
upstreamResponse,
|
|
1223
1472
|
route.modelId,
|
|
1224
1473
|
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;
|
|
1474
|
+
)) {
|
|
1475
|
+
poolRetryOutcome = 400;
|
|
1476
|
+
} else if (shouldRetryCodexPoolAccountQuota(upstreamResponse)) {
|
|
1477
|
+
// Pre-stream only: once SSE has begun, mid-stream quota stays terminal.
|
|
1478
|
+
poolRetryOutcome = upstreamResponse.status;
|
|
1242
1479
|
}
|
|
1243
1480
|
|
|
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,
|
|
1481
|
+
if (poolRetryOutcome !== undefined) {
|
|
1482
|
+
const retry = await retryCodexPoolOnAlternateAccount({
|
|
1483
|
+
req,
|
|
1271
1484
|
config,
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1485
|
+
route,
|
|
1486
|
+
parsed,
|
|
1487
|
+
logCtx,
|
|
1488
|
+
options,
|
|
1489
|
+
firstAuthCtx: authCtx,
|
|
1490
|
+
firstResponse: upstreamResponse,
|
|
1491
|
+
outcomeStatus: poolRetryOutcome,
|
|
1492
|
+
upstream,
|
|
1493
|
+
connectMs,
|
|
1494
|
+
passthroughEstimate,
|
|
1495
|
+
stream: parsed.stream,
|
|
1496
|
+
});
|
|
1497
|
+
if (retry.kind === "transport") {
|
|
1498
|
+
authCtx = retry.authCtx;
|
|
1499
|
+
return transportFailureResponse(retry.error);
|
|
1500
|
+
}
|
|
1501
|
+
if (retry.kind === "retried") {
|
|
1502
|
+
authCtx = retry.authCtx;
|
|
1503
|
+
request = retry.request;
|
|
1504
|
+
upstreamResponse = retry.upstreamResponse;
|
|
1505
|
+
selectedForwardHeaders = retry.selectedForwardHeaders;
|
|
1506
|
+
// Keep subagent quota-failure health keyed to the account that actually served.
|
|
1507
|
+
subagentFallbackAccountId = retry.authCtx.accountId;
|
|
1290
1508
|
}
|
|
1291
1509
|
}
|
|
1292
1510
|
}
|
|
@@ -1306,6 +1524,7 @@ export async function handleResponses(
|
|
|
1306
1524
|
config,
|
|
1307
1525
|
authCtx,
|
|
1308
1526
|
route.provider,
|
|
1527
|
+
route.modelId,
|
|
1309
1528
|
logCtx,
|
|
1310
1529
|
req.headers.get("x-codex-parent-thread-id"),
|
|
1311
1530
|
);
|
|
@@ -1314,7 +1533,7 @@ export async function handleResponses(
|
|
|
1314
1533
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1315
1534
|
// primary was the 5h window; it now carries weekly data for GPT plans.
|
|
1316
1535
|
// Prefer primary when present, fall back to secondary for compatibility.
|
|
1317
|
-
const
|
|
1536
|
+
const quotaMeta = codexQuotaOutcomeMeta(upstreamResponse);
|
|
1318
1537
|
const { applyAccountQuotaFromUpstreamHeaders } = await import("../../codex/auth-api");
|
|
1319
1538
|
applyAccountQuotaFromUpstreamHeaders(authCtx.accountId, upstreamResponse.headers);
|
|
1320
1539
|
if (terminalBodyWillRecord) {
|
|
@@ -1338,16 +1557,16 @@ export async function handleResponses(
|
|
|
1338
1557
|
}
|
|
1339
1558
|
options.onNativePassthroughTerminal?.(status);
|
|
1340
1559
|
});
|
|
1341
|
-
} else
|
|
1560
|
+
} else if (!shouldDeferCodexResetDerivedCooldown(
|
|
1561
|
+
upstreamResponse,
|
|
1562
|
+
options.deferCodexResetDerivedCooldown,
|
|
1563
|
+
)) {
|
|
1342
1564
|
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),
|
|
1565
|
+
...quotaMeta,
|
|
1566
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1567
|
+
modelId: route.modelId,
|
|
1568
|
+
probeLeaseId: codexProbeLeaseId(authCtx),
|
|
1569
|
+
probeQuotaScope: codexProbeQuotaScope(authCtx),
|
|
1351
1570
|
});
|
|
1352
1571
|
}
|
|
1353
1572
|
}
|
|
@@ -1381,8 +1600,9 @@ export async function handleResponses(
|
|
|
1381
1600
|
// known-bad runtime remains the tee path below.
|
|
1382
1601
|
if (isEventStream && upstreamResponse.body) {
|
|
1383
1602
|
const repairConfig = route.provider.responsesItemIdRepair;
|
|
1384
|
-
const
|
|
1385
|
-
const
|
|
1603
|
+
const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig);
|
|
1604
|
+
const winNoClientRewrite = process.platform === "win32" && !needsClientRewrite;
|
|
1605
|
+
const eagerDecision = winNoClientRewrite ? decideEagerRelay(config.streamMode ?? "auto") : null;
|
|
1386
1606
|
if (eagerDecision?.useEagerRelay) {
|
|
1387
1607
|
const turnAc = new AbortController();
|
|
1388
1608
|
linkAbortSignal(upstream, turnAc.signal);
|
|
@@ -1433,6 +1653,8 @@ export async function handleResponses(
|
|
|
1433
1653
|
onClientCancel: () => options.onNativePassthroughCancel?.(),
|
|
1434
1654
|
onDone: () => unregisterTurn(turnAc),
|
|
1435
1655
|
});
|
|
1656
|
+
// The eager branch is reachable only through winNoClientRewrite, so it must stay a pure
|
|
1657
|
+
// native relay without an image-gen or item-id JS pull wrapper on win32 (Bun#32111).
|
|
1436
1658
|
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
1437
1659
|
return markNativePassthroughSseResponse(new Response(eagerBody, {
|
|
1438
1660
|
status: upstreamResponse.status,
|
|
@@ -1492,12 +1714,19 @@ export async function handleResponses(
|
|
|
1492
1714
|
// win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
|
|
1493
1715
|
// relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
|
|
1494
1716
|
// mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
|
|
1495
|
-
|
|
1496
|
-
|
|
1717
|
+
// Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
|
|
1718
|
+
const payloadRewrites = [
|
|
1719
|
+
createImageGenCallRestoreRewrite(imageGenCallAliases),
|
|
1720
|
+
hasResponsesItemIdRepair(repairConfig)
|
|
1721
|
+
? createResponsesItemIdPayloadRewrite(repairConfig!)
|
|
1722
|
+
: undefined,
|
|
1723
|
+
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
|
|
1724
|
+
const rewrittenBody = payloadRewrites.length > 0
|
|
1725
|
+
? relaySseWithPayloadRewrite(nativeBody, composeSsePayloadRewrites(...payloadRewrites))
|
|
1497
1726
|
: nativeBody;
|
|
1498
|
-
const clientBody = process.platform === "win32" && !
|
|
1727
|
+
const clientBody = process.platform === "win32" && !needsClientRewrite
|
|
1499
1728
|
? nativeBody
|
|
1500
|
-
: relaySseWithFailedTail(
|
|
1729
|
+
: relaySseWithFailedTail(rewrittenBody, upstream);
|
|
1501
1730
|
return markNativePassthroughSseResponse(new Response(clientBody, {
|
|
1502
1731
|
status: upstreamResponse.status,
|
|
1503
1732
|
headers,
|
|
@@ -1511,7 +1740,7 @@ export async function handleResponses(
|
|
|
1511
1740
|
rememberPassthroughResponse(JSON.parse(text) as { id?: unknown; output?: unknown; status?: unknown });
|
|
1512
1741
|
} catch { /* non-JSON despite content-type; recording is best-effort */ }
|
|
1513
1742
|
}
|
|
1514
|
-
return new Response(text, {
|
|
1743
|
+
return new Response(restoreImageGenCallsInJson(text, imageGenCallAliases), {
|
|
1515
1744
|
status: upstreamResponse.status,
|
|
1516
1745
|
statusText: upstreamResponse.statusText,
|
|
1517
1746
|
headers,
|
|
@@ -1526,6 +1755,195 @@ export async function handleResponses(
|
|
|
1526
1755
|
});
|
|
1527
1756
|
}
|
|
1528
1757
|
|
|
1758
|
+
// Image / web-search sidecars: plan once, then dispatch with runTurn-aware priority.
|
|
1759
|
+
// Routed-compaction turns must NOT hit the image bridge: compaction clears tools/_webSearch but
|
|
1760
|
+
// leaves _imageGeneration, so planImageBridge would activate and return a normal Responses
|
|
1761
|
+
// completion instead of the synthetic compaction item Codex expects (#424).
|
|
1762
|
+
//
|
|
1763
|
+
// Web-search's loop only supports buildRequest/fetch/parseStream — NOT adapter.runTurn. Sending
|
|
1764
|
+
// Cursor/runTurn requests into runWithWebSearch produces empty HTTP failures. So:
|
|
1765
|
+
// - non-runTurn: web-search wins over image when both eligible (documented priority)
|
|
1766
|
+
// - runTurn: image bridge may run (it supports runTurn); web-search is skipped so runTurn
|
|
1767
|
+
// can proceed for web-search-only turns
|
|
1768
|
+
const wsPlan = !routedCompaction
|
|
1769
|
+
? planWebSearch(config, parsed, false, route.provider, route.modelId, openAiSidecar)
|
|
1770
|
+
: undefined;
|
|
1771
|
+
const imgPlan = !routedCompaction ? await planImageBridge(config, parsed, route.provider) : undefined;
|
|
1772
|
+
const vidPlan = !routedCompaction ? await planVideoBridge(config, parsed, route.provider) : undefined;
|
|
1773
|
+
const canRunWebSearch = !!wsPlan && !adapter.runTurn;
|
|
1774
|
+
if ((imgPlan || vidPlan) && canRunWebSearch) {
|
|
1775
|
+
// Web search takes priority when both are active — the media bridge cannot run
|
|
1776
|
+
// alongside runWithWebSearch. Surface a runtime signal so the user knows their
|
|
1777
|
+
// configured video/image bridge was skipped for this turn, rather than silently
|
|
1778
|
+
// dropping a paid capability.
|
|
1779
|
+
if (vidPlan) console.warn("[videos] video bridge skipped: web search is active for this turn");
|
|
1780
|
+
if (imgPlan) console.warn("[images] image bridge skipped: web search is active for this turn");
|
|
1781
|
+
}
|
|
1782
|
+
if ((imgPlan || vidPlan) && (!wsPlan || adapter.runTurn)) {
|
|
1783
|
+
// The image bridge detects a hosted image_generation tool and requires streaming.
|
|
1784
|
+
// The video bridge activates from config and injects a tool — it also needs streaming
|
|
1785
|
+
// (the loop returns SSE). For video-only (no imgPlan) on a non-streaming request, skip
|
|
1786
|
+
// the bridge entirely so enabling the feature doesn't break ordinary non-streaming traffic.
|
|
1787
|
+
if (!parsed.stream) {
|
|
1788
|
+
if (imgPlan) {
|
|
1789
|
+
return formatErrorResponse(400, "invalid_request_error", "image bridge requires stream=true");
|
|
1790
|
+
}
|
|
1791
|
+
// Video-only: skip bridge for non-streaming requests
|
|
1792
|
+
} else {
|
|
1793
|
+
// Replace any pre-existing image_gen/video_gen aliases instead of appending duplicate wire names.
|
|
1794
|
+
const priorTools = parsed.context.tools ?? [];
|
|
1795
|
+
const bridgeTools = [...priorTools.filter(t => {
|
|
1796
|
+
if (t.imageGeneration) return false;
|
|
1797
|
+
if (t.videoGeneration) return false;
|
|
1798
|
+
if (imgPlan && imgPlan.toolNames.has(t.name)) return false;
|
|
1799
|
+
if (imgPlan && t.namespace && imgPlan.toolNames.has(namespacedToolName(t.namespace, t.name))) return false;
|
|
1800
|
+
// Only strip unnamespaced video_gen aliases — a namespaced MCP video_gen is left alone.
|
|
1801
|
+
if (vidPlan && !t.namespace && vidPlan.toolNames.has(t.name)) return false;
|
|
1802
|
+
return true;
|
|
1803
|
+
})];
|
|
1804
|
+
const existingNames = new Set(bridgeTools.map(t => t.name));
|
|
1805
|
+
if (imgPlan && !existingNames.has(IMAGE_GEN_TOOL_NAME)) bridgeTools.push(buildImageTool());
|
|
1806
|
+
if (vidPlan && !existingNames.has(VIDEO_GEN_TOOL_NAME)) bridgeTools.push(buildVideoTool());
|
|
1807
|
+
parsed.context.tools = bridgeTools;
|
|
1808
|
+
// Hosted image_generation tool_choice / allowed_tools must target the synthetic function name.
|
|
1809
|
+
// Gate on imgPlan — in a video-only turn buildImageTool() was never injected, so rewriting
|
|
1810
|
+
// image_generation/image_gen aliases would add an undeclared tool that strict upstreams reject.
|
|
1811
|
+
const tc = parsed.options.toolChoice;
|
|
1812
|
+
if (imgPlan && tc && typeof tc === "object" && "allowedTools" in tc && Array.isArray(tc.allowedTools)) {
|
|
1813
|
+
const mapped = tc.allowedTools.map(name =>
|
|
1814
|
+
name === "image_generation" || name === "image_gen" || (imgPlan.toolNames.has(name) ?? false)
|
|
1815
|
+
? IMAGE_GEN_TOOL_NAME
|
|
1816
|
+
: name,
|
|
1817
|
+
);
|
|
1818
|
+
parsed.options.toolChoice = { ...tc, allowedTools: [...new Set(mapped)] };
|
|
1819
|
+
} else if (imgPlan && tc && typeof tc === "object" && "name" in tc && typeof tc.name === "string"
|
|
1820
|
+
&& (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) {
|
|
1821
|
+
parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME };
|
|
1822
|
+
}
|
|
1823
|
+
const imgResponse = await runWithImageBridge({
|
|
1824
|
+
parsed, adapter,
|
|
1825
|
+
...(imgPlan ? { plan: imgPlan } : {}),
|
|
1826
|
+
...(vidPlan ? { videoPlan: vidPlan } : {}),
|
|
1827
|
+
forwardHeaders: selectedForwardHeaders,
|
|
1828
|
+
onAttemptSend: () => noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens),
|
|
1829
|
+
abortSignal: options.abortSignal,
|
|
1830
|
+
maxRounds: imgPlan && vidPlan
|
|
1831
|
+
? clampImageMaxRounds(Math.min(config.images?.maxRounds ?? 3, config.images?.videoMaxRounds ?? 2))
|
|
1832
|
+
: imgPlan
|
|
1833
|
+
? clampImageMaxRounds(config.images?.maxRounds)
|
|
1834
|
+
: clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
|
|
1835
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
1836
|
+
stallTimeoutSec: config.stallTimeoutSec,
|
|
1837
|
+
fetchImpl: providerFetch(route.provider),
|
|
1838
|
+
onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
|
|
1839
|
+
...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
|
|
1840
|
+
onUsage: usage => {
|
|
1841
|
+
// Cursor may assign _cursorConversationId inside the image loop's first runTurn;
|
|
1842
|
+
// backfill so Logs can filter/total that opening request (parity with the normal
|
|
1843
|
+
// runTurn branch).
|
|
1844
|
+
if (!logCtx.conversationId && parsed._cursorConversationId) {
|
|
1845
|
+
logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId);
|
|
1846
|
+
}
|
|
1847
|
+
logCtx.usageFromBridge = true;
|
|
1848
|
+
if (usage) {
|
|
1849
|
+
logCtx.usage = usage;
|
|
1850
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
1851
|
+
}
|
|
1852
|
+
},
|
|
1853
|
+
on429: retryAfter => {
|
|
1854
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
1855
|
+
retryAfter,
|
|
1856
|
+
now: Date.now(),
|
|
1857
|
+
attemptedKey: route.provider.apiKey,
|
|
1858
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
1859
|
+
});
|
|
1860
|
+
if (!rotated) return null;
|
|
1861
|
+
route.provider = rotated;
|
|
1862
|
+
return resolveAdapter(
|
|
1863
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
1864
|
+
config.cacheRetention,
|
|
1865
|
+
);
|
|
1866
|
+
},
|
|
1867
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
1868
|
+
...(options.forceEmptyResponseId ? { forceEmptyResponseId: true } : {}),
|
|
1869
|
+
onCompletedResponse: (response, providerState) =>
|
|
1870
|
+
rememberResponseState(
|
|
1871
|
+
parsed._rawBody,
|
|
1872
|
+
response,
|
|
1873
|
+
continuationStateForResponse(providerState),
|
|
1874
|
+
adapterNeedsForcedContinuation(adapter.name) ? { force: true } : undefined,
|
|
1875
|
+
),
|
|
1876
|
+
});
|
|
1877
|
+
if (imgResponse.body) {
|
|
1878
|
+
const imgTurnAc = new AbortController();
|
|
1879
|
+
return new Response(trackStreamLifetime(imgResponse.body, imgTurnAc), {
|
|
1880
|
+
status: imgResponse.status,
|
|
1881
|
+
headers: imgResponse.headers,
|
|
1882
|
+
});
|
|
1883
|
+
}
|
|
1884
|
+
return imgResponse;
|
|
1885
|
+
} // end else (streaming bridge)
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
|
|
1889
|
+
// run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
|
|
1890
|
+
// through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
|
|
1891
|
+
// Placed BEFORE the runTurn early-return for non-runTurn adapters so dual-tool turns dispatch
|
|
1892
|
+
// through web-search instead of being swallowed. runTurn adapters never enter this branch.
|
|
1893
|
+
if (canRunWebSearch && wsPlan) {
|
|
1894
|
+
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
1895
|
+
noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens);
|
|
1896
|
+
const wsResponse = await runWithWebSearch({
|
|
1897
|
+
parsed, adapter,
|
|
1898
|
+
backend: wsPlan.backend,
|
|
1899
|
+
forwardProvider: wsPlan.forwardSidecar?.provider,
|
|
1900
|
+
anthropicSidecar: wsPlan.anthropicSidecar,
|
|
1901
|
+
hostedTool: wsPlan.hostedTool,
|
|
1902
|
+
selectedForwardHeaders: wsPlan.forwardSidecar?.headers ?? selectedForwardHeaders,
|
|
1903
|
+
settings: wsPlan.settings,
|
|
1904
|
+
maxSearches: wsPlan.maxSearches,
|
|
1905
|
+
forceEmptyResponseId: true,
|
|
1906
|
+
abortSignal: options.abortSignal,
|
|
1907
|
+
...(options.onFirstOutput ? { onFirstOutput: options.onFirstOutput } : {}),
|
|
1908
|
+
onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
|
|
1909
|
+
onUsage: usage => {
|
|
1910
|
+
logCtx.usageFromBridge = true;
|
|
1911
|
+
if (usage) {
|
|
1912
|
+
logCtx.usage = usage;
|
|
1913
|
+
if (logCtx.activeAttempt) logCtx.activeAttempt.usage = usage;
|
|
1914
|
+
}
|
|
1915
|
+
},
|
|
1916
|
+
recordSidecarOutcome: wsPlan.forwardSidecar?.recordOutcome,
|
|
1917
|
+
connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
|
|
1918
|
+
routedModelStallTimeoutMs: wsPlan.routedModelStallTimeoutMs,
|
|
1919
|
+
stallTimeoutSec: wsPlan.stallTimeoutSec,
|
|
1920
|
+
on429: retryAfter => {
|
|
1921
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
1922
|
+
retryAfter,
|
|
1923
|
+
now: Date.now(),
|
|
1924
|
+
attemptedKey: route.provider.apiKey,
|
|
1925
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
1926
|
+
});
|
|
1927
|
+
if (!rotated) return null;
|
|
1928
|
+
route.provider = rotated;
|
|
1929
|
+
return resolveAdapter(
|
|
1930
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
1931
|
+
config.cacheRetention,
|
|
1932
|
+
);
|
|
1933
|
+
},
|
|
1934
|
+
});
|
|
1935
|
+
// Register the sidecar stream as an active turn so drainAndShutdown waits for (or aborts)
|
|
1936
|
+
// in-flight web-search turns instead of skipping them during graceful shutdown.
|
|
1937
|
+
if (wsResponse.body) {
|
|
1938
|
+
const wsTurnAc = new AbortController();
|
|
1939
|
+
return new Response(trackStreamLifetime(wsResponse.body, wsTurnAc), {
|
|
1940
|
+
status: wsResponse.status,
|
|
1941
|
+
headers: wsResponse.headers,
|
|
1942
|
+
});
|
|
1943
|
+
}
|
|
1944
|
+
return wsResponse;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1529
1947
|
if (adapter.runTurn) {
|
|
1530
1948
|
const runTurnAbort = new AbortController();
|
|
1531
1949
|
linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
@@ -1646,64 +2064,6 @@ export async function handleResponses(
|
|
|
1646
2064
|
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
1647
2065
|
}
|
|
1648
2066
|
|
|
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
2067
|
const upstream = new AbortController();
|
|
1708
2068
|
const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
|
|
1709
2069
|
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
@@ -1806,6 +2166,9 @@ export async function handleResponses(
|
|
|
1806
2166
|
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
1807
2167
|
}
|
|
1808
2168
|
sentOAuthSnapshot = refreshed;
|
|
2169
|
+
if (route.providerName === "kiro") {
|
|
2170
|
+
parsed._kiroAuthContext = { ...(refreshed.kiro ?? {}) };
|
|
2171
|
+
}
|
|
1809
2172
|
const refreshedProvider = resolveProviderTransport(
|
|
1810
2173
|
route.providerName,
|
|
1811
2174
|
{ ...route.provider, apiKey: refreshed.accessToken },
|
|
@@ -1827,7 +2190,7 @@ export async function handleResponses(
|
|
|
1827
2190
|
// SAME request once per remaining key. OAuth/forward providers and single-key pools
|
|
1828
2191
|
// return null immediately, so this stays a no-op for them (src/providers/key-failover.ts).
|
|
1829
2192
|
while (upstreamResponse.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
1830
|
-
const rotated = rotateProviderTransportOn429(config, route.providerName, {
|
|
2193
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
1831
2194
|
retryAfter: upstreamResponse.headers.get("retry-after"),
|
|
1832
2195
|
now: Date.now(),
|
|
1833
2196
|
attemptedKey: route.provider.apiKey,
|
|
@@ -1846,6 +2209,42 @@ export async function handleResponses(
|
|
|
1846
2209
|
if ("failed" in result) return result.failed;
|
|
1847
2210
|
upstreamResponse = result;
|
|
1848
2211
|
}
|
|
2212
|
+
|
|
2213
|
+
// Opt-in Anthropic OAuth account pool (#294): cool the failed account and retry
|
|
2214
|
+
// with another eligible OAuth account (bounded per request). Disabled by default.
|
|
2215
|
+
while (
|
|
2216
|
+
upstreamResponse.status === 429
|
|
2217
|
+
&& anthropicPoolAccountId
|
|
2218
|
+
&& isAnthropicAccountPoolEnabled(config)
|
|
2219
|
+
&& anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
|
|
2220
|
+
) {
|
|
2221
|
+
const nextAccountId = rotateAnthropicAccountOn429(
|
|
2222
|
+
config,
|
|
2223
|
+
anthropicPoolAccountId,
|
|
2224
|
+
upstreamResponse.headers.get("retry-after"),
|
|
2225
|
+
anthropicSessionKey,
|
|
2226
|
+
);
|
|
2227
|
+
if (!nextAccountId) break;
|
|
2228
|
+
try { void upstreamResponse.body?.cancel().catch(() => {}); } catch { /* already consumed/closed */ }
|
|
2229
|
+
try {
|
|
2230
|
+
const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
|
|
2231
|
+
anthropicPoolAccountId = nextAccountId;
|
|
2232
|
+
anthropicPoolFailovers += 1;
|
|
2233
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
2234
|
+
promoteAnthropicActiveAccount(nextAccountId);
|
|
2235
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
2236
|
+
activeAdapter = resolveAdapter(
|
|
2237
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
2238
|
+
config.cacheRetention,
|
|
2239
|
+
);
|
|
2240
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
2241
|
+
const result = await rebuildAndRefetch("anthropic-oauth-429");
|
|
2242
|
+
if ("failed" in result) return result.failed;
|
|
2243
|
+
upstreamResponse = result;
|
|
2244
|
+
} catch {
|
|
2245
|
+
break;
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
1849
2248
|
// Anthropic 413 request_too_large: rebuild once with every image one tier lower
|
|
1850
2249
|
// (spiral guard: single attempt). The biased response re-enters the 429 check above.
|
|
1851
2250
|
if (shouldAttemptImageTierRetry({
|
|
@@ -1954,7 +2353,7 @@ export async function handleResponses(
|
|
|
1954
2353
|
}
|
|
1955
2354
|
|
|
1956
2355
|
if (response.status === 429 && hasKeyPoolFailover(route.provider)) {
|
|
1957
|
-
const rotated = rotateProviderTransportOn429(config, route.providerName, {
|
|
2356
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
1958
2357
|
retryAfter: response.headers.get("retry-after"),
|
|
1959
2358
|
now: Date.now(),
|
|
1960
2359
|
attemptedKey: route.provider.apiKey,
|
|
@@ -1970,6 +2369,38 @@ export async function handleResponses(
|
|
|
1970
2369
|
continue;
|
|
1971
2370
|
}
|
|
1972
2371
|
}
|
|
2372
|
+
if (
|
|
2373
|
+
response.status === 429
|
|
2374
|
+
&& anthropicPoolAccountId
|
|
2375
|
+
&& isAnthropicAccountPoolEnabled(config)
|
|
2376
|
+
&& anthropicPoolFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST
|
|
2377
|
+
) {
|
|
2378
|
+
const nextAccountId = rotateAnthropicAccountOn429(
|
|
2379
|
+
config,
|
|
2380
|
+
anthropicPoolAccountId,
|
|
2381
|
+
response.headers.get("retry-after"),
|
|
2382
|
+
anthropicSessionKey,
|
|
2383
|
+
);
|
|
2384
|
+
if (nextAccountId) {
|
|
2385
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
2386
|
+
try {
|
|
2387
|
+
const accessToken = await getAnthropicPoolAccessToken(nextAccountId);
|
|
2388
|
+
anthropicPoolAccountId = nextAccountId;
|
|
2389
|
+
anthropicPoolFailovers += 1;
|
|
2390
|
+
route.provider = { ...route.provider, apiKey: accessToken };
|
|
2391
|
+
promoteAnthropicActiveAccount(nextAccountId);
|
|
2392
|
+
logCtx.provider = formatAnthropicProviderForLog("anthropic", nextAccountId, config);
|
|
2393
|
+
activeAdapter = resolveAdapter(
|
|
2394
|
+
resolveWireProtocolOverride(route.providerName, route.modelId, route.provider),
|
|
2395
|
+
config.cacheRetention,
|
|
2396
|
+
);
|
|
2397
|
+
sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, activeAdapter.name);
|
|
2398
|
+
continue;
|
|
2399
|
+
} catch {
|
|
2400
|
+
// fall through to emit continuation error below
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
1973
2404
|
if (shouldAttemptImageTierRetry({
|
|
1974
2405
|
status: response.status,
|
|
1975
2406
|
adapterName: activeAdapter.name,
|