@bitkyc08/opencodex 2.88.0 → 2.90.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/bin/ocx.mjs +15 -4
- package/gui/dist/assets/App-DgxkE6Md.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic.ts +10 -3
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +62 -37
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +22 -13
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/openai-responses/transport.ts +13 -1
- package/src/adapters/registry.ts +1 -1
- package/src/adapters/upstream-http-error.ts +3 -2
- package/src/adapters/zed.ts +7 -4
- package/src/bridge/internal.ts +11 -1
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +10 -4
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +5 -0
- package/src/cli/account-api.ts +13 -1
- package/src/cli/account-extended.ts +17 -5
- package/src/cli/account-key-quota.ts +4 -1
- package/src/cli/account.ts +1 -1
- package/src/cli/capabilities-agents-routing.ts +45 -8
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-provider-models.ts +5 -4
- package/src/cli/combo-result.ts +8 -2
- package/src/cli/combo.ts +17 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/message-command.ts +4 -1
- package/src/cli/message-remote-args.ts +69 -0
- package/src/cli/message-remote-command.ts +77 -0
- package/src/cli/provider-runtime.ts +69 -1
- package/src/cli/registry.ts +3 -3
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +55 -9
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +13 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +57 -10
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/auto-review.ts +2 -1
- package/src/codex/catalog/build-entries.ts +17 -7
- package/src/codex/catalog/derive-entry.ts +3 -1
- package/src/codex/catalog/metadata.ts +2 -0
- package/src/codex/catalog/model-visibility.ts +0 -2
- package/src/codex/catalog/ownership.ts +71 -0
- package/src/codex/catalog/parsing.ts +12 -3
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/convergence.ts +2 -1
- package/src/codex/low-quota-desktop-notifier.ts +125 -0
- package/src/codex/low-quota-protection.ts +57 -31
- package/src/codex/main-account-cache.ts +77 -3
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/main-account.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +10 -2
- package/src/combos/jev-model-backend.ts +30 -7
- package/src/combos/jev-model-request.ts +20 -0
- package/src/combos/jev-quota-activation.ts +153 -0
- package/src/combos/jev-quota-config.ts +62 -0
- package/src/combos/jev-quota-route.ts +26 -0
- package/src/combos/jev-quota.ts +88 -0
- package/src/combos/jev.ts +25 -12
- package/src/combos/types.ts +27 -0
- package/src/compatibility/index.ts +3 -0
- package/src/compatibility/openai-responses.ts +8 -1
- package/src/compatibility/opengateway-responses.ts +61 -0
- package/src/config/account-auto-pause-revisions.ts +136 -0
- package/src/config/diagnostics.ts +13 -0
- package/src/config/live-reconcile.ts +9 -0
- package/src/config/persist-unlocked.ts +10 -1
- package/src/config/provider-validation.ts +7 -2
- package/src/config/removed-opencode-free.ts +135 -0
- package/src/config/schema/account-auto-pause.ts +14 -0
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +7 -0
- package/src/config.ts +10 -3
- package/src/generated/compatibility-version.json +476 -216
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +34 -11
- package/src/lib/account-auto-pause-response.ts +16 -0
- package/src/lib/bun-runtime.ts +18 -1
- package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/ssh-argv.ts +3 -0
- package/src/messaging/envelope.ts +6 -3
- package/src/messaging/remote-auth.ts +83 -0
- package/src/messaging/remote-bridge.ts +270 -0
- package/src/messaging/remote-contract.ts +108 -0
- package/src/messaging/remote-enrollment-recovery.ts +59 -0
- package/src/messaging/remote-enrollment.ts +244 -0
- package/src/messaging/remote-envelope.ts +40 -0
- package/src/messaging/remote-files.ts +117 -0
- package/src/messaging/remote-owner.ts +169 -0
- package/src/messaging/remote-ports.ts +56 -0
- package/src/messaging/remote-process.ts +131 -0
- package/src/messaging/remote-replies.ts +98 -0
- package/src/messaging/remote-rpc-admission.ts +32 -0
- package/src/messaging/remote-send.ts +72 -0
- package/src/messaging/remote-store.ts +111 -0
- package/src/messaging/remote-tunnels.ts +65 -0
- package/src/messaging/rpc.ts +13 -1
- package/src/oauth/account-auto-pause-policy.ts +66 -0
- package/src/oauth/account-auto-pause.ts +171 -0
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/account-quota-rank.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +6 -0
- package/src/oauth/anthropic-model-family.ts +8 -0
- package/src/oauth/anthropic-model-quota.ts +2 -6
- package/src/oauth/api-key-pool-preserve.ts +37 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +5 -35
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/providers/api-key-pause.ts +32 -0
- package/src/providers/api-key-resolve.ts +10 -4
- package/src/providers/api-key-selection.ts +6 -1
- package/src/providers/api-keys.ts +8 -4
- package/src/providers/claude-cli-usage.ts +25 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +21 -13
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/opencode-zen-rate-limit.ts +6 -72
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +20 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
- package/src/providers/quota/anthropic-decision-windows.ts +47 -0
- package/src/providers/quota/antigravity.ts +45 -8
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +15 -6
- package/src/providers/quota-decision-publication.ts +77 -0
- package/src/providers/quota-decision-snapshot.ts +145 -0
- package/src/providers/quota-routing-cache.ts +4 -1
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +8 -3
- package/src/providers/registry/entries-extended.ts +14 -53
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +147 -0
- package/src/routing/compatibility/behavior.ts +4 -1
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +2 -1
- package/src/server/chat-native.ts +36 -20
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +12 -2
- package/src/server/management/provider-completion-test.ts +289 -0
- package/src/server/management/provider-routes.ts +9 -1
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/messages-native.ts +29 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +23 -9
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +79 -30
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +10 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +28 -15
- package/src/server/responses/core-combo.ts +34 -5
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +10 -0
- package/src/server/responses/fetch-helpers.ts +65 -14
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +5 -2
- package/src/server/responses/passthrough-dispatch.ts +42 -45
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +92 -5
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +16 -3
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/startup-action-control.ts +2 -2
- package/src/server/startup-health-cache.ts +2 -2
- package/src/server/windows-tray-control.ts +2 -2
- package/src/service/definition.ts +9 -1
- package/src/service/orchestration.ts +17 -0
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +11 -5
- package/src/types/request.ts +36 -3
- package/src/types.ts +2 -0
- package/src/usage/cost.ts +17 -1
- package/src/usage/expected-prices.ts +3 -1
- package/src/usage/jev-stats.ts +4 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +7 -0
- package/src/web-search/loop.ts +39 -13
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -3
- package/src/web-search/sidecar-search-replay.ts +96 -0
- package/gui/dist/assets/App-CHC89EXQ.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
|
@@ -259,8 +259,15 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
|
|
|
259
259
|
}
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
-
//
|
|
263
|
-
//
|
|
262
|
+
// Flat SSE error events carry their code/message beside type:error, without a nested
|
|
263
|
+
// error object. Match only the same dedicated code or exact verification identity.
|
|
264
|
+
if ((record.error === undefined || record.error === null)
|
|
265
|
+
&& (record.type === "error" || record.type === "invalid_request_error")
|
|
266
|
+
&& (record.code === "invalid_encrypted_content" || ((record.code === null || record.code === undefined)
|
|
267
|
+
&& typeof record.message === "string" && record.message.startsWith("The encrypted content ")
|
|
268
|
+
&& record.message.endsWith(" could not be verified. Reason: Encrypted content could not be decrypted or parsed.")))) return true;
|
|
269
|
+
|
|
270
|
+
// The caller-mismatch identity also arrives as a flat typed error.
|
|
264
271
|
if (
|
|
265
272
|
record.type === "invalid_request_error"
|
|
266
273
|
&& typeof record.message === "string"
|
|
@@ -277,14 +284,14 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
|
|
|
277
284
|
|
|
278
285
|
|
|
279
286
|
/**
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* requests. Ordinary turns keep the function-output-only inspection.
|
|
287
|
+
* Opaque state can be refused inside a 200 stream rather than as an HTTP 4xx. The passthrough
|
|
288
|
+
* inspects its bounded zero-output prelude even when a restored thread has no serving record;
|
|
289
|
+
* successful native ciphertext remains unchanged and blobless turns do not gain inspection.
|
|
284
290
|
*/
|
|
285
291
|
export function retainedReasoningStreamCandidate(parsed: OcxParsedRequest, outboundBody: string | undefined): boolean {
|
|
286
|
-
|
|
287
|
-
|
|
292
|
+
// A restored thread has no process-local serving record. Inspect its actual outbound
|
|
293
|
+
// opaque state too; only an explicit zero-output rejection can authorize the rebuild.
|
|
294
|
+
return parsed._stripReasoningEncryptedContent !== true
|
|
288
295
|
&& outboundResponsesBodyCarriesOpaqueBlob(outboundBody);
|
|
289
296
|
}
|
|
290
297
|
|
|
@@ -310,7 +317,7 @@ export function retainedReasoningStreamRejection(
|
|
|
310
317
|
|
|
311
318
|
|
|
312
319
|
/**
|
|
313
|
-
* Whether an upstream Responses 4xx
|
|
320
|
+
* Whether an upstream Responses 4xx or explicitly identified 502 rejected opaque replay state.
|
|
314
321
|
*
|
|
315
322
|
* The outbound-body check is intentional: the inbound transcript may contain a proxy envelope or
|
|
316
323
|
* compaction blob that the adapter already lowered, in which case a replay would be byte-identical.
|
|
@@ -329,8 +336,10 @@ export function shouldAttemptOpaqueBlobRecovery(args: {
|
|
|
329
336
|
const acceptedStatus = (args.status >= 400 && args.status < 500)
|
|
330
337
|
|| (
|
|
331
338
|
args.status === 502
|
|
332
|
-
|
|
333
|
-
|
|
339
|
+
// Gateways can project a reasoning-only rejection as 502. The existing strict
|
|
340
|
+
// identity classifier below still excludes generic gateway/transport failures.
|
|
341
|
+
&& (!isEncryptedFunctionOutputRejection(args.errorBody)
|
|
342
|
+
|| outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody))
|
|
334
343
|
);
|
|
335
344
|
return acceptedStatus
|
|
336
345
|
&& adapterSpeaksResponsesWire(args.adapterName)
|
|
@@ -12,6 +12,7 @@ import { createInferenceSendBudget } from "../inference/context";
|
|
|
12
12
|
import { finalizeOwnedTranslatorBudget, finalizeAccountLease } from "./core-lifetime";
|
|
13
13
|
import type { TranslatorBudget } from "../../lib/translator-budget";
|
|
14
14
|
import { executeComboResponses } from "./core-combo";
|
|
15
|
+
import { registerTurn, unregisterTurn } from "../lifecycle";
|
|
15
16
|
import { runWithCompactionRecovery } from "./compaction-recovery";
|
|
16
17
|
/**
|
|
17
18
|
* Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough
|
|
@@ -67,15 +68,17 @@ export async function handleComboResponses(
|
|
|
67
68
|
logCtx: RequestLogContext,
|
|
68
69
|
options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
|
|
69
70
|
): Promise<Response> {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
requestDispatchers
|
|
78
|
-
|
|
71
|
+
// Each child relay binds the turn's lease and unbinds it when its stream ends; the lease is
|
|
72
|
+
// released once nothing is bound. Hold it for the whole ladder so a refused child's relay
|
|
73
|
+
// cannot settle the turn the next target still runs in ("turn already settled"). An accepted
|
|
74
|
+
// child's stream is bound before this returns and keeps the lease until it ends.
|
|
75
|
+
const ladder = new AbortController();
|
|
76
|
+
registerTurn(ladder, options.turnAdmissionLease);
|
|
77
|
+
try {
|
|
78
|
+
return await executeComboResponses(req, rawBody, comboId, config, logCtx, options, requestDispatchers);
|
|
79
|
+
} finally {
|
|
80
|
+
if (options.turnAdmissionLease && "bindAbortController" in options.turnAdmissionLease) unregisterTurn(ladder);
|
|
81
|
+
}
|
|
79
82
|
}
|
|
80
83
|
const requestDispatchers: ResponsesDispatchers = { handleResponses, handleComboResponses };
|
|
81
84
|
export { adapterNeedsForcedContinuation } from "./core-replay";
|
|
@@ -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
|
+
}
|
|
@@ -172,6 +172,7 @@ export function mergeUsage(
|
|
|
172
172
|
const cachedInputTokens = sumOptional("cachedInputTokens");
|
|
173
173
|
const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
|
|
174
174
|
const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
|
|
175
|
+
const cacheCreation1hInputTokens = sumOptional("cacheCreation1hInputTokens");
|
|
175
176
|
const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
|
|
176
177
|
const providerCredits = sumOptional("providerCredits");
|
|
177
178
|
const contextTotalTokens = second.contextTotalTokens ?? first.contextTotalTokens;
|
|
@@ -188,6 +189,7 @@ export function mergeUsage(
|
|
|
188
189
|
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
|
|
189
190
|
...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
|
|
190
191
|
...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
|
|
192
|
+
...(cacheCreation1hInputTokens !== undefined ? { cacheCreation1hInputTokens } : {}),
|
|
191
193
|
...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
|
|
192
194
|
...(providerCredits !== undefined ? { providerCredits } : {}),
|
|
193
195
|
...(first.estimated || second.estimated ? { estimated: true } : {}),
|
|
@@ -241,6 +243,11 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
241
243
|
heldBytes = 0;
|
|
242
244
|
return released;
|
|
243
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
|
+
};
|
|
244
251
|
|
|
245
252
|
while (true) {
|
|
246
253
|
let terminalSeen = false;
|
|
@@ -277,6 +284,7 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
277
284
|
if (retries < maxRetries) {
|
|
278
285
|
// Suppress the terminal: the client must never see a completed event
|
|
279
286
|
// for a turn that produced nothing. Retry the identical turn.
|
|
287
|
+
markRetryBoundary();
|
|
280
288
|
retries += 1;
|
|
281
289
|
try {
|
|
282
290
|
source = await options.continuation();
|
|
@@ -298,6 +306,7 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
298
306
|
// malformed function call). Nothing actionable reached the client, so this is the same
|
|
299
307
|
// replay-safe shape as an empty completion: retry the identical turn once.
|
|
300
308
|
usage = mergeUsage(usage, event.usage);
|
|
309
|
+
markRetryBoundary();
|
|
301
310
|
retries += 1;
|
|
302
311
|
try {
|
|
303
312
|
source = await options.continuation();
|
|
@@ -349,6 +358,7 @@ export async function* guardEmptyCompletionEventStream(
|
|
|
349
358
|
// actionable reached the client. Retry once, then surface a stated error
|
|
350
359
|
// instead of letting the bridge reduce the turn to adapter_eof.
|
|
351
360
|
if (!sawContent && !passthrough && retries < maxRetries) {
|
|
361
|
+
markRetryBoundary();
|
|
352
362
|
retries += 1;
|
|
353
363
|
try {
|
|
354
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 {
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
} from "../../lib/provider-egress";
|
|
32
33
|
import { redactSecretString } from "../../lib/redact";
|
|
33
34
|
import { outboundCredentials } from "../../lib/outbound-credential-registry";
|
|
35
|
+
import { compressUpstreamRequestBody, isCanonicalCodexResponsesUrl } from "./request-compression";
|
|
34
36
|
|
|
35
37
|
export { withUpstreamHttpVersion };
|
|
36
38
|
|
|
@@ -48,11 +50,19 @@ const EGRESS_DECIDED = Symbol.for("opencodex.provider-egress.decided");
|
|
|
48
50
|
const UPSTREAM_REWRITTEN = Symbol.for("opencodex.plugins.upstream-rewritten");
|
|
49
51
|
// Survives queued credential rebuilds and executor replacement without importing budget owners.
|
|
50
52
|
const PHYSICAL_DISPATCH = Symbol.for("opencodex.physical-dispatch");
|
|
53
|
+
// Final destination and headers after plugin rewrites and queued rebuilds, for observers that
|
|
54
|
+
// must attribute a response to the credential that actually reached the wire.
|
|
55
|
+
const PHYSICAL_SEND_OBSERVER = Symbol.for("opencodex.physical-send-observer");
|
|
56
|
+
export type AccountAutoPauseObserver = ((response: Response) => Promise<Response>) & {
|
|
57
|
+
physical?: (sameDestination: boolean, headers: Headers) => void;
|
|
58
|
+
};
|
|
59
|
+
/** Init fragment that reports the final send of the observer's attempt; empty when nothing observes. */
|
|
60
|
+
export function physicalSendObserverInit(observer: AccountAutoPauseObserver | undefined, target: string): Record<symbol, unknown> {
|
|
61
|
+
const physical = observer?.physical;
|
|
62
|
+
return physical ? { [PHYSICAL_SEND_OBSERVER]: (destination: string, headers: Headers) => physical(destination === target, headers) } : {};
|
|
63
|
+
}
|
|
51
64
|
// One WS attempt and its HTTP fallback are one logical physical-send receipt.
|
|
52
65
|
const WS_FALLBACK_RECEIPT = Symbol.for("opencodex.ws-fallback-receipt");
|
|
53
|
-
// Replacement admission waits on the client signal, outside the response-header deadline.
|
|
54
|
-
const HEADER_DEADLINE = Symbol("header-deadline");
|
|
55
|
-
type HeaderDeadline = { clientSignal: AbortSignal; pause(): void; resume(): void };
|
|
56
66
|
|
|
57
67
|
/**
|
|
58
68
|
* Announce once, per provider, that an explicit egress route moved this provider off the
|
|
@@ -206,18 +216,27 @@ export function sendWithConnectionPolicy(
|
|
|
206
216
|
// Bun can reset large native Codex string uploads before receiving headers while
|
|
207
217
|
// the identical UTF-8 buffer succeeds. Convert only at the final HTTP send so
|
|
208
218
|
// WebSocket selection still sees the serialized string and no retry is added.
|
|
219
|
+
// Opt-in compression (#7247) takes precedence at the same boundary and also yields bytes. Every
|
|
220
|
+
// retry and HTTP fallback re-enters here with the caller's original string, so each send derives
|
|
221
|
+
// its own coded bytes; `headers` is already this send's private copy.
|
|
209
222
|
const target = input instanceof Request ? input.url : String(input);
|
|
210
223
|
const body = init?.body;
|
|
211
|
-
const
|
|
212
|
-
|
|
224
|
+
const compressed = compressUpstreamRequestBody(target, body, headers);
|
|
225
|
+
if (compressed) {
|
|
226
|
+
headers.set("Content-Encoding", compressed.encoding);
|
|
227
|
+
headers.delete("Content-Length");
|
|
228
|
+
}
|
|
229
|
+
const largeCodexBody = !compressed && typeof body === "string"
|
|
230
|
+
&& isCanonicalCodexResponsesUrl(target)
|
|
213
231
|
&& Buffer.byteLength(body, "utf8") >= 1024 * 1024;
|
|
214
232
|
(init as Record<symbol, (() => void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.();
|
|
233
|
+
(init as Record<symbol, ((destination: string, headers: Headers) => void) | undefined> | undefined)?.[PHYSICAL_SEND_OBSERVER]?.(target, headers);
|
|
215
234
|
// Final headers, after plugin rewrites: an upstream can only echo what it received here, so the
|
|
216
235
|
// request log refuses any diagnostic that repeats one of these values (see request-log-terminal-status).
|
|
217
236
|
outboundCredentials().remember(headers);
|
|
218
237
|
return physicalFetch(input, {
|
|
219
238
|
...init,
|
|
220
|
-
...(largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
|
|
239
|
+
...(compressed ? { body: compressed.body } : largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
|
|
221
240
|
headers,
|
|
222
241
|
redirect: "manual",
|
|
223
242
|
...(fresh ? { keepalive: false } : {}),
|
|
@@ -227,7 +246,22 @@ export function sendWithConnectionPolicy(
|
|
|
227
246
|
});
|
|
228
247
|
}
|
|
229
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
|
+
|
|
230
263
|
export interface ProviderFetchOptions {
|
|
264
|
+
accountAutoPause?: (name: string, provider: OcxProviderConfig, headers: Headers) => AccountAutoPauseObserver | undefined;
|
|
231
265
|
/**
|
|
232
266
|
* Keep this send on HTTP even where the WebSocket upstream would normally be selected.
|
|
233
267
|
*
|
|
@@ -308,7 +342,9 @@ export function providerFetch(
|
|
|
308
342
|
) as typeof globalThis.fetch);
|
|
309
343
|
const httpFetch = Object.assign(
|
|
310
344
|
async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
|
|
311
|
-
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) => {
|
|
312
348
|
// Refuse before any dispatch side effect where that is sound. `beforeDispatch` commits
|
|
313
349
|
// attempt accounting and consumes admission state, so a refusal firing after it would
|
|
314
350
|
// charge an attempt for a send that never happens, and a throwing hook would mask the
|
|
@@ -318,7 +354,7 @@ export function providerFetch(
|
|
|
318
354
|
// full decision can be made now. With an override, only the configured value is checked:
|
|
319
355
|
// the override may rebuild against a different host and select a different transport, and
|
|
320
356
|
// refusing on this destination would reject a request whose real route is fine.
|
|
321
|
-
if (
|
|
357
|
+
if (override) egressFor(input);
|
|
322
358
|
else providerEgressSendInit(egressBinding, transport, input);
|
|
323
359
|
// The hook inspects the outgoing headers and refuses the send by throwing; it is not a
|
|
324
360
|
// mutator, and the copy it receives is deliberately not threaded onward. `Connection`
|
|
@@ -335,8 +371,8 @@ export function providerFetch(
|
|
|
335
371
|
else if (!notified) { options.onPhysicalDispatch!(); notified = true; }
|
|
336
372
|
} } : {}),
|
|
337
373
|
};
|
|
338
|
-
return
|
|
339
|
-
?
|
|
374
|
+
return override
|
|
375
|
+
? override(input, dispatchInit, dispatch, reacquirePacing)
|
|
340
376
|
: dispatch(input, dispatchInit);
|
|
341
377
|
},
|
|
342
378
|
{ preconnect },
|
|
@@ -344,7 +380,7 @@ export function providerFetch(
|
|
|
344
380
|
// ChatGPT Codex backend: streaming turns ride the responses_websockets
|
|
345
381
|
// transport (measured ~3s faster TTFT than the SSE POST queue); everything
|
|
346
382
|
// else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
|
|
347
|
-
const
|
|
383
|
+
const unobserved = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
|
|
348
384
|
reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
|
|
349
385
|
const upstreamWebsocket = provider.upstreamWebsocket;
|
|
350
386
|
if (!options.httpOnly && typeof input === "string" && init
|
|
@@ -375,12 +411,27 @@ export function providerFetch(
|
|
|
375
411
|
options.onPhysicalDispatch?.();
|
|
376
412
|
};
|
|
377
413
|
const fallback = ((url: Parameters<typeof globalThis.fetch>[0], fallbackInit?: RequestInit) =>
|
|
378
|
-
httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit, reacquirePacing)) as typeof globalThis.fetch;
|
|
379
|
-
|
|
380
|
-
|
|
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);
|
|
381
423
|
}
|
|
382
424
|
return httpFetch(input, init, reacquirePacing);
|
|
383
425
|
};
|
|
426
|
+
const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
|
|
427
|
+
reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
|
|
428
|
+
const observe = options.accountAutoPause?.(providerName, provider,
|
|
429
|
+
new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
|
|
430
|
+
const observedInit = observe?.physical
|
|
431
|
+
? { ...init, ...physicalSendObserverInit(observe, input instanceof Request ? input.url : String(input)) } : init;
|
|
432
|
+
const response = await unobserved(input, observedInit, reacquirePacing);
|
|
433
|
+
return observe ? observe(response) : response;
|
|
434
|
+
};
|
|
384
435
|
let pacingSlotAcquired = options.pacingSlotAcquired === true;
|
|
385
436
|
const waitForPacing = async (signal?: AbortSignal): Promise<ProviderRequestSlot | undefined> => {
|
|
386
437
|
if (pacingSlotAcquired) {
|
|
@@ -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,7 @@
|
|
|
1
1
|
import { readBoundedResponseBytes } from "../../lib/bounded-body";
|
|
2
2
|
import { JevModelInvokeError, type JevModelInvoke, type JevModelInvokeResult } from "../../combos/jev-model-backend";
|
|
3
3
|
import { JEV_MAX_REQUEST_BYTES, JEV_MAX_RESPONSE_BYTES } from "../../combos/jev";
|
|
4
|
+
import { serializeJevModelRequest } from "../../combos/jev-model-request";
|
|
4
5
|
import type { OcxConfig } from "../../types";
|
|
5
6
|
import { codexEffortRank, isCodexReasoningEffort } from "../../reasoning-effort";
|
|
6
7
|
import { routeConcreteModel } from "../../router";
|
|
@@ -92,7 +93,7 @@ function decodeSse(bytes: Uint8Array): JevModelInvokeResult {
|
|
|
92
93
|
* bounds billed output (including reasoning) and gives the spend reservation a nonzero ceiling;
|
|
93
94
|
* a truncated answer is malformed and fails open like any other bad reply.
|
|
94
95
|
*/
|
|
95
|
-
export
|
|
96
|
+
export { JEV_MODEL_MAX_OUTPUT_TOKENS } from "../../combos/jev-model-request";
|
|
96
97
|
|
|
97
98
|
/**
|
|
98
99
|
* The cheapest reasoning effort the decision model declares, so a reasoning model cannot spend
|
|
@@ -121,19 +122,17 @@ export function jevDecisionReasoningEffort(config: OcxConfig, model: string): st
|
|
|
121
122
|
* or conversation header. It therefore runs on credentials configured on provider rows only.
|
|
122
123
|
*/
|
|
123
124
|
export function createJevModelInvoker(context: JevModelInvokerContext): JevModelInvoke {
|
|
124
|
-
return async ({ model, instructions, input, signal }) => {
|
|
125
|
+
return async ({ model, instructions, input, signal, withoutQuota }) => {
|
|
125
126
|
const effort = jevDecisionReasoningEffort(context.config, model);
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
});
|
|
136
|
-
if (new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES) {
|
|
127
|
+
let body = serializeJevModelRequest({ model, instructions, input }, effort);
|
|
128
|
+
/** True when the serialized decision request exceeds the request byte limit. */
|
|
129
|
+
const oversized = () => new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES;
|
|
130
|
+
let quotaOmitted = false;
|
|
131
|
+
if (oversized() && withoutQuota) {
|
|
132
|
+
body = serializeJevModelRequest({ model, ...withoutQuota }, effort);
|
|
133
|
+
quotaOmitted = true;
|
|
134
|
+
}
|
|
135
|
+
if (oversized()) {
|
|
137
136
|
throw new JevModelInvokeError("malformed", "decision request too large");
|
|
138
137
|
}
|
|
139
138
|
const lease = tryAdmitTurn(undefined, signal);
|
|
@@ -178,7 +177,7 @@ export function createJevModelInvoker(context: JevModelInvokerContext): JevModel
|
|
|
178
177
|
return terminalResult(parsed, "");
|
|
179
178
|
})();
|
|
180
179
|
usage = result.usage;
|
|
181
|
-
return result;
|
|
180
|
+
return quotaOmitted ? { ...result, quotaOmitted: true } : result;
|
|
182
181
|
} finally {
|
|
183
182
|
// No final log row is written for this detached context, so settle its spend here.
|
|
184
183
|
childLog.spendTracker?.settle(childLog.usage ?? (usage
|
|
@@ -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;
|
|
@@ -24,7 +24,7 @@ export function createNativeSteeringLogObserver(logCtx: RequestLogContext, onFir
|
|
|
24
24
|
if (usage && (usages.has(event.response.id) || usages.size < MAX_NATIVE_STEERING_RESPONSES)) {
|
|
25
25
|
// Retain numeric counters only, never arbitrary rawUsage metadata.
|
|
26
26
|
const counters: OcxUsage = { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens };
|
|
27
|
-
for (const key of ["totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens"] as const) {
|
|
27
|
+
for (const key of ["totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "cacheCreation1hInputTokens", "reasoningOutputTokens"] as const) {
|
|
28
28
|
if (typeof usage[key] === "number") counters[key] = usage[key];
|
|
29
29
|
}
|
|
30
30
|
usages.set(event.response.id, counters);
|
|
@@ -33,7 +33,7 @@ export function createNativeSteeringLogObserver(logCtx: RequestLogContext, onFir
|
|
|
33
33
|
if (!usages.size) return;
|
|
34
34
|
const total: OcxUsage = { inputTokens: 0, outputTokens: 0 };
|
|
35
35
|
for (const usage of usages.values()) {
|
|
36
|
-
for (const key of ["inputTokens", "outputTokens", "totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens"] as const) {
|
|
36
|
+
for (const key of ["inputTokens", "outputTokens", "totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "cacheCreation1hInputTokens", "reasoningOutputTokens"] as const) {
|
|
37
37
|
const value = usage[key];
|
|
38
38
|
if (typeof value === "number" && Number.isFinite(value)) total[key] = (total[key] ?? 0) + value;
|
|
39
39
|
}
|