@bitkyc08/opencodex 2.89.0 → 2.91.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/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/App-nZ34cR5G.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +28 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- 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/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +7 -2
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +19 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- 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/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +84 -9
- package/src/cli/provider.ts +44 -2
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -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/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +58 -12
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- 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/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/failover.ts +8 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -2
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +360 -192
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- 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/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +240 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +8 -10
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- 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 +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/chat-native.ts +8 -7
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +27 -4
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/live.ts +5 -0
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +12 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/agent-task-recovery.ts +1 -1
- 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-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core-auth.ts +31 -1
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +75 -1
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +85 -8
- 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 +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +19 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/orchestration.ts +17 -0
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/quota-summary-BY_10IEz.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
|
+
}
|
|
@@ -37,6 +37,7 @@ import type { ResponsesTerminalStatus } from "../../bridge";
|
|
|
37
37
|
import { isCodexWsQuotaObservedResponse, isCodexWsUpstreamResponse, isCodexWsPreludeProjection } from "./ws-upstream";
|
|
38
38
|
import { recordSubagentQuotaFailureForThreadSpawn } from "../../codex/subagent-model-fallback";
|
|
39
39
|
import { recordCodexUpstreamOutcome } from "../../codex/routing";
|
|
40
|
+
import { codexFailureWindowOutcomeMeta } from "../../codex/routing/failure-window";
|
|
40
41
|
import { codexProbeLeaseId, codexProbeQuotaScope, codexTransientProbeGrant, releaseCodexAuthContextProbeLease } from "../../codex/auth-context";
|
|
41
42
|
import { consumeComboFailure } from "./core-combo-failure";
|
|
42
43
|
import { clientCancelledResponse, readDisplaySafeErrorText } from "./core-errors";
|
|
@@ -535,6 +536,7 @@ export async function deliverPassthroughResponse(
|
|
|
535
536
|
// request. The sibling recorders on this path already decline: the terminal recorder
|
|
536
537
|
// needs an ok streaming body, and the quota-header snapshot finds no quota headers.
|
|
537
538
|
recordCodexUpstreamOutcome(config, admissionState.authCtx.accountId, upstreamResponse.status, {
|
|
539
|
+
...codexFailureWindowOutcomeMeta(admissionState.authCtx),
|
|
538
540
|
...quotaMeta,
|
|
539
541
|
threadId: admissionState.authCtx.affinityKey,
|
|
540
542
|
fixedAccount: admissionState.authCtx.fixedAccount,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { recoverAutoPausedAccount } from "./
|
|
1
|
+
import { recoverAutoPausedAccount, rotatePassthroughKeyOn429 } from "./passthrough-key-rotation";
|
|
2
2
|
import { adoptCodexWsStage, capturePassthroughAffinity } from "./passthrough-observations";
|
|
3
3
|
import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
|
|
4
4
|
import { requestPacingOverloadResponse } from "./pacing-overload";
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
import { restoreRoutedCustomCalls, RoutedCustomToolCompatError } from "../../responses/custom-tool-compat";
|
|
54
54
|
import { XaiToolSchemaCompatibilityError } from "../../adapters/xai-tool-schema";
|
|
55
55
|
import { MuseToolChoiceCompatibilityError } from "../../adapters/openai-responses/muse-tool-choice";
|
|
56
|
+
import { OpenGatewayInputCompatibilityError } from "../../adapters/openai-responses/opengateway-input";
|
|
56
57
|
import { formatErrorResponse } from "../../bridge";
|
|
57
58
|
import { redactSecretString } from "../../lib/redact";
|
|
58
59
|
import {
|
|
@@ -132,7 +133,7 @@ import {
|
|
|
132
133
|
import { mapCodexAuthContextErrorToResponse } from "./codex-auth-error";
|
|
133
134
|
import { classifyTransportFailureKind, transportErrorCode } from "../../lib/upstream-reachability";
|
|
134
135
|
import { captureAccountAutoPauseAttempt, wasAccountAutoPaused, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
|
|
135
|
-
import { recordCodexUpstreamOutcome } from "../../codex/routing";
|
|
136
|
+
import { codexFailureWindowOutcomeMeta, recordCodexUpstreamOutcome } from "../../codex/routing";
|
|
136
137
|
import { describeUpstreamConnectFailure } from "./upstream-error";
|
|
137
138
|
import type { OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
|
|
138
139
|
import {
|
|
@@ -172,6 +173,7 @@ import { upstreamErrorMessageFromPayload, ENCRYPTED_FUNCTION_OUTPUT_REJECTION }
|
|
|
172
173
|
import { isTransientConsoleGoUploadRejection } from "../../providers/opencode-zen-rate-limit";
|
|
173
174
|
import { planReasoningEffortDowngrade } from "../../providers/reasoning-metadata";
|
|
174
175
|
import { unboundPoolSpendRefusalResponse } from "../workflow-refusal";
|
|
176
|
+
import { guardPassthroughCompletion, parseOutboundRequestBody } from "./passthrough-completion";
|
|
175
177
|
|
|
176
178
|
/** Transfer an uncommitted Copilot exchange to the existing translated pipeline without new admission. */
|
|
177
179
|
export interface PassthroughAdapterHandoff { kind: "adapter-handoff" }
|
|
@@ -386,6 +388,7 @@ export async function preparePassthroughExchange(
|
|
|
386
388
|
|| error instanceof XaiToolSchemaCompatibilityError
|
|
387
389
|
|| error instanceof RoutedCustomToolCompatError
|
|
388
390
|
|| error instanceof MuseToolChoiceCompatibilityError
|
|
391
|
+
|| error instanceof OpenGatewayInputCompatibilityError
|
|
389
392
|
) {
|
|
390
393
|
return formatErrorResponse(400, "invalid_request_error", redactSecretString(error.message));
|
|
391
394
|
}
|
|
@@ -434,16 +437,6 @@ export async function preparePassthroughExchange(
|
|
|
434
437
|
// `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for
|
|
435
438
|
// `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there
|
|
436
439
|
// too because the proxy cannot establish the caller's declared authorization boundary.
|
|
437
|
-
const parseOutboundRequestBody = (bodyText: string): Record<string, unknown> | undefined => {
|
|
438
|
-
try {
|
|
439
|
-
const body = JSON.parse(bodyText) as unknown;
|
|
440
|
-
return body && typeof body === "object" && !Array.isArray(body)
|
|
441
|
-
? body as Record<string, unknown>
|
|
442
|
-
: undefined;
|
|
443
|
-
} catch {
|
|
444
|
-
return undefined;
|
|
445
|
-
}
|
|
446
|
-
};
|
|
447
440
|
let outboundRequestBody: Record<string, unknown> | undefined;
|
|
448
441
|
const declaredWireToolNames = new Set<string>();
|
|
449
442
|
const declaredBareWireToolNames = new Set<string>();
|
|
@@ -990,6 +983,7 @@ export async function preparePassthroughExchange(
|
|
|
990
983
|
}
|
|
991
984
|
if (usesCodexForwardPoolAuth(admissionState.authCtx, route.provider)) {
|
|
992
985
|
recordCodexUpstreamOutcome(config, admissionState.authCtx.accountId, outcome, {
|
|
986
|
+
...codexFailureWindowOutcomeMeta(admissionState.authCtx),
|
|
993
987
|
threadId: admissionState.authCtx.affinityKey,
|
|
994
988
|
fixedAccount: admissionState.authCtx.fixedAccount,
|
|
995
989
|
modelId: route.modelId,
|
|
@@ -1157,6 +1151,7 @@ export async function preparePassthroughExchange(
|
|
|
1157
1151
|
`${route.providerName}|${route.modelId}|${recovery}`,
|
|
1158
1152
|
{ allowFinalRecoveryReserve: transientSendPolicy() === null },
|
|
1159
1153
|
);
|
|
1154
|
+
if (recovery === "empty-completion") allowance.attempts = Math.min(1, allowance.attempts);
|
|
1160
1155
|
try {
|
|
1161
1156
|
return await fetchWithTransientRetry(
|
|
1162
1157
|
innerRecovery => {
|
|
@@ -1188,7 +1183,8 @@ export async function preparePassthroughExchange(
|
|
|
1188
1183
|
.then(adoptObservedResponse);
|
|
1189
1184
|
},
|
|
1190
1185
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url), attempts: allowance.attempts,
|
|
1191
|
-
onSendsConsumed: transientSendReporter(allowance.permit),
|
|
1186
|
+
onSendsConsumed: transientSendReporter(allowance.permit),
|
|
1187
|
+
claimAmbiguousResend: recovery === "empty-completion" ? undefined : claimPreHeaderResend },
|
|
1192
1188
|
);
|
|
1193
1189
|
} catch (err) {
|
|
1194
1190
|
return { failed: transportFailureResponse(err) };
|
|
@@ -1606,6 +1602,7 @@ export async function preparePassthroughExchange(
|
|
|
1606
1602
|
return transportFailureResponse(err);
|
|
1607
1603
|
}
|
|
1608
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; }
|
|
1609
1606
|
const captureAffinityResponse = (
|
|
1610
1607
|
response: Response,
|
|
1611
1608
|
captureAuthCtx: CodexAuthContext = admissionState.authCtx,
|
|
@@ -1942,6 +1939,9 @@ export async function preparePassthroughExchange(
|
|
|
1942
1939
|
break;
|
|
1943
1940
|
}
|
|
1944
1941
|
|
|
1942
|
+
upstreamResponse = await guardPassthroughCompletion(
|
|
1943
|
+
upstreamResponse, request, config, upstream.signal, logCtx, rebuildAndRefetch,
|
|
1944
|
+
);
|
|
1945
1945
|
// Where this relay dials upstream. Local infrastructure (loopback / private / `.local` / `.lan`)
|
|
1946
1946
|
// is operator-trusted and its silent phases are normal, so an unset stall budget resolves to
|
|
1947
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
|
+
}
|
|
@@ -13,6 +13,7 @@ import { resolvePolicyProfileId } from "../../routing/profile";
|
|
|
13
13
|
import { parseSyntheticRowId } from "../fast-row";
|
|
14
14
|
import { routeConcreteModel } from "../../router";
|
|
15
15
|
import { isPolicyCandidateRefusal, policyWireDestination, type PolicyRequestScope } from "./policy-request-scope";
|
|
16
|
+
import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
|
|
16
17
|
|
|
17
18
|
type CoreHandler = typeof handleResponsesCore;
|
|
18
19
|
type CoreOptions = Parameters<CoreHandler>[3];
|
|
@@ -25,6 +26,23 @@ function candidateKey(candidate: Pick<RouteCandidateTrace, "provider" | "model">
|
|
|
25
26
|
return `${candidate.provider}\u0000${candidate.model}`;
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
/**
|
|
30
|
+
* OCX-11: the destination a candidate physically dispatches to, after provider aliases and
|
|
31
|
+
* blockedModelRedirects, so the fallback guard compares where requests really go. A candidate
|
|
32
|
+
* that does not resolve keeps its own selector.
|
|
33
|
+
*/
|
|
34
|
+
function resolvedPolicyTarget(
|
|
35
|
+
config: OcxConfig,
|
|
36
|
+
candidate: Pick<RouteCandidateTrace, "provider" | "model">,
|
|
37
|
+
): { provider: string; model: string } {
|
|
38
|
+
try {
|
|
39
|
+
const routed = routeConcreteModel(config, `${candidate.provider}/${candidate.model}`);
|
|
40
|
+
return { provider: routed.providerName, model: routed.modelId };
|
|
41
|
+
} catch {
|
|
42
|
+
return { provider: candidate.provider, model: candidate.model };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
function staysWithinPolicyAfterRedirect(
|
|
29
47
|
config: OcxConfig,
|
|
30
48
|
policyEligibility: ReadonlySet<string>,
|
|
@@ -219,12 +237,32 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
219
237
|
policyScope.triedDestinations.add(policyScope.preparedDestination ?? policyWireDestination({
|
|
220
238
|
providerName: initialTrace.selected.provider, modelId: initialTrace.selected.model,
|
|
221
239
|
}));
|
|
240
|
+
let current = resolvedPolicyTarget(config, initialTrace.selected);
|
|
241
|
+
const policyRoute = `policy/${initialTrace.profile!.id}`;
|
|
222
242
|
|
|
223
243
|
while (!storedPool401ReplayDispatched && await shouldHopPolicyCandidate(response, req.signal)) {
|
|
224
244
|
if (req.signal.aborted) return response;
|
|
225
245
|
const next = rankPolicyFallbackCandidates(initialTrace, tried)
|
|
226
246
|
.find(candidate => staysWithinPolicyAfterRedirect(config, policyEligibility, candidate, policyScope.triedDestinations));
|
|
227
247
|
if (!next) return response;
|
|
248
|
+
// OCX-11: the next candidate is a hop; the fallback policy decides before it is sent, on the
|
|
249
|
+
// redirected destination the guard above already validated.
|
|
250
|
+
const nextTarget = resolvedPolicyTarget(config, next);
|
|
251
|
+
const hop = guardFallbackHop(config, {
|
|
252
|
+
site: "routing-profile",
|
|
253
|
+
route: policyRoute,
|
|
254
|
+
aliases: [initialRequestedModel ?? initialTrace.requestedModel],
|
|
255
|
+
from: current,
|
|
256
|
+
to: nextTarget,
|
|
257
|
+
trigger: fallbackTriggerForStatus(response.status),
|
|
258
|
+
admission: options.admission,
|
|
259
|
+
failureStatus: response.status,
|
|
260
|
+
retryAfter: response.headers.get("retry-after"),
|
|
261
|
+
});
|
|
262
|
+
if (!hop.ok) {
|
|
263
|
+
try { await response.body?.cancel(); } catch { /* the refusal replaces this body */ }
|
|
264
|
+
return hop.response;
|
|
265
|
+
}
|
|
228
266
|
tried.add(candidateKey(next));
|
|
229
267
|
|
|
230
268
|
// Retain the failed response's log owner until a candidate actually replaces it. This is
|
|
@@ -243,7 +281,12 @@ export async function handleResponsesWithPolicyFallback(
|
|
|
243
281
|
if (!Object.hasOwn(failureLog, key)) Reflect.deleteProperty(logCtx, key);
|
|
244
282
|
}
|
|
245
283
|
Object.assign(logCtx, failureLog);
|
|
246
|
-
} else
|
|
284
|
+
} else {
|
|
285
|
+
// Dispatched: only a candidate that was not locally skipped counts as a taken hop.
|
|
286
|
+
hop.commit();
|
|
287
|
+
response = nextResponse;
|
|
288
|
+
current = nextTarget;
|
|
289
|
+
}
|
|
247
290
|
if (policyScope.preparedDestination) policyScope.triedDestinations.add(policyScope.preparedDestination);
|
|
248
291
|
} catch (error) {
|
|
249
292
|
const overload = requestPacingOverloadResponse(error);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { formatErrorResponse } from "../../bridge";
|
|
2
|
+
import { ReasoningArchiveBusyError, ReasoningArchiveError,
|
|
3
|
+
ReasoningArchiveQuotaError } from "../../responses/reasoning-retention";
|
|
4
|
+
|
|
5
|
+
/** Local storage refusal before any send. Never expose filesystem messages or causes. */
|
|
6
|
+
export function reasoningArchiveErrorResponse(error: unknown): Response | undefined {
|
|
7
|
+
let code: string;
|
|
8
|
+
let message: string;
|
|
9
|
+
if (error instanceof ReasoningArchiveQuotaError) {
|
|
10
|
+
code = "reasoning_archive_quota_exceeded";
|
|
11
|
+
message = "Local reasoning archive is full. Review and manually remove older reasoning archives, then retry the unchanged request.";
|
|
12
|
+
} else if (error instanceof ReasoningArchiveBusyError) {
|
|
13
|
+
code = "reasoning_archive_busy";
|
|
14
|
+
message = "Another local reasoning archive writer is active. Wait for it to finish, then retry the unchanged request.";
|
|
15
|
+
} else if (error instanceof ReasoningArchiveError) {
|
|
16
|
+
code = "reasoning_archive_unavailable";
|
|
17
|
+
message = "Local reasoning archive is unavailable. Check archive directory permissions and local storage health before retrying the unchanged request.";
|
|
18
|
+
} else return undefined;
|
|
19
|
+
// Match the local spend-ledger storage refusal's 503 convention without guessing a delay.
|
|
20
|
+
return formatErrorResponse(503, "server_error", message, { code, classified: true });
|
|
21
|
+
}
|
|
@@ -100,6 +100,7 @@ import type {
|
|
|
100
100
|
import {
|
|
101
101
|
codexRouteCredentialDomainHeaders,
|
|
102
102
|
codexRouteCredentialOwnership,
|
|
103
|
+
mainHardLockFallbackCallerCheck,
|
|
103
104
|
resolveResponsesCodexAuth,
|
|
104
105
|
withClaudeNativeSession,
|
|
105
106
|
} from "./core-auth";
|
|
@@ -159,6 +160,7 @@ import {
|
|
|
159
160
|
import { acquireUpstreamHostAdmission } from "../../codex/upstream-host-health";
|
|
160
161
|
import { applyCompactionRoutingOverride, compactionRoutingKeepsProviderIdentity } from "./compaction-routing";
|
|
161
162
|
import { applyMainHardLockFallbackOverride } from "./main-hard-lock-routing";
|
|
163
|
+
import { FallbackRefusedError } from "../../routing/fallback-policy";
|
|
162
164
|
import { codexAuthContextLogLabel } from "../../codex/account-label";
|
|
163
165
|
import {
|
|
164
166
|
conversationStateBindingFromAuth,
|
|
@@ -364,7 +366,17 @@ export async function prepareResponsesRequest(
|
|
|
364
366
|
// must run before comboIdFromRawBody reads `model`.
|
|
365
367
|
if (!concreteSelection && !options.compactionRoutingOverride && !isCompactionTrigger && !options.internalDecisionCall
|
|
366
368
|
&& !shadowCallIntercepted && !shadowCallMatches && !memoryModelApplies && !threadSpawn && inboundWire === "responses") {
|
|
367
|
-
|
|
369
|
+
try {
|
|
370
|
+
options.mainHardLockFallbackOverride = await applyMainHardLockFallbackOverride(
|
|
371
|
+
body, config, undefined, mainHardLockFallbackCallerCheck(req, config, options), options.admission,
|
|
372
|
+
);
|
|
373
|
+
} catch (error) {
|
|
374
|
+
if (options.abortSignal?.aborted || req.signal.aborted) return clientCancelledResponse();
|
|
375
|
+
if (error instanceof FallbackRefusedError) return error.response;
|
|
376
|
+
const mapped = mapCodexAuthContextErrorToResponse(error, { now: Date.now() });
|
|
377
|
+
if (mapped) return mapped;
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
368
380
|
}
|
|
369
381
|
const comboId = !concreteSelection ? comboIdFromRawBody(body, config) : null;
|
|
370
382
|
// A decision model may be a plain model or a non-JEV combo. A JEV combo here would recurse
|
|
@@ -995,7 +1007,9 @@ export async function prepareResponsesRequest(
|
|
|
995
1007
|
subagentFallbackModelEligibleAccountIdsForModel,
|
|
996
1008
|
fallbackChain,
|
|
997
1009
|
candidateRoute => canPassThroughEncryptedV2AgentTask(candidateRoute, inboundWire),
|
|
1010
|
+
options.admission,
|
|
998
1011
|
);
|
|
1012
|
+
if (fallback?.refusal) return fallback.refusal;
|
|
999
1013
|
if (fallback) {
|
|
1000
1014
|
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
|
|
1001
1015
|
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
|
|
@@ -1209,11 +1223,15 @@ export async function prepareResponsesRequest(
|
|
|
1209
1223
|
recoverySelectionOptions,
|
|
1210
1224
|
subagentFallbackAccountPreview,
|
|
1211
1225
|
subagentFallbackModelEligibleAccountIdsForModel,
|
|
1226
|
+
undefined,
|
|
1227
|
+
undefined,
|
|
1228
|
+
options.admission,
|
|
1212
1229
|
);
|
|
1213
1230
|
} finally {
|
|
1214
1231
|
recoverySelectionAdmission?.release();
|
|
1215
1232
|
}
|
|
1216
1233
|
})();
|
|
1234
|
+
if (fallback?.refusal) return fallback.refusal;
|
|
1217
1235
|
if (fallback) {
|
|
1218
1236
|
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackFrom = fallback.from;
|
|
1219
1237
|
(logCtx as unknown as Record<string, unknown>).subagentModelFallbackTo = fallback.to;
|