@bitkyc08/opencodex 2.89.0 → 2.91.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/App-DMps4OLk.css +1 -0
- package/gui/dist/assets/App-nZ34cR5G.js +58 -0
- package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-BuLy7hL1.js} +1 -1
- package/gui/dist/assets/{index-uchD-ngf.js → index-DGXS51sp.js} +15 -15
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- package/src/adapters/anthropic.ts +2 -5
- package/src/adapters/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +28 -2
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google.ts +28 -9
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +7 -2
- package/src/adapters/openai-responses/request-strips.ts +32 -0
- package/src/adapters/openai-responses/transport.ts +19 -1
- package/src/chat/inbound.ts +20 -2
- package/src/chat/outbound.ts +36 -4
- package/src/chat/reasoning-details.ts +137 -0
- package/src/claude/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- package/src/cli/capabilities-agents-routing.ts +50 -0
- package/src/cli/capabilities-base.ts +15 -2
- package/src/cli/capabilities-observe-system.ts +4 -4
- package/src/cli/codex-shim-autorestore.ts +5 -0
- package/src/cli/dispatch.ts +30 -0
- package/src/cli/ensure-desktop-cold-start.ts +2 -2
- package/src/cli/fallback.ts +127 -0
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +1 -2
- package/src/cli/link-apply-restart.ts +72 -0
- package/src/cli/link-child.ts +6 -2
- package/src/cli/link-cleanup.ts +95 -0
- package/src/cli/link-enrollment.ts +4 -0
- package/src/cli/link.ts +84 -9
- package/src/cli/provider.ts +44 -2
- package/src/cli/registry.ts +13 -1
- package/src/cli/restart-failure.ts +4 -0
- package/src/cli/runtime-api.ts +1 -1
- package/src/cli/status-codex-settings.ts +56 -0
- package/src/cli/status-fallback.ts +29 -0
- package/src/cli/status-install.ts +52 -0
- package/src/cli/status.ts +20 -1
- package/src/cli/system-restart-client.ts +32 -1
- package/src/cli/update-restart-child.ts +4 -3
- package/src/cli/update-restart-home.ts +20 -8
- package/src/client/connect.ts +82 -6
- package/src/client/home-unreachable.ts +16 -0
- package/src/client/hub-relay.ts +8 -2
- package/src/client/link-home.ts +119 -6
- package/src/client/link-parity-reporter.ts +107 -0
- package/src/client/link-relay-transport.ts +8 -1
- package/src/client/link-relay.ts +27 -3
- package/src/client/machine-listener.ts +83 -1
- package/src/client/runtime-restart.ts +127 -0
- package/src/client/runtime.ts +137 -25
- package/src/codex/account-lifecycle.ts +19 -6
- package/src/codex/account-store.ts +27 -8
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +10 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +58 -12
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- package/src/codex/desktop-compatibility/windows-key-protection.ts +18 -6
- package/src/codex/main-account-hard-lock.ts +14 -0
- package/src/codex/quota-history.ts +7 -0
- package/src/codex/quota.ts +10 -0
- package/src/codex/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/codex/subagent-model-fallback.ts +24 -1
- package/src/combos/failover.ts +8 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/diagnostics.ts +5 -0
- package/src/config/load-degrade.ts +2 -0
- package/src/config/provider-validation.ts +7 -2
- package/src/config/schema/config-schema.ts +3 -0
- package/src/config/schema/fallback-policy.ts +40 -0
- package/src/generated/compatibility-version.json +360 -192
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +2 -2
- package/src/lib/authorization-scheme.ts +95 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/provider-client-headers.ts +1 -0
- package/src/lib/redact.ts +2 -2
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/system-restart-contract.ts +24 -0
- package/src/lib/test-home-guard.ts +78 -11
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -0
- package/src/link/child-parity.ts +124 -0
- package/src/link/codex-settings.ts +37 -0
- package/src/link/compatibility.ts +119 -0
- package/src/link/management-routes.ts +2 -0
- package/src/link/peer-report.ts +100 -0
- package/src/link/remote-platform.ts +78 -0
- package/src/link/ssh-argv.ts +19 -0
- package/src/link/status-projection.ts +24 -2
- package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -0
- package/src/oauth/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/kimi.ts +2 -0
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- package/src/oauth/nous.ts +4 -1
- package/src/oauth/store.ts +2 -1
- package/src/protocols/codecs/chat.ts +3 -3
- package/src/protocols/encoders/adapter-events.ts +26 -2
- package/src/protocols/encoders/chat.ts +55 -3
- package/src/providers/claude-cli-usage.ts +20 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +10 -2
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +240 -0
- package/src/providers/openai-sidecar.ts +5 -0
- package/src/providers/quota/anthropic-account-quota.ts +18 -6
- package/src/providers/quota/antigravity.ts +8 -10
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/vendor-probes-oauth.ts +12 -3
- package/src/providers/quota-routing-cache.ts +1 -1
- package/src/providers/quota.ts +5 -1
- package/src/providers/registry/entries-extended.ts +6 -1
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/quota-pool/assemble.ts +326 -0
- package/src/quota-pool/contract.ts +58 -0
- package/src/quota-pool/identity.ts +203 -0
- package/src/remote/features.ts +74 -0
- package/src/remote/protocol.ts +2 -0
- package/src/remote/served-by.ts +57 -0
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +7 -0
- package/src/responses/reasoning-envelope.ts +12 -0
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +11 -2
- package/src/routing/fallback-policy.ts +290 -0
- package/src/routing/fallback-state.ts +179 -0
- package/src/server/audio-client.ts +2 -1
- package/src/server/audio-dictation.ts +2 -1
- package/src/server/audio-upstream.ts +2 -1
- package/src/server/auth-cors.ts +6 -5
- package/src/server/background-lifecycle.ts +4 -0
- package/src/server/chat-completions.ts +8 -1
- package/src/server/chat-native.ts +8 -7
- package/src/server/gui-session.ts +2 -1
- package/src/server/index/link-listener.ts +11 -1
- package/src/server/index/link-management.ts +47 -0
- package/src/server/index/link-relay-sessions.ts +11 -2
- package/src/server/index/optional-listeners.ts +7 -2
- package/src/server/index/serve-options.ts +27 -4
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/live.ts +5 -0
- package/src/server/management/context.ts +1 -1
- package/src/server/management/fallback-routes.ts +75 -0
- package/src/server/management/link-enrollment.ts +21 -3
- package/src/server/management/link-route-state.ts +2 -0
- package/src/server/management/link-routes.ts +12 -1
- package/src/server/management/oauth-account-routes.ts +2 -1
- package/src/server/management/quota-pool-routes.ts +24 -0
- package/src/server/management/route-registry.ts +3 -0
- package/src/server/management-api.ts +16 -0
- package/src/server/management-auth.ts +2 -1
- package/src/server/messages-native.ts +11 -0
- package/src/server/request-log.ts +16 -7
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +2 -2
- package/src/server/responses/agent-task-recovery.ts +1 -1
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-stream-preflight.ts +7 -2
- package/src/server/responses/compact.ts +4 -2
- package/src/server/responses/core-auth.ts +31 -1
- package/src/server/responses/core-codex-account.ts +21 -14
- package/src/server/responses/core-combo.ts +75 -1
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +8 -0
- package/src/server/responses/encrypted-payload.ts +3 -3
- package/src/server/responses/fetch-helpers.ts +30 -10
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/main-hard-lock-routing.ts +85 -8
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +2 -0
- package/src/server/responses/passthrough-dispatch.ts +13 -13
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/policy-fallback.ts +44 -1
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-prepare.ts +19 -1
- package/src/server/responses/request-transport.ts +61 -3
- package/src/server/restart-replacement.ts +50 -2
- package/src/server/ws-bridge.ts +5 -0
- package/src/service/cli.ts +25 -2
- package/src/service/diagnostics.ts +20 -6
- package/src/service/orchestration.ts +17 -0
- package/src/service/status-json.ts +116 -0
- package/src/types/config.ts +7 -0
- package/src/types/fallback.ts +38 -0
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +16 -1
- package/src/types.ts +1 -0
- package/src/web-search/loop.ts +2 -2
- package/gui/dist/assets/App-BnRSujOq.js +0 -58
- package/gui/dist/assets/App-dnkJ85te.css +0 -1
- package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { effectiveAzureOutputBudget } from "../../adapters/azure";
|
|
2
2
|
import { resolveWireProtocolOverride } from "../adapter-resolve";
|
|
3
|
-
import type
|
|
3
|
+
import { comboCooldownRetryAfterSeconds, type CodexAccountModelRefusal } from "../../combos/failover";
|
|
4
|
+
import { fallbackTriggerForStatus, guardFallbackHop } from "../../routing/fallback-policy";
|
|
4
5
|
import {
|
|
5
6
|
isCodexReasoningEffort,
|
|
6
7
|
isDeclaredReasoningEffort,
|
|
@@ -32,6 +33,7 @@ import {
|
|
|
32
33
|
isComboTargetInCooldown,
|
|
33
34
|
snapshotComboQuotaCooldowns,
|
|
34
35
|
comboExclusionReasons,
|
|
36
|
+
comboTargetSkipTrigger,
|
|
35
37
|
noteComboSuccess,
|
|
36
38
|
comboFailureDecision,
|
|
37
39
|
advanceComboAfterFailure,
|
|
@@ -124,6 +126,20 @@ import { clientWireOf } from "../inference/client-wire";
|
|
|
124
126
|
*/
|
|
125
127
|
export const COMBO_TARGET_BASE_SENDS = CODEX_TEXT_GUARDED_BUDGET_POLICY.baseSendAllowance;
|
|
126
128
|
|
|
129
|
+
/**
|
|
130
|
+
* OCX-11: where a combo target physically dispatches, after provider aliases and
|
|
131
|
+
* blockedModelRedirects. A target that does not resolve keeps its configured selector; its
|
|
132
|
+
* dispatch fails on its own.
|
|
133
|
+
*/
|
|
134
|
+
function resolvedComboDestination(config: OcxConfig, target: { provider: string; model: string }): { provider: string; model: string } {
|
|
135
|
+
try {
|
|
136
|
+
const route = routeConcreteModel(config, `${target.provider}/${target.model}`);
|
|
137
|
+
return { provider: route.providerName, model: route.modelId };
|
|
138
|
+
} catch {
|
|
139
|
+
return { provider: target.provider, model: target.model };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
127
143
|
function cooledPoolAccountLabel(config: OcxConfig, providerName: string, modelId: string, label: string | undefined): string | undefined {
|
|
128
144
|
if (!label) return undefined;
|
|
129
145
|
const instance = configuredAnthropicInstance(config, providerName);
|
|
@@ -638,6 +654,30 @@ export async function executeComboResponses(
|
|
|
638
654
|
? clientCancelledResponse()
|
|
639
655
|
: comboUnavailable(comboId, undefined, comboExclusionReasons(config, comboId, targetEligible));
|
|
640
656
|
}
|
|
657
|
+
// OCX-11: an ordered failover combo that starts on a later target than its first one, for any
|
|
658
|
+
// reason (cooling down, provider disabled, cached quota spent, ineligible for this request),
|
|
659
|
+
// makes a proactive hop, so the fallback policy decides before anything is sent. Both ends are
|
|
660
|
+
// the resolved destinations, after provider aliases and blockedModelRedirects.
|
|
661
|
+
const primaryTarget = combo.targets[0];
|
|
662
|
+
// An allowed hop is recorded only once its dispatch is admitted below.
|
|
663
|
+
let pendingHopCommit: (() => void) | undefined;
|
|
664
|
+
if (!toolLoopPin && (combo.strategy ?? "failover") === "failover" && primaryTarget
|
|
665
|
+
&& targetKey(primaryTarget) !== targetKey(pick.target)) {
|
|
666
|
+
const primaryCooling = isComboTargetInCooldown(comboId, primaryTarget, initialNow);
|
|
667
|
+
const proactive = guardFallbackHop(config, {
|
|
668
|
+
site: "combo",
|
|
669
|
+
route: `combo/${comboId}`,
|
|
670
|
+
aliases: [requestedModel],
|
|
671
|
+
from: resolvedComboDestination(config, primaryTarget),
|
|
672
|
+
to: resolvedComboDestination(config, pick.target),
|
|
673
|
+
trigger: primaryCooling ? "cooldown" : comboTargetSkipTrigger(config, primaryTarget, initialNow),
|
|
674
|
+
admission: options.admission,
|
|
675
|
+
retryAfter: primaryCooling ? comboCooldownRetryAfterSeconds(comboId, initialNow) ?? null : null,
|
|
676
|
+
now: initialNow,
|
|
677
|
+
});
|
|
678
|
+
if (!proactive.ok) return proactive.response;
|
|
679
|
+
pendingHopCommit = proactive.commit;
|
|
680
|
+
}
|
|
641
681
|
let jevDecision: JevDecision | undefined;
|
|
642
682
|
if (combo.strategy === "jev" && !toolLoopPin) {
|
|
643
683
|
const choices = eligibleJevComboChoices(config, comboId, targetEligible, Date.now());
|
|
@@ -760,6 +800,9 @@ export async function executeComboResponses(
|
|
|
760
800
|
// no-more-targets branch; a budget refusal ends the ladder one iteration later, where that
|
|
761
801
|
// iteration's own `childLog` is already out of scope.
|
|
762
802
|
let lastFailedChildLog: RequestLogContext | undefined;
|
|
803
|
+
// The target behind `lastFailure`, so the next dispatch can tell a hop from a same-target retry.
|
|
804
|
+
let lastFailedTarget: { provider: string; model: string } | undefined;
|
|
805
|
+
let lastFailedRetryAfter: string | undefined;
|
|
763
806
|
// The exhausted-combo mapping below runs outside the loop, where `failure.upstreamCode`
|
|
764
807
|
// is gone, so carry the loop's own classification decision instead of re-deriving a
|
|
765
808
|
// weaker one from the status alone (#4149).
|
|
@@ -812,7 +855,29 @@ export async function executeComboResponses(
|
|
|
812
855
|
while (pick) {
|
|
813
856
|
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
814
857
|
const firstComboTarget = comboTargetsDispatched === 0;
|
|
858
|
+
// The physical destination after provider aliases and blockedModelRedirects: hops are
|
|
859
|
+
// classified by where the request really goes, not by the selector the combo names.
|
|
815
860
|
const targetRoute = routeConcreteModel(config, `${pick.target.provider}/${pick.target.model}`);
|
|
861
|
+
// OCX-11: every later dispatch after a failure is a hop when the target changed. One check
|
|
862
|
+
// here covers the ordinary failover pick and the recovery picks alike.
|
|
863
|
+
if (!firstComboTarget && lastFailure && lastFailedTarget) {
|
|
864
|
+
const hop = guardFallbackHop(config, {
|
|
865
|
+
site: "combo",
|
|
866
|
+
route: `combo/${comboId}`,
|
|
867
|
+
aliases: [requestedModel],
|
|
868
|
+
from: lastFailedTarget,
|
|
869
|
+
to: { provider: targetRoute.providerName, model: targetRoute.modelId },
|
|
870
|
+
trigger: fallbackTriggerForStatus(lastFailure.status),
|
|
871
|
+
admission: options.admission,
|
|
872
|
+
failureStatus: lastFailure.status,
|
|
873
|
+
retryAfter: lastFailedRetryAfter ?? null,
|
|
874
|
+
});
|
|
875
|
+
if (!hop.ok) {
|
|
876
|
+
if (lastFailedChildLog) adoptFailedChildLog(lastFailedChildLog);
|
|
877
|
+
return hop.response;
|
|
878
|
+
}
|
|
879
|
+
pendingHopCommit = hop.commit;
|
|
880
|
+
}
|
|
816
881
|
// The inherited spend tracker observes this parent log, before the child has its own label.
|
|
817
882
|
logCtx.spendPoolId = targetRoute.providerName;
|
|
818
883
|
// Combo prepays before its child normalizes the route; the parent tracker must
|
|
@@ -851,6 +916,9 @@ export async function executeComboResponses(
|
|
|
851
916
|
// an earlier one already recorded one.
|
|
852
917
|
return exhaustedFailure();
|
|
853
918
|
}
|
|
919
|
+
// Admitted: only now is a guarded hop to this target recorded as taken.
|
|
920
|
+
pendingHopCommit?.();
|
|
921
|
+
pendingHopCommit = undefined;
|
|
854
922
|
const initialSend = hopDecision?.allowed ? { permit: hopDecision.permit, producerOwned: false } : undefined;
|
|
855
923
|
try {
|
|
856
924
|
comboTargetsDispatched += 1;
|
|
@@ -1147,6 +1215,9 @@ export async function executeComboResponses(
|
|
|
1147
1215
|
quotaFailedChildLog = childLog;
|
|
1148
1216
|
}
|
|
1149
1217
|
lastFailedChildLog = childLog;
|
|
1218
|
+
// The failed child's resolved destination, so the next hop compares physical targets.
|
|
1219
|
+
lastFailedTarget = { provider: targetRoute.providerName, model: targetRoute.modelId };
|
|
1220
|
+
lastFailedRetryAfter = failure.retryAfter;
|
|
1150
1221
|
// A replacement that answers 200 is unmarked, and its zero-output failure only exists once
|
|
1151
1222
|
// preflight has rebuilt the stream as a fresh Response. A spent grant never hops: a status the
|
|
1152
1223
|
// client would resend becomes the refusal, and anything else reaches the client as it is.
|
|
@@ -1165,6 +1236,9 @@ export async function executeComboResponses(
|
|
|
1165
1236
|
: comboFailureDecision(failure.response.status, failure.classificationText, {
|
|
1166
1237
|
code: failure.upstreamCode,
|
|
1167
1238
|
codexModelRefusal: failure.codexModelRefusal,
|
|
1239
|
+
// The adapter the failed leg actually resolved: a configured `kiro` provider can route a
|
|
1240
|
+
// model through another wire via `modelAdapters`, and an unresolved leg grants no hop.
|
|
1241
|
+
failedAdapter: childLog.providerAdapter,
|
|
1168
1242
|
});
|
|
1169
1243
|
const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
|
|
1170
1244
|
// Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
|
|
@@ -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)
|
|
@@ -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();
|
|
@@ -265,13 +265,13 @@ export function hasUnreadableEncryptedAgentTask(input: unknown): boolean {
|
|
|
265
265
|
// codex-rs appends one agent_message (any of the four codex-rs message types) at the
|
|
266
266
|
// current input tail. Historical
|
|
267
267
|
// agent messages may be adjacent in full-history bodies; they must not poison the
|
|
268
|
-
// later task. compaction_trigger/additional_tools are trailing
|
|
269
|
-
// a newer user turn.
|
|
268
|
+
// later task. compaction_trigger/additional_tools/configuration_update are trailing
|
|
269
|
+
// metadata rather than a newer user turn; Codex may record an effort update after the task.
|
|
270
270
|
let index = input.length - 1;
|
|
271
271
|
while (index >= 0) {
|
|
272
272
|
const item = input[index];
|
|
273
273
|
const type = item && typeof item === "object" ? (item as { type?: unknown }).type : undefined;
|
|
274
|
-
if (type !== "compaction_trigger" && type !== "additional_tools") break;
|
|
274
|
+
if (type !== "compaction_trigger" && type !== "additional_tools" && type !== "configuration_update") break;
|
|
275
275
|
index -= 1;
|
|
276
276
|
}
|
|
277
277
|
const item = input[index];
|
|
@@ -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, routeConcreteModel } from "../../router";
|
|
4
|
+
import { resolveChosenTarget } from "./shadow-target-availability";
|
|
5
|
+
import { getRoutingProfile, resolvePolicyProfileId, type NormalizedRoutingProfile } from "../../routing/profile";
|
|
4
6
|
import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models";
|
|
5
7
|
import {
|
|
6
8
|
getMainAccountHardLockStatus,
|
|
@@ -10,6 +12,9 @@ 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
|
+
import { FallbackRefusedError, guardFallbackHop, type FallbackAdmission, type FallbackTarget } from "../../routing/fallback-policy";
|
|
16
|
+
/** Resolves to a synchronous ownership proof, or null when the caller does not own the locked main. */
|
|
17
|
+
export type MainHardLockCallerCheck = (sourceModel: string) => Promise<(() => boolean) | null>;
|
|
13
18
|
|
|
14
19
|
function isConfiguredComboSelector(config: OcxConfig, modelId: string): boolean {
|
|
15
20
|
const comboId = resolveComboId(config, modelId);
|
|
@@ -72,11 +77,13 @@ function rawModelOf(body: unknown): { raw: Record<string, unknown>; model: strin
|
|
|
72
77
|
* - Luna Reserve keeps its own admission policy (`assertReserveAdmission`); its helper
|
|
73
78
|
* traffic is not operator chat and must not silently change models.
|
|
74
79
|
*/
|
|
75
|
-
export function applyMainHardLockFallbackOverride(
|
|
80
|
+
export async function applyMainHardLockFallbackOverride(
|
|
76
81
|
body: unknown,
|
|
77
82
|
config: OcxConfig,
|
|
78
|
-
now
|
|
79
|
-
|
|
83
|
+
now?: number,
|
|
84
|
+
callerCheck?: MainHardLockCallerCheck,
|
|
85
|
+
admission?: FallbackAdmission,
|
|
86
|
+
): Promise<MainHardLockFallbackOverride | null> {
|
|
80
87
|
const parsed = rawModelOf(body);
|
|
81
88
|
if (!parsed) return null;
|
|
82
89
|
const fallback = resolveMainAccountHardLockFallback(config);
|
|
@@ -85,16 +92,51 @@ export function applyMainHardLockFallbackOverride(
|
|
|
85
92
|
if (!isBareOpenAiFamilyModel(parsed.model)
|
|
86
93
|
|| parsed.model === NATIVE_RESERVE_MODEL
|
|
87
94
|
|| isConfiguredComboSelector(config, parsed.model)) return null;
|
|
88
|
-
if (getMainAccountHardLockStatus(config, now).state !== "blocked") return null;
|
|
89
95
|
const accountMode = providerCodexAccountMode(
|
|
90
96
|
OPENAI_CODEX_PROVIDER_ID,
|
|
91
97
|
config.providers?.[OPENAI_CODEX_PROVIDER_ID],
|
|
92
98
|
);
|
|
93
|
-
if (accountMode
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
if (accountMode === "direct") {
|
|
100
|
+
// Direct may forward a different caller's healthy credential. Only stored-main substitution
|
|
101
|
+
// or an observed-main match owes this lock. Recheck usage after the startup binding wait.
|
|
102
|
+
const stillOwnsLockedMain = callerCheck ? await callerCheck(parsed.model) : null;
|
|
103
|
+
// Everything from here to the rewrite is synchronous, so this proof cannot go stale before it.
|
|
104
|
+
if (!stillOwnsLockedMain?.()) return null;
|
|
105
|
+
if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
|
|
106
|
+
} else {
|
|
107
|
+
if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
|
|
108
|
+
if (getEligiblePoolAccounts(config, undefined, now ?? Date.now(), codexQuotaScopeForModel(parsed.model)).length > 0) return null;
|
|
96
109
|
}
|
|
97
110
|
|
|
111
|
+
// Combos and policies keep their own dispatch admission and declared failover/evidence.
|
|
112
|
+
// Other chosen targets must resolve now; a removed qualifier cannot become a default send.
|
|
113
|
+
const policyId = resolvePolicyProfileId(config, fallback.model);
|
|
114
|
+
if (!isConfiguredComboSelector(config, fallback.model)
|
|
115
|
+
&& !(policyId && getRoutingProfile(config, policyId))) {
|
|
116
|
+
try {
|
|
117
|
+
const target = resolveChosenTarget(fallback.model, model => previewRouteModel(config, model));
|
|
118
|
+
if ("unavailable" in target) return null;
|
|
119
|
+
} catch {
|
|
120
|
+
// An unavailable target keeps the source lock's refusal without touching the body.
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// OCX-11: the reroute is a proactive model hop; refused, the request is answered with the
|
|
126
|
+
// fallback refusal and the body is left untouched.
|
|
127
|
+
const hop = guardFallbackHop(config, {
|
|
128
|
+
site: "hard-lock",
|
|
129
|
+
route: parsed.model,
|
|
130
|
+
from: { provider: OPENAI_CODEX_PROVIDER_ID, model: parsed.model },
|
|
131
|
+
to: hardLockTargetOf(config, parsed.model, fallback.model),
|
|
132
|
+
trigger: "hard_lock",
|
|
133
|
+
admission,
|
|
134
|
+
...(now !== undefined ? { now } : {}),
|
|
135
|
+
});
|
|
136
|
+
if (!hop.ok) throw new FallbackRefusedError(hop.response);
|
|
137
|
+
// The rewrite is this site's admission: the body now names the fallback model.
|
|
138
|
+
hop.commit();
|
|
139
|
+
|
|
98
140
|
parsed.raw.model = fallback.model;
|
|
99
141
|
if (fallback.reasoningEffort !== undefined) {
|
|
100
142
|
const reasoning = parsed.raw.reasoning;
|
|
@@ -107,3 +149,38 @@ export function applyMainHardLockFallbackOverride(
|
|
|
107
149
|
}
|
|
108
150
|
return { sourceModel: parsed.model, targetModel: fallback.model };
|
|
109
151
|
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The hop's destination for the fallback policy, read from config only. A routing profile picks
|
|
155
|
+
* its candidate from request-history evidence, and evaluating it here would open that index under
|
|
156
|
+
* OPENCODEX_HOME just to classify the hop (on Windows the open handle then outlives the request's
|
|
157
|
+
* home); the profile's own dispatch still evaluates it. Its declared candidates are classified
|
|
158
|
+
* instead, as the hop the profile could take: a candidate on another provider makes it a provider
|
|
159
|
+
* hop. Combos, account namespaces and concrete targets resolve from config alone.
|
|
160
|
+
*/
|
|
161
|
+
function hardLockTargetOf(config: OcxConfig, sourceModel: string, model: string): FallbackTarget {
|
|
162
|
+
const policyId = resolvePolicyProfileId(config, model);
|
|
163
|
+
const profile = policyId ? getRoutingProfile(config, policyId) : undefined;
|
|
164
|
+
if (profile) return profileHopTarget(config, sourceModel, profile);
|
|
165
|
+
try {
|
|
166
|
+
const route = previewRouteModel(config, model);
|
|
167
|
+
return { provider: route.providerName, model: route.modelId };
|
|
168
|
+
} catch {
|
|
169
|
+
const slash = model.indexOf("/");
|
|
170
|
+
return slash > 0 ? { provider: model.slice(0, slash), model: model.slice(slash + 1) } : { provider: "", model };
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function profileHopTarget(config: OcxConfig, sourceModel: string, profile: NormalizedRoutingProfile): FallbackTarget {
|
|
175
|
+
const targets = profile.candidates.map((candidate): FallbackTarget => {
|
|
176
|
+
try {
|
|
177
|
+
const route = routeConcreteModel(config, candidate.provider + "/" + candidate.model);
|
|
178
|
+
return { provider: route.providerName, model: route.modelId };
|
|
179
|
+
} catch {
|
|
180
|
+
return { provider: candidate.provider, model: candidate.model };
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return targets.find(target => target.provider !== OPENAI_CODEX_PROVIDER_ID)
|
|
184
|
+
?? targets.find(target => target.model !== sourceModel)
|
|
185
|
+
?? { provider: OPENAI_CODEX_PROVIDER_ID, model: sourceModel };
|
|
186
|
+
}
|