@bitkyc08/opencodex 2.89.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/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
- 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/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/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 +5 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- 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/ensure-desktop-cold-start.ts +2 -2
- 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 +48 -6
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/main-account-hard-lock.ts +14 -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/combos/failover.ts +8 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/generated/compatibility-version.json +168 -108
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -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/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- 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 +207 -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 +6 -2
- 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/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- 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/server/chat-native.ts +8 -7
- 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/oauth-account-routes.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/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 +2 -0
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +3 -0
- 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/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +33 -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/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/service/orchestration.ts +17 -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/quota-summary-BY_10IEz.js +0 -1
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { TranslatorBudget } from "../../lib/translator-budget";
|
|
2
|
+
import type { AdapterRequest } from "../../adapters/base";
|
|
3
|
+
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
4
|
+
|
|
5
|
+
// DeepSeek's image-token accounting does not include the inline base64 wire cost.
|
|
6
|
+
// https://api-docs.deepseek.com/guides/vision/ documents a separate 48 MiB body cap.
|
|
7
|
+
const INLINE_THRESHOLD = 40 * 1024 * 1024;
|
|
8
|
+
const TARGET_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
10
|
+
const MAX_REPAIR_BYTES = 128 * 1024 * 1024;
|
|
11
|
+
const MAX_CACHE_ENTRIES = 512;
|
|
12
|
+
const CACHE_MS = 50 * 60 * 1000;
|
|
13
|
+
let activeRepairs = 0;
|
|
14
|
+
const files = new Map<string, { id: string; until: number }>();
|
|
15
|
+
|
|
16
|
+
/** Narrow parsed JSON objects without treating arrays as image or message records. */
|
|
17
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
18
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Only the first-party Responses image contract, never a similarly named gateway. */
|
|
22
|
+
export function needsDeepSeekImageFiles(request: AdapterRequest): boolean {
|
|
23
|
+
const url = new URL(request.url);
|
|
24
|
+
return url.origin === "https://api.deepseek.com"
|
|
25
|
+
&& (url.pathname === "/responses" || url.pathname === "/v1/responses")
|
|
26
|
+
&& request.method === "POST"
|
|
27
|
+
&& Buffer.byteLength(request.body, "utf8") > INLINE_THRESHOLD;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Replace older inline images with same-account, expiring file references. The client history
|
|
32
|
+
* and images stay intact; provider token usage and Codex's compaction policy remain truthful.
|
|
33
|
+
* The caller owns the provider's transport, cancellation and credential-selection fence.
|
|
34
|
+
*/
|
|
35
|
+
export async function deepSeekImageFileBody(
|
|
36
|
+
request: AdapterRequest,
|
|
37
|
+
execute: typeof globalThis.fetch,
|
|
38
|
+
abortSignal?: AbortSignal,
|
|
39
|
+
options?: { allowedModels: readonly string[]; budget?: TranslatorBudget },
|
|
40
|
+
): Promise<string | undefined> {
|
|
41
|
+
if (!options?.allowedModels.length || !needsDeepSeekImageFiles(request)) return undefined;
|
|
42
|
+
const requestBytes = Buffer.byteLength(request.body, "utf8");
|
|
43
|
+
if (requestBytes > MAX_REPAIR_BYTES) throw new Error("DeepSeek image replay exceeds the 128 MiB repair limit; compact the request.");
|
|
44
|
+
if (activeRepairs >= 2) throw new Error("DeepSeek image replay preparation is busy; retry after an active request completes.");
|
|
45
|
+
activeRepairs++;
|
|
46
|
+
const releaseCopy = options.budget?.observeAcceptedRequestCopy(requestBytes * 2);
|
|
47
|
+
try {
|
|
48
|
+
abortSignal?.throwIfAborted();
|
|
49
|
+
const body: unknown = JSON.parse(request.body);
|
|
50
|
+
if (!record(body) || !options.allowedModels.includes(String(body.model))
|
|
51
|
+
|| !Array.isArray(body.input)) return undefined;
|
|
52
|
+
const auth = new Headers(request.headers).get("authorization");
|
|
53
|
+
if (!auth) return undefined;
|
|
54
|
+
const scope = new Bun.CryptoHasher("sha256").update(auth).digest("hex");
|
|
55
|
+
const signal = AbortSignal.any([...(abortSignal ? [abortSignal] : []), AbortSignal.timeout(60_000)]);
|
|
56
|
+
let bytes = Buffer.byteLength(request.body, "utf8");
|
|
57
|
+
let changed = false;
|
|
58
|
+
for (const item of body.input) {
|
|
59
|
+
if (!record(item)) continue;
|
|
60
|
+
const parts = item.type === "function_call_output" || item.type === "custom_tool_call_output"
|
|
61
|
+
? item.output : item.role === "user" || item.role === "developer" ? item.content : undefined; // DeepSeek rejects other roles' images.
|
|
62
|
+
if (!Array.isArray(parts)) continue;
|
|
63
|
+
for (const part of parts) {
|
|
64
|
+
if (bytes <= TARGET_BYTES) break;
|
|
65
|
+
// file_id ignores detail; keep explicit low-resolution requests inline.
|
|
66
|
+
if (!record(part) || part.type !== "input_image" || part.file_id !== undefined
|
|
67
|
+
|| part.detail === "low" || typeof part.image_url !== "string") continue;
|
|
68
|
+
const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,/.exec(part.image_url);
|
|
69
|
+
if (!match) continue;
|
|
70
|
+
signal.throwIfAborted();
|
|
71
|
+
const data = part.image_url.slice(match[0].length);
|
|
72
|
+
if (data.length > Math.ceil(MAX_FILE_BYTES / 3) * 4) continue;
|
|
73
|
+
const hash = new Bun.CryptoHasher("sha256").update(part.image_url).digest("hex");
|
|
74
|
+
const key = `${scope}:${hash}`;
|
|
75
|
+
let entry = files.get(key);
|
|
76
|
+
if (entry && entry.until <= Date.now()) { files.delete(key); entry = undefined; }
|
|
77
|
+
if (!entry) {
|
|
78
|
+
// Conservative peak: UTF-16 base64 slice/re-encoding, decoded buffer, Blob and multipart.
|
|
79
|
+
const releaseUpload = options.budget?.observeExternallyCapped("provider_image_upload", data.length * 4 + Math.ceil(data.length * 3 / 4) * 3 + 65_536);
|
|
80
|
+
try {
|
|
81
|
+
const image = Buffer.from(data, "base64");
|
|
82
|
+
if (image.length > MAX_FILE_BYTES || image.toString("base64") !== data) continue;
|
|
83
|
+
const form = new FormData();
|
|
84
|
+
form.set("purpose", "user_data");
|
|
85
|
+
form.set("expires_after[anchor]", "created_at");
|
|
86
|
+
form.set("expires_after[seconds]", "3600");
|
|
87
|
+
form.set("file", new Blob([image], { type: match[1] }), "image");
|
|
88
|
+
// Never follow a redirect with credentials or copy request-specific Content-Length.
|
|
89
|
+
const response = await execute("https://api.deepseek.com/files", {
|
|
90
|
+
method: "POST", headers: { authorization: auth }, body: form, signal, redirect: "manual",
|
|
91
|
+
});
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
await response.body?.cancel();
|
|
94
|
+
throw new Error(`DeepSeek image upload failed (HTTP ${response.status}); conversation images were preserved.`);
|
|
95
|
+
}
|
|
96
|
+
const result = await readBoundedResponseBody(response, { signal, maxBytes: 65_536 });
|
|
97
|
+
let uploaded: unknown;
|
|
98
|
+
try { uploaded = JSON.parse(result.text); } catch { /* Safe, fixed error below. */ }
|
|
99
|
+
if (result.truncated || !record(uploaded) || typeof uploaded.id !== "string"
|
|
100
|
+
|| !/^file-api-[A-Za-z0-9_-]{1,200}$/.test(uploaded.id)) {
|
|
101
|
+
throw new Error("DeepSeek image upload returned an invalid file reference; conversation images were preserved.");
|
|
102
|
+
}
|
|
103
|
+
// Cache only opaque ids and hashes, not image bytes or plaintext credentials.
|
|
104
|
+
entry = { id: uploaded.id, until: Date.now() + CACHE_MS };
|
|
105
|
+
if (typeof uploaded.expires_at === "number") entry.until = Math.min(entry.until, uploaded.expires_at * 1000 - 60_000);
|
|
106
|
+
while (files.size >= MAX_CACHE_ENTRIES) files.delete(files.keys().next().value!);
|
|
107
|
+
files.set(key, entry);
|
|
108
|
+
} finally { releaseUpload?.(); }
|
|
109
|
+
}
|
|
110
|
+
const removedBytes = part.image_url.length + "image_url".length;
|
|
111
|
+
delete part.image_url;
|
|
112
|
+
part.file_id = entry.id;
|
|
113
|
+
bytes += entry.id.length + "file_id".length - removedBytes;
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (!changed) return undefined;
|
|
118
|
+
const releaseSerialization = options.budget?.observeExternallyCapped("passthrough_serialization", requestBytes * 2);
|
|
119
|
+
try { return JSON.stringify(body); } finally { releaseSerialization?.(); }
|
|
120
|
+
} finally { releaseCopy?.(); activeRepairs--; }
|
|
121
|
+
}
|
|
@@ -243,6 +243,11 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
243
243
|
heldBytes = 0;
|
|
244
244
|
return released;
|
|
245
245
|
};
|
|
246
|
+
// Held events from a discarded attempt are released with the retry's output, so a boundary
|
|
247
|
+
// keeps the retry's reasoning from being appended to the discarded attempt's reasoning item.
|
|
248
|
+
const markRetryBoundary = () => {
|
|
249
|
+
if (held.length > 0 && held.at(-1)?.type !== "assistant_boundary") held.push({ type: "assistant_boundary" });
|
|
250
|
+
};
|
|
246
251
|
|
|
247
252
|
while (true) {
|
|
248
253
|
let terminalSeen = false;
|
|
@@ -279,6 +284,7 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
279
284
|
if (retries < maxRetries) {
|
|
280
285
|
// Suppress the terminal: the client must never see a completed event
|
|
281
286
|
// for a turn that produced nothing. Retry the identical turn.
|
|
287
|
+
markRetryBoundary();
|
|
282
288
|
retries += 1;
|
|
283
289
|
try {
|
|
284
290
|
source = await options.continuation();
|
|
@@ -300,6 +306,7 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
300
306
|
// malformed function call). Nothing actionable reached the client, so this is the same
|
|
301
307
|
// replay-safe shape as an empty completion: retry the identical turn once.
|
|
302
308
|
usage = mergeUsage(usage, event.usage);
|
|
309
|
+
markRetryBoundary();
|
|
303
310
|
retries += 1;
|
|
304
311
|
try {
|
|
305
312
|
source = await options.continuation();
|
|
@@ -351,6 +358,7 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
351
358
|
// actionable reached the client. Retry once, then surface a stated error
|
|
352
359
|
// instead of letting the bridge reduce the turn to adapter_eof.
|
|
353
360
|
if (!sawContent && !passthrough && retries < maxRetries) {
|
|
361
|
+
markRetryBoundary();
|
|
354
362
|
retries += 1;
|
|
355
363
|
try {
|
|
356
364
|
source = await options.continuation();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { NativeResponseControl } from "./native-response-control";
|
|
2
|
+
import { PROVIDER_HEADER_DEADLINE as HEADER_DEADLINE, type ProviderHeaderDeadline as HeaderDeadline } from "../../lib/provider-admission-wait";
|
|
2
3
|
import type { CodexWsPreOutputRefusalHold } from "./codex-ws-wire";
|
|
3
4
|
import type { Server } from "bun";
|
|
4
5
|
import {
|
|
@@ -62,9 +63,6 @@ export function physicalSendObserverInit(observer: AccountAutoPauseObserver | un
|
|
|
62
63
|
}
|
|
63
64
|
// One WS attempt and its HTTP fallback are one logical physical-send receipt.
|
|
64
65
|
const WS_FALLBACK_RECEIPT = Symbol.for("opencodex.ws-fallback-receipt");
|
|
65
|
-
// Replacement admission waits on the client signal, outside the response-header deadline.
|
|
66
|
-
const HEADER_DEADLINE = Symbol("header-deadline");
|
|
67
|
-
type HeaderDeadline = { clientSignal: AbortSignal; pause(): void; resume(): void };
|
|
68
66
|
|
|
69
67
|
/**
|
|
70
68
|
* Announce once, per provider, that an explicit egress route moved this provider off the
|
|
@@ -248,6 +246,20 @@ export function sendWithConnectionPolicy(
|
|
|
248
246
|
});
|
|
249
247
|
}
|
|
250
248
|
|
|
249
|
+
interface CodexWsExecutor { provider: OcxProviderConfig; http: typeof globalThis.fetch }
|
|
250
|
+
const codexWsExecutors = new WeakMap<object, CodexWsExecutor>();
|
|
251
|
+
const codexWsExecutor = (send: (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => Promise<Response>, binding: CodexWsExecutor) => {
|
|
252
|
+
codexWsExecutors.set(send, binding);
|
|
253
|
+
return send as typeof globalThis.fetch;
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Set for the executor a `dispatchOverride` receives on the WS branch: it must not get HTTP-only
|
|
257
|
+
* send policy. `provider` is the one WS eligibility was checked against; a dispatch-time
|
|
258
|
+
* replacement was not, so it sends through `http` (the same fallback) instead.
|
|
259
|
+
*/
|
|
260
|
+
export const codexWsExecutorBinding = (execute: unknown): CodexWsExecutor | undefined =>
|
|
261
|
+
typeof execute === "function" ? codexWsExecutors.get(execute) : undefined;
|
|
262
|
+
|
|
251
263
|
export interface ProviderFetchOptions {
|
|
252
264
|
accountAutoPause?: (name: string, provider: OcxProviderConfig, headers: Headers) => AccountAutoPauseObserver | undefined;
|
|
253
265
|
/**
|
|
@@ -330,7 +342,9 @@ export function providerFetch(
|
|
|
330
342
|
) as typeof globalThis.fetch);
|
|
331
343
|
const httpFetch = Object.assign(
|
|
332
344
|
async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
|
|
333
|
-
reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void
|
|
345
|
+
reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>,
|
|
346
|
+
// False only for the HTTP fallback of a WS send the override already wrapped: one logical send.
|
|
347
|
+
override: ProviderFetchOptions["dispatchOverride"] | null = options.dispatchOverride) => {
|
|
334
348
|
// Refuse before any dispatch side effect where that is sound. `beforeDispatch` commits
|
|
335
349
|
// attempt accounting and consumes admission state, so a refusal firing after it would
|
|
336
350
|
// charge an attempt for a send that never happens, and a throwing hook would mask the
|
|
@@ -340,7 +354,7 @@ export function providerFetch(
|
|
|
340
354
|
// full decision can be made now. With an override, only the configured value is checked:
|
|
341
355
|
// the override may rebuild against a different host and select a different transport, and
|
|
342
356
|
// refusing on this destination would reject a request whose real route is fine.
|
|
343
|
-
if (
|
|
357
|
+
if (override) egressFor(input);
|
|
344
358
|
else providerEgressSendInit(egressBinding, transport, input);
|
|
345
359
|
// The hook inspects the outgoing headers and refuses the send by throwing; it is not a
|
|
346
360
|
// mutator, and the copy it receives is deliberately not threaded onward. `Connection`
|
|
@@ -357,8 +371,8 @@ export function providerFetch(
|
|
|
357
371
|
else if (!notified) { options.onPhysicalDispatch!(); notified = true; }
|
|
358
372
|
} } : {}),
|
|
359
373
|
};
|
|
360
|
-
return
|
|
361
|
-
?
|
|
374
|
+
return override
|
|
375
|
+
? override(input, dispatchInit, dispatch, reacquirePacing)
|
|
362
376
|
: dispatch(input, dispatchInit);
|
|
363
377
|
},
|
|
364
378
|
{ preconnect },
|
|
@@ -397,9 +411,15 @@ export function providerFetch(
|
|
|
397
411
|
options.onPhysicalDispatch?.();
|
|
398
412
|
};
|
|
399
413
|
const fallback = ((url: Parameters<typeof globalThis.fetch>[0], fallbackInit?: RequestInit) =>
|
|
400
|
-
httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit, reacquirePacing)) as typeof globalThis.fetch;
|
|
401
|
-
|
|
402
|
-
|
|
414
|
+
httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit, reacquirePacing, null)) as typeof globalThis.fetch;
|
|
415
|
+
const wsSend = codexWsExecutor((url: Parameters<typeof globalThis.fetch>[0], wsInit?: RequestInit) => codexWsUpstreamFetch(String(url), wsInit ?? init,
|
|
416
|
+
fallback, runtime, options.onCodexWsQuota, options.beforeDispatch, options.nativeControl,
|
|
417
|
+
async () => { (await waitForPacing(init.signal ?? undefined))?.release(); }, notifyOnce, options.preOutputRefusalHold),
|
|
418
|
+
{ provider, http: markEgressTransparentExecutor(fallback) });
|
|
419
|
+
// A WS frame is a physical send like any other, so it runs the same override: that is where a
|
|
420
|
+
// key-auth send is committed to its attempt row and the send budget is rebound to the
|
|
421
|
+
// credential that left. Without it a key-auth WS turn logged `sendCount: 0`.
|
|
422
|
+
return options.dispatchOverride ? options.dispatchOverride(input, init, wsSend, reacquirePacing) : wsSend(input, init);
|
|
403
423
|
}
|
|
404
424
|
return httpFetch(input, init, reacquirePacing);
|
|
405
425
|
};
|
|
@@ -55,6 +55,9 @@ const REMOTE_IMAGE_TOKENS = 850;
|
|
|
55
55
|
/** Decoded image bytes per token. Coarse tile-count proxy, not a provider formula. */
|
|
56
56
|
const IMAGE_BYTES_PER_TOKEN = 750;
|
|
57
57
|
|
|
58
|
+
/** Compressed-audio bytes per token. More conservative than the image proxy. */
|
|
59
|
+
const AUDIO_BYTES_PER_TOKEN = 256;
|
|
60
|
+
|
|
58
61
|
export interface InputAdmissionResult {
|
|
59
62
|
admitted: boolean;
|
|
60
63
|
estimatedTokens: number;
|
|
@@ -85,6 +88,7 @@ function imageTokens(imageUrl: string): number {
|
|
|
85
88
|
function contentPartTokens(part: OcxContentPart, modelId: string): number {
|
|
86
89
|
if (part.type === "image") return imageTokens(part.imageUrl);
|
|
87
90
|
if (part.type === "video") return imageTokens(part.videoUrl);
|
|
91
|
+
if (part.type === "audio") return base64PayloadTokens(part.data, AUDIO_BYTES_PER_TOKEN);
|
|
88
92
|
// An inline document is a base64 payload, not a sentence: estimating it from its marker would
|
|
89
93
|
// admit a request whose real input is orders of magnitude larger. Counted arithmetically —
|
|
90
94
|
// rebuilding the data URL here would materialize a second request-sized string just to measure it.
|
|
@@ -92,10 +96,10 @@ function contentPartTokens(part: OcxContentPart, modelId: string): number {
|
|
|
92
96
|
return estimateTokens(part.text, modelId);
|
|
93
97
|
}
|
|
94
98
|
|
|
95
|
-
function base64PayloadTokens(base64: string): number {
|
|
99
|
+
function base64PayloadTokens(base64: string, bytesPerToken = IMAGE_BYTES_PER_TOKEN): number {
|
|
96
100
|
if (base64.length === 0) return 0;
|
|
97
101
|
const decoded = Math.floor((base64.length * 3) / 4);
|
|
98
|
-
return Math.max(1, Math.ceil(decoded /
|
|
102
|
+
return Math.max(1, Math.ceil(decoded / bytesPerToken));
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { OcxConfig } from "../../types";
|
|
2
2
|
import { resolveComboId } from "../../combos";
|
|
3
|
-
import { isBareOpenAiFamilyModel } from "../../router";
|
|
3
|
+
import { isBareOpenAiFamilyModel, previewRouteModel } from "../../router";
|
|
4
|
+
import { resolveChosenTarget } from "./shadow-target-availability";
|
|
5
|
+
import { getRoutingProfile, resolvePolicyProfileId } from "../../routing/profile";
|
|
4
6
|
import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models";
|
|
5
7
|
import {
|
|
6
8
|
getMainAccountHardLockStatus,
|
|
@@ -10,6 +12,8 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store";
|
|
|
10
12
|
import { getEligiblePoolAccounts } from "../../codex/routing/selection";
|
|
11
13
|
import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
12
14
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
15
|
+
/** Resolves to a synchronous ownership proof, or null when the caller does not own the locked main. */
|
|
16
|
+
export type MainHardLockCallerCheck = (sourceModel: string) => Promise<(() => boolean) | null>;
|
|
13
17
|
|
|
14
18
|
function isConfiguredComboSelector(config: OcxConfig, modelId: string): boolean {
|
|
15
19
|
const comboId = resolveComboId(config, modelId);
|
|
@@ -72,11 +76,12 @@ function rawModelOf(body: unknown): { raw: Record<string, unknown>; model: strin
|
|
|
72
76
|
* - Luna Reserve keeps its own admission policy (`assertReserveAdmission`); its helper
|
|
73
77
|
* traffic is not operator chat and must not silently change models.
|
|
74
78
|
*/
|
|
75
|
-
export function applyMainHardLockFallbackOverride(
|
|
79
|
+
export async function applyMainHardLockFallbackOverride(
|
|
76
80
|
body: unknown,
|
|
77
81
|
config: OcxConfig,
|
|
78
|
-
now
|
|
79
|
-
|
|
82
|
+
now?: number,
|
|
83
|
+
callerCheck?: MainHardLockCallerCheck,
|
|
84
|
+
): Promise<MainHardLockFallbackOverride | null> {
|
|
80
85
|
const parsed = rawModelOf(body);
|
|
81
86
|
if (!parsed) return null;
|
|
82
87
|
const fallback = resolveMainAccountHardLockFallback(config);
|
|
@@ -85,14 +90,34 @@ export function applyMainHardLockFallbackOverride(
|
|
|
85
90
|
if (!isBareOpenAiFamilyModel(parsed.model)
|
|
86
91
|
|| parsed.model === NATIVE_RESERVE_MODEL
|
|
87
92
|
|| isConfiguredComboSelector(config, parsed.model)) return null;
|
|
88
|
-
if (getMainAccountHardLockStatus(config, now).state !== "blocked") return null;
|
|
89
93
|
const accountMode = providerCodexAccountMode(
|
|
90
94
|
OPENAI_CODEX_PROVIDER_ID,
|
|
91
95
|
config.providers?.[OPENAI_CODEX_PROVIDER_ID],
|
|
92
96
|
);
|
|
93
|
-
if (accountMode
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
if (accountMode === "direct") {
|
|
98
|
+
// Direct may forward a different caller's healthy credential. Only stored-main substitution
|
|
99
|
+
// or an observed-main match owes this lock. Recheck usage after the startup binding wait.
|
|
100
|
+
const stillOwnsLockedMain = callerCheck ? await callerCheck(parsed.model) : null;
|
|
101
|
+
// Everything from here to the rewrite is synchronous, so this proof cannot go stale before it.
|
|
102
|
+
if (!stillOwnsLockedMain?.()) return null;
|
|
103
|
+
if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
|
|
104
|
+
} else {
|
|
105
|
+
if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
|
|
106
|
+
if (getEligiblePoolAccounts(config, undefined, now ?? Date.now(), codexQuotaScopeForModel(parsed.model)).length > 0) return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Combos and policies keep their own dispatch admission and declared failover/evidence.
|
|
110
|
+
// Other chosen targets must resolve now; a removed qualifier cannot become a default send.
|
|
111
|
+
const policyId = resolvePolicyProfileId(config, fallback.model);
|
|
112
|
+
if (!isConfiguredComboSelector(config, fallback.model)
|
|
113
|
+
&& !(policyId && getRoutingProfile(config, policyId))) {
|
|
114
|
+
try {
|
|
115
|
+
const target = resolveChosenTarget(fallback.model, model => previewRouteModel(config, model));
|
|
116
|
+
if ("unavailable" in target) return null;
|
|
117
|
+
} catch {
|
|
118
|
+
// An unavailable target keeps the source lock's refusal without touching the body.
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
96
121
|
}
|
|
97
122
|
|
|
98
123
|
parsed.raw.model = fallback.model;
|
|
@@ -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,
|