@bitkyc08/opencodex 2.88.0 → 2.90.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 +11 -1
- package/bin/ocx.mjs +15 -4
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic.ts +10 -3
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +62 -37
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +22 -13
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/adapters/registry.ts +1 -1
- package/src/adapters/upstream-http-error.ts +3 -2
- package/src/adapters/zed.ts +7 -4
- package/src/bridge/internal.ts +11 -1
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +10 -4
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +5 -0
- package/src/cli/account-api.ts +13 -1
- package/src/cli/account-extended.ts +17 -5
- package/src/cli/account-key-quota.ts +4 -1
- package/src/cli/account.ts +1 -1
- package/src/cli/capabilities-agents-routing.ts +45 -8
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-provider-models.ts +5 -4
- package/src/cli/combo-result.ts +8 -2
- package/src/cli/combo.ts +17 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/message-command.ts +4 -1
- package/src/cli/message-remote-args.ts +69 -0
- package/src/cli/message-remote-command.ts +77 -0
- package/src/cli/provider-runtime.ts +69 -1
- package/src/cli/registry.ts +3 -3
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +55 -9
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +13 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +57 -10
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/auto-review.ts +2 -1
- package/src/codex/catalog/build-entries.ts +17 -7
- package/src/codex/catalog/derive-entry.ts +3 -1
- package/src/codex/catalog/metadata.ts +2 -0
- package/src/codex/catalog/model-visibility.ts +0 -2
- package/src/codex/catalog/ownership.ts +71 -0
- package/src/codex/catalog/parsing.ts +12 -3
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/low-quota-desktop-notifier.ts +125 -0
- package/src/codex/low-quota-protection.ts +57 -31
- package/src/codex/main-account-cache.ts +77 -3
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/main-account.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +10 -2
- package/src/combos/jev-model-backend.ts +30 -7
- package/src/combos/jev-model-request.ts +20 -0
- package/src/combos/jev-quota-activation.ts +153 -0
- package/src/combos/jev-quota-config.ts +62 -0
- package/src/combos/jev-quota-route.ts +26 -0
- package/src/combos/jev-quota.ts +88 -0
- package/src/combos/jev.ts +25 -12
- package/src/combos/types.ts +27 -0
- package/src/compatibility/index.ts +3 -0
- package/src/compatibility/openai-responses.ts +8 -1
- package/src/compatibility/opengateway-responses.ts +61 -0
- package/src/config/account-auto-pause-revisions.ts +136 -0
- package/src/config/diagnostics.ts +13 -0
- package/src/config/live-reconcile.ts +9 -0
- package/src/config/persist-unlocked.ts +10 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/config/removed-opencode-free.ts +135 -0
- package/src/config/schema/account-auto-pause.ts +14 -0
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +7 -0
- package/src/config.ts +10 -3
- package/src/generated/compatibility-version.json +476 -216
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +34 -11
- package/src/lib/account-auto-pause-response.ts +16 -0
- package/src/lib/bun-runtime.ts +18 -1
- package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/ssh-argv.ts +3 -0
- package/src/messaging/envelope.ts +6 -3
- package/src/messaging/remote-auth.ts +83 -0
- package/src/messaging/remote-bridge.ts +270 -0
- package/src/messaging/remote-contract.ts +108 -0
- package/src/messaging/remote-enrollment-recovery.ts +59 -0
- package/src/messaging/remote-enrollment.ts +244 -0
- package/src/messaging/remote-envelope.ts +40 -0
- package/src/messaging/remote-files.ts +117 -0
- package/src/messaging/remote-owner.ts +169 -0
- package/src/messaging/remote-ports.ts +56 -0
- package/src/messaging/remote-process.ts +131 -0
- package/src/messaging/remote-replies.ts +98 -0
- package/src/messaging/remote-rpc-admission.ts +32 -0
- package/src/messaging/remote-send.ts +72 -0
- package/src/messaging/remote-store.ts +111 -0
- package/src/messaging/remote-tunnels.ts +65 -0
- package/src/messaging/rpc.ts +13 -1
- package/src/oauth/account-auto-pause-policy.ts +66 -0
- package/src/oauth/account-auto-pause.ts +171 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/account-quota-rank.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +6 -0
- package/src/oauth/anthropic-model-family.ts +8 -0
- package/src/oauth/anthropic-model-quota.ts +2 -6
- package/src/oauth/api-key-pool-preserve.ts +37 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +5 -35
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/providers/api-key-pause.ts +32 -0
- package/src/providers/api-key-resolve.ts +10 -4
- package/src/providers/api-key-selection.ts +6 -1
- package/src/providers/api-keys.ts +8 -4
- package/src/providers/claude-cli-usage.ts +25 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +21 -13
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/opencode-zen-rate-limit.ts +6 -72
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +20 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
- package/src/providers/quota/anthropic-decision-windows.ts +47 -0
- package/src/providers/quota/antigravity.ts +45 -8
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +15 -6
- package/src/providers/quota-decision-publication.ts +77 -0
- package/src/providers/quota-decision-snapshot.ts +145 -0
- package/src/providers/quota-routing-cache.ts +4 -1
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +8 -3
- package/src/providers/registry/entries-extended.ts +14 -53
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +147 -0
- package/src/routing/compatibility/behavior.ts +4 -1
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +2 -1
- package/src/server/chat-native.ts +36 -20
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +12 -2
- package/src/server/management/provider-completion-test.ts +289 -0
- package/src/server/management/provider-routes.ts +9 -1
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/messages-native.ts +29 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +23 -9
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +79 -30
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +10 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +28 -15
- package/src/server/responses/core-combo.ts +34 -5
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +10 -0
- package/src/server/responses/fetch-helpers.ts +65 -14
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +5 -2
- package/src/server/responses/passthrough-dispatch.ts +42 -45
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +92 -5
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +16 -3
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/startup-action-control.ts +2 -2
- package/src/server/startup-health-cache.ts +2 -2
- package/src/server/windows-tray-control.ts +2 -2
- package/src/service/definition.ts +9 -1
- package/src/service/orchestration.ts +17 -0
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +11 -5
- package/src/types/request.ts +36 -3
- package/src/types.ts +2 -0
- package/src/usage/cost.ts +17 -1
- package/src/usage/expected-prices.ts +3 -1
- package/src/usage/jev-stats.ts +4 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +7 -0
- package/src/web-search/loop.ts +39 -13
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -3
- package/src/web-search/sidecar-search-replay.ts +96 -0
- package/gui/dist/assets/App-CHC89EXQ.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { guardResponseBodyInactivity } from "../../lib/response-body-inactivity";
|
|
2
|
+
import { isNonReplayableResponse } from "../../lib/upstream-retry";
|
|
3
|
+
import { isPlainObject } from "../responses-snapshot-codec";
|
|
4
|
+
import { BoundedSseFrameBuffer, joinSseFrameBytes } from "../sse-frame-buffer";
|
|
5
|
+
import { sseDataPayload } from "../sse-payload-rewrite";
|
|
6
|
+
import { EMPTY_COMPLETION_MAX_BUFFERED_BYTES, EMPTY_COMPLETION_MAX_BUFFERED_EVENTS, EMPTY_COMPLETION_RETRY_ENV } from "./empty-completion-guard";
|
|
7
|
+
import { selfContainedResponsesBody } from "./reset-replay";
|
|
8
|
+
import { carryResponseToolOwnership } from "./response-tool-ownership";
|
|
9
|
+
|
|
10
|
+
const MAX_BYTES = EMPTY_COMPLETION_MAX_BUFFERED_BYTES;
|
|
11
|
+
const MAX_EVENTS = EMPTY_COMPLETION_MAX_BUFFERED_EVENTS;
|
|
12
|
+
const MAX_HOLD_MS = 30_000;
|
|
13
|
+
const decoder = new TextDecoder();
|
|
14
|
+
const reasoningEvents = new Set([
|
|
15
|
+
"response.reasoning_summary_part.added", "response.reasoning_summary_part.done",
|
|
16
|
+
"response.reasoning_summary_text.delta", "response.reasoning_summary_text.done",
|
|
17
|
+
"response.reasoning_text.delta", "response.reasoning_text.done",
|
|
18
|
+
]);
|
|
19
|
+
type RecordValue = Record<string, unknown>;
|
|
20
|
+
|
|
21
|
+
function partCommits(part: unknown): boolean {
|
|
22
|
+
return !isPlainObject(part) || part.type !== "output_text" || part.text !== ""
|
|
23
|
+
|| (part.annotations !== undefined && (!Array.isArray(part.annotations) || part.annotations.length > 0));
|
|
24
|
+
}
|
|
25
|
+
function outputCommits(output: unknown): boolean {
|
|
26
|
+
if (output === undefined) return false;
|
|
27
|
+
return !Array.isArray(output) || output.some(item => !isPlainObject(item)
|
|
28
|
+
|| (item.type !== "reasoning" && (item.type !== "message" || !Array.isArray(item.content) || item.content.some(partCommits))));
|
|
29
|
+
}
|
|
30
|
+
function wellFormedEmpty(snapshot: unknown): snapshot is RecordValue {
|
|
31
|
+
return isPlainObject(snapshot) && typeof snapshot.id === "string" && snapshot.id.trim().length > 0
|
|
32
|
+
&& snapshot.object === "response" && snapshot.status === "completed" && Array.isArray(snapshot.output)
|
|
33
|
+
&& snapshot.error == null && snapshot.incomplete_details == null
|
|
34
|
+
&& snapshot.output.every(item => isPlainObject(item) && typeof item.id === "string" && item.id.length > 0
|
|
35
|
+
&& (item.type === "reasoning" || (item.type === "message" && item.role === "assistant"
|
|
36
|
+
&& item.status === "completed" && Array.isArray(item.content) && !item.content.some(partCommits))));
|
|
37
|
+
}
|
|
38
|
+
function eventCommits(event: RecordValue): boolean {
|
|
39
|
+
if (event.replayUnsafe === true) return true;
|
|
40
|
+
switch (event.type) {
|
|
41
|
+
case "response.created": case "response.in_progress": case "response.queued": case "response.heartbeat":
|
|
42
|
+
return isPlainObject(event.response) && outputCommits(event.response.output);
|
|
43
|
+
case "response.output_item.added": case "response.output_item.done": return outputCommits([event.item]);
|
|
44
|
+
case "response.content_part.added": case "response.content_part.done": return partCommits(event.part);
|
|
45
|
+
case "response.output_text.delta": return event.delta !== "";
|
|
46
|
+
case "response.output_text.done": return event.text !== "";
|
|
47
|
+
default: return !reasoningEvents.has(String(event.type));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface OpenGatewayCompletionGuardOptions {
|
|
52
|
+
readonly url: string;
|
|
53
|
+
readonly body: unknown;
|
|
54
|
+
readonly signal: AbortSignal;
|
|
55
|
+
readonly retry: () => Promise<Response>;
|
|
56
|
+
readonly recordUsage: (usage: unknown) => void;
|
|
57
|
+
readonly retryEnabled?: boolean;
|
|
58
|
+
readonly stallTimeoutMs?: number;
|
|
59
|
+
/** Expiry releases original bytes; it never aborts a healthy upstream. */
|
|
60
|
+
readonly holdTimeoutMs?: number;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
type Inspection = { kind: "pass"; response: Response }
|
|
64
|
+
| { kind: "empty" | "invalid"; snapshot: RecordValue; streaming: boolean };
|
|
65
|
+
|
|
66
|
+
async function inspect(response: Response, options: OpenGatewayCompletionGuardOptions): Promise<Inspection> {
|
|
67
|
+
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
68
|
+
const streaming = contentType.includes("text/event-stream")
|
|
69
|
+
|| (!contentType && isPlainObject(options.body) && options.body.stream === true);
|
|
70
|
+
if (!response.body || (!streaming && !contentType.includes("application/json"))) return { kind: "pass", response };
|
|
71
|
+
const guarded = guardResponseBodyInactivity(response, options.signal, streaming ? options.stallTimeoutMs ?? 0 : 0);
|
|
72
|
+
const reader = guarded.response.body?.getReader();
|
|
73
|
+
if (!reader) return { kind: "pass", response };
|
|
74
|
+
const framer = new BoundedSseFrameBuffer(MAX_BYTES);
|
|
75
|
+
const held: Array<Uint8Array | undefined> = [];
|
|
76
|
+
let bytes = 0, events = 0, transferred = false, cleaned = false;
|
|
77
|
+
let pending: ReturnType<typeof reader.read> | undefined;
|
|
78
|
+
const holdMs = options.holdTimeoutMs ?? MAX_HOLD_MS;
|
|
79
|
+
const deadline = performance.now() + holdMs;
|
|
80
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
81
|
+
const expired = new Promise<"expired">(resolve => { timer = setTimeout(() => resolve("expired"), holdMs); });
|
|
82
|
+
const dropHeld = (): void => {
|
|
83
|
+
for (let index = 0; index < held.length; index += 1) held[index] = undefined;
|
|
84
|
+
};
|
|
85
|
+
const finishOwned = (): void => {
|
|
86
|
+
if (cleaned) return;
|
|
87
|
+
cleaned = true;
|
|
88
|
+
guarded.dispose();
|
|
89
|
+
try { reader.releaseLock(); }
|
|
90
|
+
catch (error) { if (!(error instanceof TypeError)) throw error; }
|
|
91
|
+
};
|
|
92
|
+
const cancelOwned = (reason?: unknown): void => {
|
|
93
|
+
if (cleaned) return;
|
|
94
|
+
cleaned = true;
|
|
95
|
+
dropHeld();
|
|
96
|
+
try { void reader.cancel(reason).catch(() => {}); }
|
|
97
|
+
catch (error) { if (!(error instanceof TypeError)) throw error; }
|
|
98
|
+
guarded.dispose();
|
|
99
|
+
try { reader.releaseLock(); }
|
|
100
|
+
catch (error) { if (!(error instanceof TypeError)) throw error; }
|
|
101
|
+
};
|
|
102
|
+
const pass = (): Inspection => {
|
|
103
|
+
transferred = true;
|
|
104
|
+
let index = 0;
|
|
105
|
+
const body = new ReadableStream<Uint8Array>({
|
|
106
|
+
async pull(controller) {
|
|
107
|
+
if (index < held.length) {
|
|
108
|
+
const chunk = held[index];
|
|
109
|
+
held[index] = undefined;
|
|
110
|
+
index += 1;
|
|
111
|
+
if (chunk) controller.enqueue(chunk);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const next = await (pending ?? reader.read());
|
|
116
|
+
pending = undefined;
|
|
117
|
+
if (next.done) { finishOwned(); controller.close(); }
|
|
118
|
+
else controller.enqueue(next.value);
|
|
119
|
+
} catch (error) { cancelOwned(error); controller.error(error); }
|
|
120
|
+
},
|
|
121
|
+
cancel(reason) { dropHeld(); cancelOwned(reason); },
|
|
122
|
+
}, { highWaterMark: 0 });
|
|
123
|
+
return { kind: "pass", response: carryResponseToolOwnership(response, new Response(body, {
|
|
124
|
+
status: response.status, statusText: response.statusText, headers: response.headers,
|
|
125
|
+
})) };
|
|
126
|
+
};
|
|
127
|
+
const terminal = (snapshot: unknown): Inspection => ({
|
|
128
|
+
kind: wellFormedEmpty(snapshot) ? "empty" : "invalid",
|
|
129
|
+
snapshot: isPlainObject(snapshot) ? snapshot : {}, streaming,
|
|
130
|
+
});
|
|
131
|
+
try {
|
|
132
|
+
for (;;) {
|
|
133
|
+
if (performance.now() >= deadline) return pass();
|
|
134
|
+
pending = reader.read();
|
|
135
|
+
let next: Awaited<typeof pending> | "expired";
|
|
136
|
+
try { next = await Promise.race([pending, expired]); }
|
|
137
|
+
catch { return pass(); }
|
|
138
|
+
if (next === "expired") return pass();
|
|
139
|
+
pending = undefined;
|
|
140
|
+
if (!next.done) {
|
|
141
|
+
if (bytes + next.value.byteLength > MAX_BYTES || held.length >= MAX_EVENTS) {
|
|
142
|
+
held.push(next.value);
|
|
143
|
+
return pass();
|
|
144
|
+
}
|
|
145
|
+
held.push(next.value.slice());
|
|
146
|
+
bytes += next.value.byteLength;
|
|
147
|
+
}
|
|
148
|
+
if (!streaming) {
|
|
149
|
+
if (!next.done) continue;
|
|
150
|
+
let snapshot: unknown;
|
|
151
|
+
try { snapshot = JSON.parse(decoder.decode(joinSseFrameBytes(held.flatMap(chunk => chunk ? [chunk] : [])))); }
|
|
152
|
+
catch { return terminal(undefined); }
|
|
153
|
+
if (isPlainObject(snapshot) && (outputCommits(snapshot.output)
|
|
154
|
+
|| snapshot.error != null || ["failed", "incomplete", "queued", "in_progress"].includes(String(snapshot.status)))) return pass();
|
|
155
|
+
return terminal(snapshot);
|
|
156
|
+
}
|
|
157
|
+
let frames;
|
|
158
|
+
try { frames = next.done ? [{ block: framer.finish() }] : framer.feed(next.value); }
|
|
159
|
+
catch { return pass(); }
|
|
160
|
+
for (const raw of frames) {
|
|
161
|
+
if (raw.block.byteLength === 0) continue;
|
|
162
|
+
if (++events >= MAX_EVENTS) return pass();
|
|
163
|
+
const block = decoder.decode(raw.block), data = sseDataPayload(block);
|
|
164
|
+
if (data === null) { if (/^event:/m.test(block)) return pass(); continue; }
|
|
165
|
+
let event: unknown;
|
|
166
|
+
try { event = JSON.parse(data); } catch { return pass(); }
|
|
167
|
+
if (!isPlainObject(event)) return pass();
|
|
168
|
+
if (event.replayUnsafe === true) return pass();
|
|
169
|
+
if (event.type === "response.completed") {
|
|
170
|
+
if (isPlainObject(event.response) && outputCommits(event.response.output)) return pass();
|
|
171
|
+
return terminal(event.response);
|
|
172
|
+
}
|
|
173
|
+
if (eventCommits(event)) return pass();
|
|
174
|
+
}
|
|
175
|
+
if (next.done) return pass();
|
|
176
|
+
}
|
|
177
|
+
} finally {
|
|
178
|
+
clearTimeout(timer); framer.dispose();
|
|
179
|
+
if (!transferred) cancelOwned();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function terminalResponse(source: Response, inspection: Exclude<Inspection, { kind: "pass" }>, reason: string): Response {
|
|
184
|
+
const incomplete = reason === "max_output_tokens";
|
|
185
|
+
const snapshot: RecordValue = { ...inspection.snapshot, status: incomplete ? "incomplete" : "failed" };
|
|
186
|
+
if (incomplete) {
|
|
187
|
+
snapshot.incomplete_details = { reason };
|
|
188
|
+
delete snapshot.error;
|
|
189
|
+
} else {
|
|
190
|
+
snapshot.error = {
|
|
191
|
+
type: "upstream_error", code: reason, message: "OpenGateway returned no usable completion before output.",
|
|
192
|
+
};
|
|
193
|
+
delete snapshot.incomplete_details;
|
|
194
|
+
}
|
|
195
|
+
const headers = new Headers(source.headers);
|
|
196
|
+
headers.delete("content-length"); headers.delete("content-encoding");
|
|
197
|
+
if (inspection.streaming) headers.set("content-type", "text/event-stream");
|
|
198
|
+
const text = inspection.streaming
|
|
199
|
+
? `event: response.${snapshot.status}\ndata: ${JSON.stringify({ type: `response.${snapshot.status}`, response: snapshot })}\n\n`
|
|
200
|
+
: JSON.stringify(snapshot);
|
|
201
|
+
return carryResponseToolOwnership(source, new Response(text, {
|
|
202
|
+
status: inspection.streaming || incomplete ? 200 : 502, headers,
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function guardOpenGatewayCompletion(response: Response, options: OpenGatewayCompletionGuardOptions): Promise<Response> {
|
|
207
|
+
if (options.url !== "https://apis.opengateway.ai/v1/responses" || response.status !== 200) return response;
|
|
208
|
+
let body: unknown;
|
|
209
|
+
try { body = typeof options.body === "string" ? JSON.parse(options.body) : options.body; }
|
|
210
|
+
catch { return response; }
|
|
211
|
+
if (!isPlainObject(body)) return response;
|
|
212
|
+
const canReplay = options.retryEnabled !== false && process.env[EMPTY_COMPLETION_RETRY_ENV] !== "0"
|
|
213
|
+
&& selfContainedResponsesBody(body) && !isNonReplayableResponse(response);
|
|
214
|
+
let selected = response;
|
|
215
|
+
for (let attempt = 0; ; attempt++) {
|
|
216
|
+
const inspected = await inspect(selected, options);
|
|
217
|
+
if (inspected.kind === "pass") return inspected.response;
|
|
218
|
+
const usage = inspected.snapshot.usage;
|
|
219
|
+
options.recordUsage(usage);
|
|
220
|
+
if (inspected.kind === "invalid") return terminalResponse(selected, inspected, "invalid_completion");
|
|
221
|
+
if (typeof body.max_output_tokens === "number" && body.max_output_tokens > 0
|
|
222
|
+
&& isPlainObject(usage) && typeof usage.output_tokens === "number" && usage.output_tokens >= body.max_output_tokens) {
|
|
223
|
+
return terminalResponse(selected, inspected, "max_output_tokens");
|
|
224
|
+
}
|
|
225
|
+
if (attempt > 0 || !canReplay || options.signal.aborted) {
|
|
226
|
+
return terminalResponse(selected, inspected, attempt > 0
|
|
227
|
+
? "empty_completion_retry_failed" : "empty_completion");
|
|
228
|
+
}
|
|
229
|
+
selected = await options.retry();
|
|
230
|
+
if (selected.status !== 200) return selected;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { AdapterRequest } from "../../adapters/base";
|
|
2
|
+
import { formatErrorResponse } from "../../bridge";
|
|
3
|
+
import { resolveStallTimeoutMs } from "../../stall-timeout";
|
|
4
|
+
import type { OcxConfig } from "../../types";
|
|
5
|
+
import { recordKeyWireAttemptUsage, usageFromResponsesPayload, type RequestLogContext } from "../request-log";
|
|
6
|
+
import { isPlainObject } from "../responses-snapshot-codec";
|
|
7
|
+
import { guardOpenGatewayCompletion } from "./opengateway-completion-guard";
|
|
8
|
+
|
|
9
|
+
export function parseOutboundRequestBody(bodyText: string): Record<string, unknown> | undefined {
|
|
10
|
+
try {
|
|
11
|
+
const body: unknown = JSON.parse(bodyText);
|
|
12
|
+
return isPlainObject(body) ? body : undefined;
|
|
13
|
+
} catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function guardPassthroughCompletion(
|
|
19
|
+
response: Response,
|
|
20
|
+
request: AdapterRequest,
|
|
21
|
+
config: Pick<OcxConfig, "emptyCompletionRetry" | "stallTimeoutSec">,
|
|
22
|
+
signal: AbortSignal,
|
|
23
|
+
logCtx: RequestLogContext,
|
|
24
|
+
rebuildAndRefetch: (kind: "empty-completion") => Promise<Response | { failed: Response | { kind: "adapter-handoff" } }>,
|
|
25
|
+
): Promise<Response> {
|
|
26
|
+
if (request.url !== "https://apis.opengateway.ai/v1/responses") return Promise.resolve(response);
|
|
27
|
+
return guardOpenGatewayCompletion(response, {
|
|
28
|
+
url: request.url,
|
|
29
|
+
body: parseOutboundRequestBody(request.body),
|
|
30
|
+
signal,
|
|
31
|
+
retryEnabled: config.emptyCompletionRetry,
|
|
32
|
+
stallTimeoutMs: resolveStallTimeoutMs(config.stallTimeoutSec),
|
|
33
|
+
recordUsage: usage => { recordKeyWireAttemptUsage(logCtx, usageFromResponsesPayload(usage)); },
|
|
34
|
+
retry: async () => {
|
|
35
|
+
const result = await rebuildAndRefetch("empty-completion");
|
|
36
|
+
if (!("failed" in result)) return result;
|
|
37
|
+
return result.failed instanceof Response ? result.failed
|
|
38
|
+
: formatErrorResponse(502, "upstream_error", "Native recovery changed the provider wire");
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
|
|
1
2
|
import { isNativeControlResponse } from "./native-response-control";
|
|
2
3
|
import { createOutboundCredentialMask, createTerminalErrorRedactionBlockRewrite } from "./terminal-error-redaction";
|
|
3
4
|
import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options";
|
|
@@ -36,6 +37,7 @@ import type { ResponsesTerminalStatus } from "../../bridge";
|
|
|
36
37
|
import { isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./ws-upstream";
|
|
37
38
|
import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback";
|
|
38
39
|
import { recordCodexUpstreamOutcome } from "../../codex/routing";
|
|
40
|
+
import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
|
|
39
41
|
import { codexProbeLeaseId, codexProbeQuotaScope, codexTransientProbeGrant, releaseCodexAuthContextProbeLease } from "../../codex/auth-context";
|
|
40
42
|
import { consumeComboFailure } from "./core-combo-failure";
|
|
41
43
|
import { clientCancelledResponse, readDisplaySafeErrorText } from "./core-errors";
|
|
@@ -527,13 +529,14 @@ export async function deliverPassthroughResponse(
|
|
|
527
529
|
} else if (!shouldDeferCodexResetDerivedCooldown(
|
|
528
530
|
upstreamResponse,
|
|
529
531
|
options.deferCodexResetDerivedCooldown,
|
|
530
|
-
) && !isReplayRefusalResponse(upstreamResponse)) {
|
|
532
|
+
) && !isReplayRefusalResponse(upstreamResponse) && !wasPermanentAccountRefusal(upstreamResponse)) {
|
|
531
533
|
// A refusal this proxy made is not evidence about the account. Recording it would
|
|
532
534
|
// classify the synthetic 429 as quota exhaustion and write a default cooldown against
|
|
533
535
|
// a credential the request may never have reached, and that false signal outlives the
|
|
534
536
|
// request. The sibling recorders on this path already decline: the terminal recorder
|
|
535
537
|
// needs an ok streaming body, and the quota-header snapshot finds no quota headers.
|
|
536
538
|
recordCodexUpstreamOutcome(config, admissionState.authCtx.accountId, upstreamResponse.status, {
|
|
539
|
+
...codexFailureWindowOutcomeMeta(admissionState.authCtx),
|
|
537
540
|
...quotaMeta,
|
|
538
541
|
threadId: admissionState.authCtx.affinityKey,
|
|
539
542
|
fixedAccount: admissionState.authCtx.fixedAccount,
|
|
@@ -572,7 +575,7 @@ export async function deliverPassthroughResponse(
|
|
|
572
575
|
// through sanitizePassthroughHeaders) so a redirect to a dead host can never
|
|
573
576
|
// masquerade as a pre-connection failure after the credential was seen.
|
|
574
577
|
// The numeric outcome above already classified it neutral — no streak.
|
|
575
|
-
if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse)) {
|
|
578
|
+
if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse) && !wasPermanentAccountRefusal(upstreamResponse)) {
|
|
576
579
|
return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal);
|
|
577
580
|
}
|
|
578
581
|
if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) {
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { recoverAutoPausedAccount, rotatePassthroughKeyOn429 } from "./passthrough-key-rotation";
|
|
2
|
+
import { adoptCodexWsStage, capturePassthroughAffinity } from "./passthrough-observations";
|
|
1
3
|
import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
|
|
2
4
|
import { requestPacingOverloadResponse } from "./pacing-overload";
|
|
3
5
|
import { recordDistributedResponseFailure } from "../../providers/api-key-response-ownership";
|
|
@@ -51,6 +53,7 @@ import {
|
|
|
51
53
|
import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat";
|
|
52
54
|
import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
|
|
53
55
|
import { MuseToolChoiceCompatibilityError } from "../../adapters/openai-responses/muse-tool-choice";
|
|
56
|
+
import { OpenGatewayInputCompatibilityError } from "../../adapters/openai-responses/opengateway-input";
|
|
54
57
|
import { formatErrorResponse } from "../../bridge";
|
|
55
58
|
import { redactSecretString } from "../../lib/redact";
|
|
56
59
|
import {
|
|
@@ -99,7 +102,6 @@ import {
|
|
|
99
102
|
upstreamHostCircuitOpenResponse,
|
|
100
103
|
usesCodexForwardPoolAuth,
|
|
101
104
|
codexWsQuotaObserver,
|
|
102
|
-
isFixedCodexAccount,
|
|
103
105
|
codexPoolAccountModel400Denial,
|
|
104
106
|
shouldRetryCodexPoolAccountQuota,
|
|
105
107
|
shouldRetryCodexPoolAccountTransient,
|
|
@@ -111,7 +113,7 @@ import {
|
|
|
111
113
|
clearCodexModelDenialEvidence,
|
|
112
114
|
recordCodexModelDenialEvidence,
|
|
113
115
|
} from "../../codex/model-entitlements";
|
|
114
|
-
import { codexWsSocketDeathStage, isCodexWsUpstreamResponse
|
|
116
|
+
import { codexWsSocketDeathStage, isCodexWsUpstreamResponse } from "./codex-ws-wire";
|
|
115
117
|
import { linkAbortSignal } from "./core-lifetime";
|
|
116
118
|
import type { CodexAuthContext } from "../../codex/auth-context";
|
|
117
119
|
import { checkOutboundBodySize, describeOutboundBodyRefusal } from "./outbound-body-guard";
|
|
@@ -130,7 +132,8 @@ import {
|
|
|
130
132
|
} from "../../lib/upstream-retry";
|
|
131
133
|
import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
|
|
132
134
|
import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
|
|
133
|
-
import {
|
|
135
|
+
import { captureAccountAutoPauseAttempt, wasAccountAutoPaused, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
|
|
136
|
+
import { codexFailureWindowOutcomeMeta, recordCodexUpstreamOutcome } from "../../codex/routing";
|
|
134
137
|
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
135
138
|
import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
|
|
136
139
|
import {
|
|
@@ -152,7 +155,6 @@ import {
|
|
|
152
155
|
rotateGenericOAuthAccountOn429,
|
|
153
156
|
failoverAccountSnapshot,
|
|
154
157
|
} from "../../oauth/generic-account-failover";
|
|
155
|
-
import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug";
|
|
156
158
|
import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
|
|
157
159
|
import {
|
|
158
160
|
attemptOpaqueBlobRecovery,
|
|
@@ -171,6 +173,7 @@ import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION }
|
|
|
171
173
|
import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit";
|
|
172
174
|
import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata";
|
|
173
175
|
import { unboundPoolSpendRefusalResponse } from "../workflow-refusal";
|
|
176
|
+
import { guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion";
|
|
174
177
|
|
|
175
178
|
/** Transfer an uncommitted Copilot exchange to the existing translated pipeline without new admission. */
|
|
176
179
|
export interface PassthroughAdapterHandoff { kind: "adapter-handoff" }
|
|
@@ -201,6 +204,7 @@ export async function preparePassthroughExchange(
|
|
|
201
204
|
| "genericFailoverAccountId"
|
|
202
205
|
| "passiveQuotaWriterGeneration"
|
|
203
206
|
| "oauthDispatch"
|
|
207
|
+
| "recoverAutoPausedSelection"
|
|
204
208
|
| "resolveSelectionAdapter"
|
|
205
209
|
| "resolveCopilotSelection"
|
|
206
210
|
| "copilotRefusalResponse"
|
|
@@ -384,6 +388,7 @@ export async function preparePassthroughExchange(
|
|
|
384
388
|
|| error instanceof XaiToolSchemaCompatibilityError
|
|
385
389
|
|| error instanceof RoutedCustomToolCompatError
|
|
386
390
|
|| error instanceof MuseToolChoiceCompatibilityError
|
|
391
|
+
|| error instanceof OpenGatewayInputCompatibilityError
|
|
387
392
|
) {
|
|
388
393
|
return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
|
|
389
394
|
}
|
|
@@ -432,16 +437,6 @@ export async function preparePassthroughExchange(
|
|
|
432
437
|
// `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for
|
|
433
438
|
// `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there
|
|
434
439
|
// too because the proxy cannot establish the caller's declared authorization boundary.
|
|
435
|
-
const parseOutboundRequestBody = (bodyText: string): Record<string, unknown> | undefined => {
|
|
436
|
-
try {
|
|
437
|
-
const body = JSON.parse(bodyText) as unknown;
|
|
438
|
-
return body && typeof body === "object" && !Array.isArray(body)
|
|
439
|
-
? body as Record<string, unknown>
|
|
440
|
-
: undefined;
|
|
441
|
-
} catch {
|
|
442
|
-
return undefined;
|
|
443
|
-
}
|
|
444
|
-
};
|
|
445
440
|
let outboundRequestBody: Record<string, unknown> | undefined;
|
|
446
441
|
const declaredWireToolNames = new Set<string>();
|
|
447
442
|
const declaredBareWireToolNames = new Set<string>();
|
|
@@ -733,22 +728,11 @@ export async function preparePassthroughExchange(
|
|
|
733
728
|
}
|
|
734
729
|
nativeHostState.lease = null;
|
|
735
730
|
};
|
|
736
|
-
/**
|
|
737
|
-
* #4191: a Codex WS exchange pins its content-free stage record on the
|
|
738
|
-
* Response it resolves (markCodexWsStage). Adopting the record here, at
|
|
739
|
-
* the single funnel every physical upstream response passes through,
|
|
740
|
-
* binds it to the attempt that actually served it — including the 502/504
|
|
741
|
-
* pre-response JSON settles that never reach the SSE relay.
|
|
742
|
-
*/
|
|
743
|
-
const adoptCodexWsStage = (response: Response): void => {
|
|
744
|
-
const stage = readCodexWsStage(response);
|
|
745
|
-
if (stage && logCtx.activeAttempt) logCtx.activeAttempt.codexWsStage = stage;
|
|
746
|
-
};
|
|
747
731
|
let upstreamResponse: Response, lastDistributedResponse: Response | undefined;
|
|
748
732
|
const adoptObservedResponse = <T extends Response>(response: T): T => {
|
|
749
733
|
if (!isNonReplayableResponse(response)) lastDistributedResponse = response.status === 401 || response.status === 429 ? response : undefined;
|
|
750
734
|
settleObservedHostResponse();
|
|
751
|
-
adoptCodexWsStage(response);
|
|
735
|
+
adoptCodexWsStage(response, logCtx);
|
|
752
736
|
return response;
|
|
753
737
|
};
|
|
754
738
|
let passthroughEstimate = typeof request.usageLog?.inputTokens === "number"
|
|
@@ -999,6 +983,7 @@ export async function preparePassthroughExchange(
|
|
|
999
983
|
}
|
|
1000
984
|
if (usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)) {
|
|
1001
985
|
recordCodexUpstreamOutcome(config, admissionState.authCtx.accountId, outcome, {
|
|
986
|
+
...codexFailureWindowOutcomeMeta(admissionState.authCtx),
|
|
1002
987
|
threadId: admissionState.authCtx.affinityKey,
|
|
1003
988
|
fixedAccount: admissionState.authCtx.fixedAccount,
|
|
1004
989
|
modelId: route.modelId,
|
|
@@ -1050,6 +1035,7 @@ export async function preparePassthroughExchange(
|
|
|
1050
1035
|
providerName: route.providerName,
|
|
1051
1036
|
modelId: route.modelId,
|
|
1052
1037
|
onPhysicalDispatch: receiptMode ? sendBudgetState.noteInitialDispatch : undefined,
|
|
1038
|
+
accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
|
|
1053
1039
|
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
1054
1040
|
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
1055
1041
|
? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
@@ -1165,6 +1151,7 @@ export async function preparePassthroughExchange(
|
|
|
1165
1151
|
`${route.providerName}|${route.modelId}|${recovery}`,
|
|
1166
1152
|
{ allowFinalRecoveryReserve: transientSendPolicy() === null },
|
|
1167
1153
|
);
|
|
1154
|
+
if (recovery === "empty-completion") allowance.attempts = Math.min(1, allowance.attempts);
|
|
1168
1155
|
try {
|
|
1169
1156
|
return await fetchWithTransientRetry(
|
|
1170
1157
|
innerRecovery => {
|
|
@@ -1187,6 +1174,7 @@ export async function preparePassthroughExchange(
|
|
|
1187
1174
|
dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
|
|
1188
1175
|
providerName: route.providerName,
|
|
1189
1176
|
modelId: route.modelId,
|
|
1177
|
+
accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
|
|
1190
1178
|
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
1191
1179
|
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
1192
1180
|
? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
@@ -1195,7 +1183,8 @@ export async function preparePassthroughExchange(
|
|
|
1195
1183
|
.then(adoptObservedResponse);
|
|
1196
1184
|
},
|
|
1197
1185
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts,
|
|
1198
|
-
onSendsConsumed: transientSendReporter(allowance.permit),
|
|
1186
|
+
onSendsConsumed: transientSendReporter(allowance.permit),
|
|
1187
|
+
claimAmbiguousResend: recovery === "empty-completion" ? undefined : claimPreHeaderResend },
|
|
1199
1188
|
);
|
|
1200
1189
|
} catch (err) {
|
|
1201
1190
|
return { failed: transportFailureResponse(err) };
|
|
@@ -1209,8 +1198,18 @@ export async function preparePassthroughExchange(
|
|
|
1209
1198
|
|
|
1210
1199
|
// Keep recovery kinds in sync with the generic `recovery:` loop below.
|
|
1211
1200
|
passthroughRecovery: for (;;) {
|
|
1201
|
+
if (wasAccountAutoPaused(upstreamResponse) && route.provider.authMode !== "forward") {
|
|
1202
|
+
const recovered = await recoverAutoPausedAccount({ requestKey: transportState, reserve: reserveCredentialHop,
|
|
1203
|
+
remaining: transientSendCapFor(transientRetryPolicyFor(route.provider)?.attempts, sendBudgetState.targetSendsUsed),
|
|
1204
|
+
refresh: () => transportState.recoverAutoPausedSelection(parsed), refetch: () => rebuildAndRefetch("oauth-account-429"), setPermit: permit => { sendBudgetState.pendingHopPermit = permit; } });
|
|
1205
|
+
if (!recovered) break;
|
|
1206
|
+
if ("failed" in recovered) return recovered.failed;
|
|
1207
|
+
upstreamResponse = recovered; continue passthroughRecovery;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
|
|
1212
1211
|
if (
|
|
1213
|
-
upstreamResponse.status === 401
|
|
1212
|
+
upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)
|
|
1214
1213
|
&& (admissionState.authCtx.kind === "main-pool" || admissionState.authCtx.kind === "pool")
|
|
1215
1214
|
&& usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)
|
|
1216
1215
|
&& codex401ReplayKind === null
|
|
@@ -1293,6 +1292,7 @@ export async function preparePassthroughExchange(
|
|
|
1293
1292
|
dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
|
|
1294
1293
|
providerName: route.providerName,
|
|
1295
1294
|
modelId: route.modelId,
|
|
1295
|
+
accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
|
|
1296
1296
|
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
1297
1297
|
beforeDispatch: headers => {
|
|
1298
1298
|
replayGuard?.(headers);
|
|
@@ -1336,14 +1336,14 @@ export async function preparePassthroughExchange(
|
|
|
1336
1336
|
continue passthroughRecovery;
|
|
1337
1337
|
}
|
|
1338
1338
|
|
|
1339
|
-
if (codex401ReplayKind !== null && upstreamResponse.status === 401) break;
|
|
1339
|
+
if (codex401ReplayKind !== null && upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)) break;
|
|
1340
1340
|
|
|
1341
1341
|
// Native Responses providers return before the generic adapter recovery loop below. Keep
|
|
1342
1342
|
// their OAuth contract identical: one pre-stream 401 forces a credential refresh and one
|
|
1343
1343
|
// rebuilt replay. xAI's current subscription models use this branch now that their official
|
|
1344
1344
|
// Grok CLI catalog declares the Responses backend.
|
|
1345
1345
|
if (
|
|
1346
|
-
upstreamResponse.status === 401
|
|
1346
|
+
upstreamResponse.status === 401 && !wasPermanentAccountRefusal(upstreamResponse)
|
|
1347
1347
|
&& isOAuth401ReplayProvider
|
|
1348
1348
|
&& transportState.sentOAuthSnapshot
|
|
1349
1349
|
&& !oauth401ReplayAttempted
|
|
@@ -1440,6 +1440,7 @@ export async function preparePassthroughExchange(
|
|
|
1440
1440
|
dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
|
|
1441
1441
|
providerName: route.providerName,
|
|
1442
1442
|
modelId: route.modelId,
|
|
1443
|
+
accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
|
|
1443
1444
|
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
1444
1445
|
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
1445
1446
|
? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
@@ -1586,6 +1587,7 @@ export async function preparePassthroughExchange(
|
|
|
1586
1587
|
dispatchOverride: oauthDispatch(request, parsed, "passthrough"),
|
|
1587
1588
|
providerName: route.providerName,
|
|
1588
1589
|
modelId: route.modelId,
|
|
1590
|
+
accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: admissionState.authCtx }, headers),
|
|
1589
1591
|
onCodexWsQuota: codexWsQuotaObserver(admissionState.authCtx, route.provider, route.modelId),
|
|
1590
1592
|
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
1591
1593
|
? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|
|
@@ -1600,6 +1602,7 @@ export async function preparePassthroughExchange(
|
|
|
1600
1602
|
return transportFailureResponse(err);
|
|
1601
1603
|
}
|
|
1602
1604
|
}
|
|
1605
|
+
const keyRotation = await rotatePassthroughKeyOn429({ config, route, parsed, response: upstreamResponse, adapterName: transportState.adapter.name, budgetSpent: () => sendBudgetExhausted(transientSendAttempts()), refetch: () => rebuildAndRefetch("key-429") }); if (keyRotation) { if ("failed" in keyRotation) return keyRotation.failed; upstreamResponse = keyRotation; continue passthroughRecovery; }
|
|
1603
1606
|
const captureAffinityResponse = (
|
|
1604
1607
|
response: Response,
|
|
1605
1608
|
captureAuthCtx: CodexAuthContext = admissionState.authCtx,
|
|
@@ -1608,18 +1611,7 @@ export async function preparePassthroughExchange(
|
|
|
1608
1611
|
|| captureAuthCtx.kind === "pool"
|
|
1609
1612
|
|| captureAuthCtx.kind === "main-pool",
|
|
1610
1613
|
): void => {
|
|
1611
|
-
|
|
1612
|
-
captureCodexAffinityDiagnostic({
|
|
1613
|
-
inboundHeaders: req.headers,
|
|
1614
|
-
outboundHeaders: captureRequest.headers,
|
|
1615
|
-
authKind: captureAuthCtx.kind,
|
|
1616
|
-
accountMode: route.codexAccountMode,
|
|
1617
|
-
fixedAccount: isFixedCodexAccount(captureAuthCtx),
|
|
1618
|
-
credentialSubstituted,
|
|
1619
|
-
accountGatedModel: ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(route.modelId),
|
|
1620
|
-
wireModelNormalized: parsed.modelId !== route.modelId,
|
|
1621
|
-
status: response.status,
|
|
1622
|
-
});
|
|
1614
|
+
capturePassthroughAffinity(response, req.headers, captureRequest.headers, captureAuthCtx, route, parsed.modelId, credentialSubstituted);
|
|
1623
1615
|
};
|
|
1624
1616
|
recordDistributedResponseFailure(lastDistributedResponse ?? upstreamResponse, config, route.providerName, options.abortSignal);
|
|
1625
1617
|
captureAffinityResponse(upstreamResponse);
|
|
@@ -1646,7 +1638,9 @@ export async function preparePassthroughExchange(
|
|
|
1646
1638
|
options.abortSignal,
|
|
1647
1639
|
parsed.modelId,
|
|
1648
1640
|
);
|
|
1649
|
-
if (
|
|
1641
|
+
if (wasAccountAutoPaused(upstreamResponse)) {
|
|
1642
|
+
poolRetryOutcome = upstreamResponse.status;
|
|
1643
|
+
} else if (model400Denial !== undefined) {
|
|
1650
1644
|
// Spend this refusal on more than one retry. It is the account's own authenticated
|
|
1651
1645
|
// answer about this model, and the roster cache that selection otherwise reads expires
|
|
1652
1646
|
// five minutes after a catalog sync fills it -- so without remembering this, the next
|
|
@@ -1679,7 +1673,7 @@ export async function preparePassthroughExchange(
|
|
|
1679
1673
|
// ladder does have one — retrying the account the refreshed roster still grants — and
|
|
1680
1674
|
// keeps it. An earlier revision also broke here on a non-400 outcome, which no test could
|
|
1681
1675
|
// justify because this flag already produced the identical result.
|
|
1682
|
-
const storedReplaySpent = codex401ReplayKind === "stored";
|
|
1676
|
+
const storedReplaySpent = codex401ReplayKind === "stored" && !wasAccountAutoPaused(upstreamResponse);
|
|
1683
1677
|
const retry = await retryCodexPoolOnAlternateAccount({
|
|
1684
1678
|
callerAuthHeaders,
|
|
1685
1679
|
config,
|
|
@@ -1698,7 +1692,7 @@ export async function preparePassthroughExchange(
|
|
|
1698
1692
|
httpOnly: canonicalBufferedJson,
|
|
1699
1693
|
preOutputRefusalHold: preOutputRefusalHold(),
|
|
1700
1694
|
onResponse: (response, retryAuthCtx, retryRequest) => {
|
|
1701
|
-
adoptCodexWsStage(response);
|
|
1695
|
+
adoptCodexWsStage(response, logCtx);
|
|
1702
1696
|
captureAffinityResponse(
|
|
1703
1697
|
response,
|
|
1704
1698
|
retryAuthCtx,
|
|
@@ -1945,6 +1939,9 @@ export async function preparePassthroughExchange(
|
|
|
1945
1939
|
break;
|
|
1946
1940
|
}
|
|
1947
1941
|
|
|
1942
|
+
upstreamResponse = await guardPassthroughCompletion(
|
|
1943
|
+
upstreamResponse, request, config, upstream.signal, logCtx, rebuildAndRefetch,
|
|
1944
|
+
);
|
|
1948
1945
|
// Where this relay dials upstream. Local infrastructure (loopback / private / `.local` / `.lan`)
|
|
1949
1946
|
// is operator-trusted and its silent phases are normal, so an unset stall budget resolves to
|
|
1950
1947
|
// disabled for it. Provider rotation above keeps the same endpoint origin, so the routed
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { isNonReplayableResponse } from "../../lib/upstream-retry";
|
|
2
|
+
import { hasKeyPoolFailover, rotateProviderTransportOn429 } from "../../providers/key-failover";
|
|
3
|
+
import type { RouteResult } from "../../router";
|
|
4
|
+
import type { OcxConfig, OcxParsedRequest } from "../../types";
|
|
5
|
+
import { bindRouteReasoningReplayScope } from "./core-replay";
|
|
6
|
+
|
|
7
|
+
// Passthrough's credential moves share one import site so passthrough-dispatch.ts stays under its line cap.
|
|
8
|
+
export { recoverAutoPausedAccount } from "./account-auto-pause-recovery";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Key-pool failover for a native passthrough 429 before any output. This wire returns before the
|
|
12
|
+
* adapter loop's key-429 arm, so without it a pooled key's 429 reached the client while the same
|
|
13
|
+
* refusal rotates on the adapter and chat-native wires. Distributed (round-robin) selection already
|
|
14
|
+
* cools the key on the send and deliberately answers without a replay. A synthesized replay refusal
|
|
15
|
+
* is not a provider rate limit and must not cool a key. A spent send budget is checked before any
|
|
16
|
+
* key is cooled, so it keeps the real 429 body. Returns undefined when nothing rotated.
|
|
17
|
+
*/
|
|
18
|
+
export async function rotatePassthroughKeyOn429<T>(options: {
|
|
19
|
+
config: OcxConfig;
|
|
20
|
+
route: Pick<RouteResult, "provider" | "providerName">;
|
|
21
|
+
parsed: OcxParsedRequest;
|
|
22
|
+
response: Response;
|
|
23
|
+
adapterName: string;
|
|
24
|
+
budgetSpent: () => boolean;
|
|
25
|
+
refetch: () => Promise<T>;
|
|
26
|
+
}): Promise<T | undefined> {
|
|
27
|
+
const { config, route, parsed, response, adapterName, refetch } = options;
|
|
28
|
+
if (response.status !== 429 || isNonReplayableResponse(response) || !hasKeyPoolFailover(route.provider)
|
|
29
|
+
|| route.provider._apiKeyAttempt?.distributed || options.budgetSpent()) return;
|
|
30
|
+
const rotated = rotateProviderTransportOn429(config, route.providerName, route.provider, {
|
|
31
|
+
retryAfter: response.headers.get("retry-after"),
|
|
32
|
+
attemptedKey: route.provider.apiKey,
|
|
33
|
+
promptCacheKey: parsed.options.promptCacheKey,
|
|
34
|
+
});
|
|
35
|
+
if (!rotated) return;
|
|
36
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ }
|
|
37
|
+
route.provider = rotated;
|
|
38
|
+
bindRouteReasoningReplayScope({ parsed, providerName: route.providerName, provider: rotated, adapterName });
|
|
39
|
+
return refetch();
|
|
40
|
+
}
|