@bitkyc08/opencodex 2.87.0 → 2.89.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-BnRSujOq.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
- package/gui/dist/assets/index-uchD-ngf.js +94 -0
- package/gui/dist/assets/{quota-summary-ChvmKGOk.js → quota-summary-BY_10IEz.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic-rejection-memo.ts +71 -0
- package/src/adapters/anthropic-replay.ts +135 -0
- package/src/adapters/anthropic-tool-catalog.ts +11 -0
- package/src/adapters/anthropic.ts +86 -48
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +53 -0
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +52 -30
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/passthrough.ts +17 -12
- package/src/adapters/openai-responses/reasoning.ts +68 -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 +17 -3
- package/src/bridge/response-json.ts +4 -4
- package/src/bridge/sse.ts +17 -10
- package/src/claude/inbound-content-options.ts +66 -0
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +18 -5
- 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/cross-home-owner.ts +16 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/loopback-family.ts +31 -0
- 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-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +30 -3
- package/src/codex/auth-api/account-list.ts +3 -0
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-context.ts +9 -4
- 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-gather.ts +3 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- 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.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/codex/websocket-activity.ts +161 -0
- package/src/codex/websocket-registry.ts +25 -1
- package/src/combos/failover.ts +2 -1
- 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/opencode-zen-upgrade.ts +9 -8
- package/src/config/persist-unlocked.ts +10 -1
- 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 +411 -175
- package/src/images/loop.ts +101 -27
- 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 +68 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/request-failure-model.ts +2 -0
- package/src/lib/state-store-registrations.ts +25 -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-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/generic-account-failover.ts +7 -4
- package/src/oauth/index.ts +5 -35
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/protocols/encoders/adapter-events.ts +5 -5
- package/src/protocols/encoders/messages.ts +2 -2
- 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 +5 -0
- package/src/providers/key-failover.ts +11 -11
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +12 -73
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +2 -1
- 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 +39 -6
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- 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 +3 -0
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +3 -2
- package/src/providers/registry/entries-extended.ts +8 -52
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-replay-cache.ts +50 -4
- package/src/responses/web-search-replay.ts +138 -0
- package/src/routing/analytics.ts +7 -15
- 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 +28 -13
- package/src/server/claude-messages.ts +4 -2
- package/src/server/index/live-sideband.ts +3 -2
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +11 -5
- package/src/server/index/websocket-handler.ts +18 -4
- package/src/server/index.ts +7 -7
- package/src/server/lifecycle.ts +13 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +10 -1
- 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 +34 -7
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +7 -2
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +34 -18
- package/src/server/responses/adapter-delivery.ts +53 -6
- package/src/server/responses/adapter-dispatch.ts +104 -29
- package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +6 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +8 -5
- package/src/server/responses/compaction-recovery.ts +2 -0
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-codex-account.ts +9 -3
- package/src/server/responses/core-combo.ts +34 -7
- package/src/server/responses/core-lifetime.ts +3 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +5 -1
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +30 -33
- package/src/server/responses/passthrough-execution.ts +3 -2
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +1 -0
- package/src/server/responses/request-transport.ts +32 -3
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/response-tool-ownership.ts +21 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +59 -10
- package/src/server/responses/sidecar-send-budget.ts +2 -2
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- 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/server/ws-bridge.ts +2 -0
- package/src/service/definition.ts +9 -1
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/opencode-zen.ts +9 -3
- package/src/types/provider.ts +9 -5
- package/src/types/request.ts +30 -4
- package/src/types/wire.ts +4 -2
- 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 +16 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/web-search/anthropic-history.ts +59 -0
- package/src/web-search/loop.ts +212 -73
- package/src/web-search/progress-stream.ts +14 -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-ZfYbLHkB.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/index-CfYDMj34.js +0 -94
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { anthropicRejectionPolicy, bindAnthropicRejectionPolicy, rejectedAnthropicPayloads } from "../../adapters/anthropic-rejection-memo";
|
|
2
|
+
import { normalizeUpstreamErrorText, readDisplaySafeErrorText } from "./core-errors";
|
|
3
|
+
import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error";
|
|
4
|
+
import { isLikelyRealAnthropicThinkingSignature } from "../../adapters/anthropic";
|
|
5
|
+
import type { AdapterEvent, OcxParsedRequest } from "../../types";
|
|
6
|
+
import { upstreamErrorMessageFromPayload } from "../../lib/errors";
|
|
7
|
+
import { readBoundedResponseBody } from "../../lib/bounded-body";
|
|
8
|
+
import { isNonReplayableResponse } from "../../lib/upstream-retry";
|
|
9
|
+
import { rememberReasoningReplayOpaqueBlobRejection } from "../../responses/reasoning-replay-cache";
|
|
10
|
+
import { prepareOpaqueBlobRecovery, type OpaqueBlobRecoveryGuard } from "./core-opaque-recovery";
|
|
11
|
+
|
|
12
|
+
/** Only the explicit conversation-binding refusal authorizes removing signed history. */
|
|
13
|
+
export function isAnthropicThinkingSignatureBindingRejectionMessage(message: string): boolean {
|
|
14
|
+
return message.includes("Invalid `signature` in `thinking` block")
|
|
15
|
+
&& message.includes("The block is bound to a different conversation");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function carriesThinkingState(parsed: OcxParsedRequest): boolean {
|
|
19
|
+
return parsed.context.messages.some(message => message.role === "assistant"
|
|
20
|
+
&& message.content.some(part => part.type === "thinking"
|
|
21
|
+
&& (isLikelyRealAnthropicThinkingSignature(part.signature)
|
|
22
|
+
|| part.redacted?.some(data => typeof data === "string" && data.length > 0))));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One request owns both the HTTP refusal and initial-event recovery, never a fresh retry guard. */
|
|
26
|
+
export function createAnthropicThinkingRecovery(
|
|
27
|
+
parsed: OcxParsedRequest,
|
|
28
|
+
signal: AbortSignal,
|
|
29
|
+
guard: OpaqueBlobRecoveryGuard,
|
|
30
|
+
canRetry: () => boolean,
|
|
31
|
+
) {
|
|
32
|
+
let rejectedScope: OcxParsedRequest["_reasoningReplayScope"];
|
|
33
|
+
let rejectedPayloads: ReadonlySet<string> | undefined;
|
|
34
|
+
let armed = false;
|
|
35
|
+
let outputSeen = false;
|
|
36
|
+
let completed = false;
|
|
37
|
+
let failed = false;
|
|
38
|
+
let remembered = false;
|
|
39
|
+
|
|
40
|
+
const prepare = (message: string, adapterName: string, attempt = parsed, response?: Response): boolean => {
|
|
41
|
+
if (signal.aborted || !canRetry() || adapterName !== "anthropic" || guard.attempted
|
|
42
|
+
|| parsed._stripReasoningEncryptedContent === true || !carriesThinkingState(attempt)
|
|
43
|
+
|| !isAnthropicThinkingSignatureBindingRejectionMessage(message)) return false;
|
|
44
|
+
rejectedPayloads = rejectedAnthropicPayloads(response);
|
|
45
|
+
// A real send must prove that it serialized opaque history; otherwise rebuilding could
|
|
46
|
+
// repeat the same request. Direct event-policy callers have no HTTP body to identify.
|
|
47
|
+
if (response && !rejectedPayloads?.size) return false;
|
|
48
|
+
const previous = anthropicRejectionPolicy(parsed);
|
|
49
|
+
if (rejectedPayloads && previous?.mode === "selective") {
|
|
50
|
+
const merged = new Set(previous.fingerprints);
|
|
51
|
+
for (const payload of rejectedPayloads) merged.add(payload);
|
|
52
|
+
rejectedPayloads = merged.size <= 256 ? merged : undefined;
|
|
53
|
+
}
|
|
54
|
+
guard.attempted = true;
|
|
55
|
+
armed = true;
|
|
56
|
+
const scope = parsed._reasoningReplayScope;
|
|
57
|
+
rejectedScope = scope ? { ...scope, current: scope.current ? { ...scope.current } : undefined } : undefined;
|
|
58
|
+
if (!rejectedPayloads) {
|
|
59
|
+
prepareOpaqueBlobRecovery(parsed);
|
|
60
|
+
if (attempt !== parsed) prepareOpaqueBlobRecovery(attempt);
|
|
61
|
+
}
|
|
62
|
+
const policy = rejectedPayloads ? { mode: "selective" as const, fingerprints: rejectedPayloads } : { mode: "blanket" as const };
|
|
63
|
+
bindAnthropicRejectionPolicy(parsed, policy);
|
|
64
|
+
bindAnthropicRejectionPolicy(attempt, policy);
|
|
65
|
+
return true;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const prepareHttp = async (response: Response, adapterName: string, attempt = parsed): Promise<boolean> => {
|
|
69
|
+
if (signal.aborted || !canRetry() || response.status !== 400 || isNonReplayableResponse(response)
|
|
70
|
+
|| adapterName !== "anthropic" || guard.attempted
|
|
71
|
+
|| parsed._stripReasoningEncryptedContent === true || !carriesThinkingState(attempt)) return false;
|
|
72
|
+
try {
|
|
73
|
+
const body = await readBoundedResponseBody(response.clone(), { signal });
|
|
74
|
+
if (!body.displaySafe || body.truncated) return false;
|
|
75
|
+
const message = upstreamErrorMessageFromPayload(JSON.parse(body.text) as unknown);
|
|
76
|
+
return typeof message === "string" && prepare(message, adapterName, attempt, response);
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const observe = (event: AdapterEvent): void => {
|
|
83
|
+
if (!armed) return;
|
|
84
|
+
if (event.type === "error" || event.type === "incomplete") failed = true;
|
|
85
|
+
if (event.type === "done") completed = true;
|
|
86
|
+
if ((event.type === "text_delta" && event.text.length > 0) || event.type === "tool_call_end") outputSeen = true;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
async function* wrap(
|
|
90
|
+
events: AsyncIterable<AdapterEvent>,
|
|
91
|
+
retry: () => AsyncIterable<AdapterEvent>,
|
|
92
|
+
adapterName: () => string,
|
|
93
|
+
replayAllowed = true,
|
|
94
|
+
response?: Response,
|
|
95
|
+
attempt = parsed,
|
|
96
|
+
): AsyncGenerator<AdapterEvent> {
|
|
97
|
+
let source = events;
|
|
98
|
+
let replaySafe = replayAllowed;
|
|
99
|
+
while (true) {
|
|
100
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
101
|
+
let recovered = false;
|
|
102
|
+
try {
|
|
103
|
+
while (true) {
|
|
104
|
+
const next = await iterator.next();
|
|
105
|
+
if (next.done) return;
|
|
106
|
+
const event = next.value;
|
|
107
|
+
if (replaySafe && event.type === "error" && prepare(event.message, adapterName(), attempt, response)) {
|
|
108
|
+
// Close the rejected body before acquiring another dispatch/pacing slot.
|
|
109
|
+
await iterator.return?.();
|
|
110
|
+
source = retry();
|
|
111
|
+
recovered = true;
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
// Even hidden reasoning, boundaries, and tool activity fence a replay. A heartbeat
|
|
115
|
+
// carrying a side-effect latch is never evidence that a turn is safe to repeat.
|
|
116
|
+
replaySafe &&= event.type === "heartbeat" && event.replayUnsafe !== true;
|
|
117
|
+
observe(event);
|
|
118
|
+
yield event;
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
if (!recovered) await iterator.return?.();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const rememberSuccess = (response: Readonly<Record<string, unknown>>): void => {
|
|
127
|
+
// HTTP 200, EOF, or a guard-generated completion alone cannot prove the stripped send worked.
|
|
128
|
+
// The delivery owner calls this only after its bridge/fold validated a successful terminal.
|
|
129
|
+
if (!armed || remembered || signal.aborted || failed || !completed || !outputSeen
|
|
130
|
+
|| response.status !== "completed" || !Array.isArray(response.output) || response.output.length === 0
|
|
131
|
+
|| !rejectedScope
|
|
132
|
+
|| rejectedScope.clientThreadId !== parsed._reasoningReplayScope?.clientThreadId
|
|
133
|
+
|| JSON.stringify(rejectedScope.current) !== JSON.stringify(parsed._reasoningReplayScope?.current)) return;
|
|
134
|
+
remembered = true;
|
|
135
|
+
rememberReasoningReplayOpaqueBlobRejection(rejectedScope, rejectedPayloads);
|
|
136
|
+
};
|
|
137
|
+
// The search owner supplies only a fully validated current iteration, never search-card output.
|
|
138
|
+
const observeIteration = (events: readonly AdapterEvent[]): void => {
|
|
139
|
+
outputSeen = false;
|
|
140
|
+
completed = false;
|
|
141
|
+
for (const event of events) observe(event);
|
|
142
|
+
};
|
|
143
|
+
const projectHttpFailure = async (response: Response, abortSignal: AbortSignal): Promise<Extract<AdapterEvent, { type: "error" }>> => {
|
|
144
|
+
const nonReplayable = isNonReplayableResponse(response);
|
|
145
|
+
if (nonReplayable) response = await sanitizeNonReplayableUpstreamError(response, abortSignal);
|
|
146
|
+
const text = await readDisplaySafeErrorText(response, abortSignal, `Provider error ${response.status}`);
|
|
147
|
+
const error = normalizeUpstreamErrorText(text, `Provider error ${response.status}`);
|
|
148
|
+
return { type: "error", status: response.status, message: error.message ?? error.safeText,
|
|
149
|
+
errorType: error.type ?? "upstream_error", ...(error.code ? { code: error.code } : {}),
|
|
150
|
+
...(nonReplayable ? { retryable: false } : {}) };
|
|
151
|
+
};
|
|
152
|
+
return { prepareHttp, prepareEvent: prepare, observeIteration, projectHttpFailure, wrap, rememberSuccess };
|
|
153
|
+
}
|
|
@@ -110,6 +110,31 @@ export function recallComboForLane(
|
|
|
110
110
|
return entry.responseModel === model ? entry.comboId : undefined;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The concrete target that completed the lane's last turn for `comboId`, under the same
|
|
115
|
+
* freshness and ownership rules as `recallComboForLane`. A lane answered by another combo, or
|
|
116
|
+
* whose target left the combo or was disabled, recalls nothing.
|
|
117
|
+
*/
|
|
118
|
+
export function recallComboTargetForLane(
|
|
119
|
+
config: OcxConfig,
|
|
120
|
+
lane: string | undefined,
|
|
121
|
+
comboId: string,
|
|
122
|
+
): Pick<OcxComboTarget, "provider" | "model"> | undefined {
|
|
123
|
+
if (!lane || !comboId) return undefined;
|
|
124
|
+
const entry = recall.get(lane);
|
|
125
|
+
if (!entry || entry.comboId !== comboId) return undefined;
|
|
126
|
+
const combo = getCombo(config, entry.comboId);
|
|
127
|
+
const provider = config.providers[entry.target.provider];
|
|
128
|
+
if (Date.now() - entry.at >= RECALL_TTL_MS
|
|
129
|
+
|| !Object.hasOwn(config.providers, entry.target.provider)
|
|
130
|
+
|| !provider || provider.disabled === true
|
|
131
|
+
|| !combo?.targets.some(target => targetKey(target) === targetKey(entry.target))) {
|
|
132
|
+
deleteEntry(lane);
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
return { provider: entry.target.provider, model: entry.target.model };
|
|
136
|
+
}
|
|
137
|
+
|
|
113
138
|
/**
|
|
114
139
|
* Periodic expiry. Without it a lane that is never read again and never touched by a config
|
|
115
140
|
* reconciliation holds its entry for the life of the process: the existing TTL is only
|
|
@@ -4,6 +4,7 @@ import { comboFailureDecision } from "../../combos";
|
|
|
4
4
|
import { httpStatusFromTerminalError } from "../../lib/errors";
|
|
5
5
|
import type { RequestFailureStage } from "../../lib/request-failure-model";
|
|
6
6
|
import type { RequestLogContext } from "../request-log";
|
|
7
|
+
import { carryResponseToolOwnership } from "./response-tool-ownership";
|
|
7
8
|
import { createSseInspector } from "../relay";
|
|
8
9
|
import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
|
|
9
10
|
|
|
@@ -114,12 +115,14 @@ function retryableZeroOutputTerminal(payload: unknown): boolean {
|
|
|
114
115
|
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
|
|
115
116
|
const event = payload as {
|
|
116
117
|
type?: unknown;
|
|
117
|
-
response?: { incomplete_details?: { reason?: unknown } };
|
|
118
|
+
response?: { incomplete_details?: { reason?: unknown }; output?: unknown };
|
|
118
119
|
};
|
|
119
120
|
if (bareErrorIsRetryable(event)) return true;
|
|
120
121
|
if (event.type === "response.failed") return true;
|
|
121
122
|
if (event.type !== "response.incomplete") return false;
|
|
122
123
|
const reason = event.response?.incomplete_details?.reason;
|
|
124
|
+
// A terminal-only upstream can carry output without preceding delta/item events.
|
|
125
|
+
if (reason === undefined) return !Array.isArray(event.response?.output) || event.response.output.length === 0;
|
|
123
126
|
return typeof reason === "string" && RETRYABLE_ZERO_OUTPUT_INCOMPLETE_REASONS.has(reason);
|
|
124
127
|
}
|
|
125
128
|
|
|
@@ -198,11 +201,11 @@ function replayBufferedResponse(
|
|
|
198
201
|
reader.cancel(reason).catch(() => undefined);
|
|
199
202
|
},
|
|
200
203
|
});
|
|
201
|
-
return new Response(body, {
|
|
204
|
+
return carryResponseToolOwnership(response, new Response(body, {
|
|
202
205
|
status: response.status,
|
|
203
206
|
statusText: response.statusText,
|
|
204
207
|
headers: response.headers,
|
|
205
|
-
});
|
|
208
|
+
}));
|
|
206
209
|
}
|
|
207
210
|
|
|
208
211
|
function failedTerminalResponse(
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Combo target pinning for an in-flight tool loop (#7298).
|
|
3
|
+
*
|
|
4
|
+
* A combo picks a target per request. A tool loop is several requests that together form one
|
|
5
|
+
* assistant turn: the model emits a call, the client runs it, and the next request carries the
|
|
6
|
+
* result back. Re-picking between those requests hands the second half of a turn to a model that
|
|
7
|
+
* did not produce the first half. That is wasteful at best (a cold prompt cache, a different
|
|
8
|
+
* plan) and a hard failure at worst: DeepSeek's thinking mode refuses a tool continuation whose
|
|
9
|
+
* call carries no reasoning it can read, and a GPT turn never leaves one.
|
|
10
|
+
*
|
|
11
|
+
* So a continuation whose input ends in a tool output keeps the target that completed the lane's
|
|
12
|
+
* previous turn, as long as that target is still selectable right now: enabled, still in the
|
|
13
|
+
* combo, out of cooldown and passing every per-request eligibility filter the caller supplies.
|
|
14
|
+
* When it is not, normal selection (and normal failover) runs, and the strict serializers make
|
|
15
|
+
* the foreign history acceptable instead: see `backfillBareToolCallTurnReasoning` and the Chat
|
|
16
|
+
* adapter's reasoning placeholder.
|
|
17
|
+
*
|
|
18
|
+
* A new user message is never pinned; re-deciding per user turn is what a combo is for.
|
|
19
|
+
*/
|
|
20
|
+
import { pickComboTarget, targetKey, type ComboPick } from "../../combos";
|
|
21
|
+
import type { NormalizedComboTarget } from "../../combos/types";
|
|
22
|
+
import type { OcxConfig } from "../../types";
|
|
23
|
+
import { recallComboTargetForLane } from "./combo-session-recall";
|
|
24
|
+
|
|
25
|
+
export const COMBO_TOOL_LOOP_PIN_REASON = "combo-tool-loop-pin";
|
|
26
|
+
|
|
27
|
+
function isToolOutputItem(item: unknown): boolean {
|
|
28
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return false;
|
|
29
|
+
const type = (item as { type?: unknown }).type;
|
|
30
|
+
return typeof type === "string" && type.endsWith("_output");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Whether this request resumes an assistant turn by returning tool results: its Responses
|
|
35
|
+
* `input` (or Chat `messages`) ends with a tool output.
|
|
36
|
+
*/
|
|
37
|
+
export function isToolLoopContinuation(body: unknown): boolean {
|
|
38
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return false;
|
|
39
|
+
const { input, messages } = body as { input?: unknown; messages?: unknown };
|
|
40
|
+
if (Array.isArray(input)) return input.length > 0 && isToolOutputItem(input[input.length - 1]);
|
|
41
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
42
|
+
const last = messages[messages.length - 1];
|
|
43
|
+
return !!last && typeof last === "object" && (last as { role?: unknown }).role === "tool";
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The pick that keeps a tool loop on its previous target, or undefined to select normally.
|
|
50
|
+
* Uses a non-mutating preview so a pinned request never disturbs round-robin rotation state.
|
|
51
|
+
*/
|
|
52
|
+
export function toolLoopPinnedPick(args: {
|
|
53
|
+
config: OcxConfig;
|
|
54
|
+
comboId: string;
|
|
55
|
+
lane: string | undefined;
|
|
56
|
+
body: unknown;
|
|
57
|
+
eligible: (target: NormalizedComboTarget) => boolean;
|
|
58
|
+
now?: number;
|
|
59
|
+
}): ComboPick | undefined {
|
|
60
|
+
if (!isToolLoopContinuation(args.body)) return undefined;
|
|
61
|
+
const previous = recallComboTargetForLane(args.config, args.lane, args.comboId);
|
|
62
|
+
if (!previous) return undefined;
|
|
63
|
+
const previousKey = targetKey(previous);
|
|
64
|
+
const pick = pickComboTarget(args.config, args.comboId, {
|
|
65
|
+
eligible: target => targetKey(target) === previousKey && args.eligible(target),
|
|
66
|
+
now: args.now,
|
|
67
|
+
preview: true,
|
|
68
|
+
});
|
|
69
|
+
return pick ? { ...pick, attempted: [previousKey] } : undefined;
|
|
70
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureAccountAutoPauseAttempt, wasAccountAutoPaused, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
|
|
1
2
|
import { spendLedgerStorageErrorResponse } from "./spend-storage-error";
|
|
2
3
|
import { createPhysicalSendReporter, rebindPhysicalSend } from "../../lib/request-execution-budget";
|
|
3
4
|
import { createInferenceSendBudget } from "../inference/context";
|
|
@@ -1058,6 +1059,7 @@ export async function handleResponsesCompact(
|
|
|
1058
1059
|
) => {
|
|
1059
1060
|
// A gateway response is not evidence about any Codex account.
|
|
1060
1061
|
if (gatewayTransport || !usesCodexForwardPoolAuth(ctx, route.provider)) return;
|
|
1062
|
+
if ((outcome === 401 || outcome === 403) && upstream && wasPermanentAccountRefusal(upstream)) return;
|
|
1061
1063
|
recordCodexUpstreamOutcome(config, ctx.accountId, outcome, {
|
|
1062
1064
|
...meta,
|
|
1063
1065
|
threadId: ctx.kind === "pool" || ctx.kind === "main-pool" ? ctx.affinityKey : undefined,
|
|
@@ -1166,6 +1168,7 @@ export async function handleResponsesCompact(
|
|
|
1166
1168
|
providerFetch(sendProvider, undefined, {
|
|
1167
1169
|
providerName: route.providerName,
|
|
1168
1170
|
modelId: route.modelId,
|
|
1171
|
+
accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: sendAuthCtx }, headers),
|
|
1169
1172
|
beforeDispatch: isCanonicalOpenAiForwardProvider(sendProvider)
|
|
1170
1173
|
? createCodexAuthDispatchGuard(sendAuthCtx, config, selectedModelId, admission) : undefined,
|
|
1171
1174
|
dispatchOverride: sendProvider.authMode !== "forward" && sendProvider.apiKey
|
|
@@ -1287,7 +1290,7 @@ export async function handleResponsesCompact(
|
|
|
1287
1290
|
|
|
1288
1291
|
recordDistributedResponseFailure(upstream, config, route.providerName, req.signal);
|
|
1289
1292
|
if (
|
|
1290
|
-
upstream.status === 401
|
|
1293
|
+
upstream.status === 401 && !wasPermanentAccountRefusal(upstream)
|
|
1291
1294
|
&& (authCtx.kind === "main-pool" || authCtx.kind === "pool")
|
|
1292
1295
|
&& !gatewayTransport
|
|
1293
1296
|
&& usesCodexForwardPoolAuth(authCtx, compactProvider)
|
|
@@ -1411,12 +1414,12 @@ export async function handleResponsesCompact(
|
|
|
1411
1414
|
// surfaces to the client, which retries the compact task OUTSIDE the logical request
|
|
1412
1415
|
// — reporting exhausted retries while another pool account sat idle (#913).
|
|
1413
1416
|
if (
|
|
1414
|
-
(upstream.status === 429 || upstream.status === 402)
|
|
1417
|
+
(upstream.status === 429 || upstream.status === 402 || wasAccountAutoPaused(upstream))
|
|
1415
1418
|
// A replay refusal this proxy synthesized carries 429 for the client's benefit only.
|
|
1416
1419
|
// It is not pool quota evidence, and the alternate account below is another send of a
|
|
1417
1420
|
// compact turn that may already have been processed.
|
|
1418
1421
|
&& !isNonReplayableResponse(upstream)
|
|
1419
|
-
&& !storedPool401ReplayAttempted
|
|
1422
|
+
&& (!storedPool401ReplayAttempted || wasAccountAutoPaused(upstream))
|
|
1420
1423
|
&& !gatewayTransport
|
|
1421
1424
|
&& usesCodexForwardPoolAuth(authCtx, route.provider)
|
|
1422
1425
|
&& !authCtx.fixedAccount
|
|
@@ -1521,7 +1524,7 @@ export async function handleResponsesCompact(
|
|
|
1521
1524
|
// the same guards as the quota move, and a 5xx whose body confirms quota keeps today's path.
|
|
1522
1525
|
if (
|
|
1523
1526
|
shouldRetryCodexPoolAccountTransient(upstream)
|
|
1524
|
-
&& !storedPool401ReplayAttempted
|
|
1527
|
+
&& (!storedPool401ReplayAttempted || wasAccountAutoPaused(upstream))
|
|
1525
1528
|
&& !gatewayTransport
|
|
1526
1529
|
&& usesCodexForwardPoolAuth(authCtx, route.provider)
|
|
1527
1530
|
&& !authCtx.fixedAccount
|
|
@@ -1642,7 +1645,7 @@ export async function handleResponsesCompact(
|
|
|
1642
1645
|
inspectResponseLogJson(logCtx, compactText);
|
|
1643
1646
|
if (!options.compactionRoutingOverride) forgetCompactHandoffRoute(req, admission);
|
|
1644
1647
|
rememberServingConversationStateIssuer(outcomeCtx, codexPoolAffinityKey(req.headers));
|
|
1645
|
-
} else if (!options.compactionRoutingOverride && quotaFailure && !storedPool401ReplayAttempted) {
|
|
1648
|
+
} else if (!options.compactionRoutingOverride && quotaFailure && (!storedPool401ReplayAttempted || wasAccountAutoPaused(upstream))) {
|
|
1646
1649
|
const fallbackModel = compactHandoffRoute(req, admission, raw.model);
|
|
1647
1650
|
if (fallbackModel && !req.signal.aborted) {
|
|
1648
1651
|
const fallbackReq = new Request(req.url, {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AdapterEvent, OcxConfig } from "../../types";
|
|
2
|
+
import { carryResponseToolOwnership } from "./response-tool-ownership";
|
|
2
3
|
import { routeConcreteModel, type RouteResult } from "../../router";
|
|
3
4
|
import { copyPlainData } from "../../lib/plain-data";
|
|
4
5
|
import { jsonUtf8Bytes } from "../../lib/json-byte-size";
|
|
@@ -78,6 +79,7 @@ async function bufferedJson(response: Response, signal: AbortSignal): Promise<{
|
|
|
78
79
|
headers.delete("content-length");
|
|
79
80
|
headers.delete("content-encoding");
|
|
80
81
|
const replacement = new Response(bytes.bytes, { status: response.status, statusText: response.statusText, headers });
|
|
82
|
+
carryResponseToolOwnership(response, replacement);
|
|
81
83
|
if (isNonReplayableResponse(response)) markResponseNonReplayable(replacement);
|
|
82
84
|
try {
|
|
83
85
|
const json: unknown = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.bytes));
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { bridgeToResponsesSSE, formatErrorResponse } from "../../bridge";
|
|
2
2
|
import { OLLAMA_LATE_ATTRIBUTION_LIMIT_MESSAGE } from "../../adapters/ollama-native";
|
|
3
|
+
import { isThroughputLimitText } from "../../claude/outbound";
|
|
4
|
+
import { isDefiniteContextOverflow } from "../../combos/failover";
|
|
5
|
+
import { classifyError } from "../../lib/errors";
|
|
3
6
|
import type { TranslatorBudget } from "../../lib/translator-budget";
|
|
4
7
|
import type { AdapterEvent } from "../../types";
|
|
5
8
|
|
|
@@ -32,8 +35,11 @@ export function jsonContextOverflowResponse(message = PROVIDER_INPUT_TOO_LARGE_M
|
|
|
32
35
|
}, { status: 413, headers: { "Cache-Control": "no-store" } });
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
/** The terminal error event every context overflow is reported as, streaming or in-stream. */
|
|
39
|
+
export function contextOverflowEvent(
|
|
40
|
+
message = PROVIDER_INPUT_TOO_LARGE_MESSAGE,
|
|
41
|
+
): Extract<AdapterEvent, { type: "error" }> {
|
|
42
|
+
return {
|
|
37
43
|
type: "error",
|
|
38
44
|
message,
|
|
39
45
|
status: 413,
|
|
@@ -43,6 +49,139 @@ async function* contextOverflowEvents(message: string): AsyncGenerator<AdapterEv
|
|
|
43
49
|
};
|
|
44
50
|
}
|
|
45
51
|
|
|
52
|
+
async function* contextOverflowEvents(message: string): AsyncGenerator<AdapterEvent> {
|
|
53
|
+
yield contextOverflowEvent(message);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether a web-search or image-bridge failure is the provider refusing an oversized input.
|
|
58
|
+
*
|
|
59
|
+
* An HTTP 413 is classified by status alone, exactly as the main path does. A 400/422 needs a
|
|
60
|
+
* definite overflow phrase in the provider message (the rule combo failover already trusts), so a
|
|
61
|
+
* plain bad request stays what it was, and rate-window wording ("too many tokens per minute")
|
|
62
|
+
* never reads as an overflow: compacting does not fix a throughput limit. An adapter that already
|
|
63
|
+
* classified its own failure as `context_length_exceeded` keeps that verdict whatever status it
|
|
64
|
+
* rode on. The local translator limit is excluded: compacting cannot shrink a buffer this proxy owns.
|
|
65
|
+
*/
|
|
66
|
+
export function isSidecarContextOverflow(
|
|
67
|
+
status: number | undefined,
|
|
68
|
+
message: string,
|
|
69
|
+
code?: string | null,
|
|
70
|
+
): boolean {
|
|
71
|
+
if (code === "translation_buffer_limit" || code === "rate_limit_exceeded") return false;
|
|
72
|
+
if (code === "context_length_exceeded") return true;
|
|
73
|
+
if (status === 413) return true;
|
|
74
|
+
return (status === 400 || status === 422) && !isThroughputLimitText(message)
|
|
75
|
+
&& isDefiniteContextOverflow(status, message);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The in-stream terminal for a sidecar overflow. A bare 413 carries no provider wording worth
|
|
80
|
+
* keeping and gets the proxy-owned message. A worded verdict keeps its own status and message:
|
|
81
|
+
* that is the evidence combo failover reads to try a larger-context target, and replacing it
|
|
82
|
+
* would end the chain at the first target that was merely too small.
|
|
83
|
+
*/
|
|
84
|
+
export function sidecarContextOverflowEvent(
|
|
85
|
+
status: number | undefined,
|
|
86
|
+
message: string,
|
|
87
|
+
): Extract<AdapterEvent, { type: "error" }> {
|
|
88
|
+
return status === undefined || !message || (status === 413 && !isDefiniteContextOverflow(413, message))
|
|
89
|
+
? contextOverflowEvent()
|
|
90
|
+
: { ...contextOverflowEvent(message), status };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** One sidecar loop failure, with what the loop knew when it raised it. */
|
|
94
|
+
export interface SidecarLoopFailure {
|
|
95
|
+
status: number;
|
|
96
|
+
message: string;
|
|
97
|
+
event?: Extract<AdapterEvent, { type: "error" }>;
|
|
98
|
+
/** The provider's own `error.code`, read from a bounded display-safe body. */
|
|
99
|
+
upstreamCode?: string;
|
|
100
|
+
/** The failed response was marked non-replayable: the provider may already have run the turn. */
|
|
101
|
+
nonReplayable?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The overflow terminal a sidecar loop failure should end with, or `undefined` to keep the
|
|
106
|
+
* failure as it is. A non-replayable failure keeps its verdict: an overflow would invite a
|
|
107
|
+
* compacted resend of a turn the provider may already have run. An adapter event marked
|
|
108
|
+
* `retryable: false` under any other code is that adapter's own verdict and is not reread.
|
|
109
|
+
*/
|
|
110
|
+
export function sidecarLoopOverflowEvent(
|
|
111
|
+
failure: SidecarLoopFailure,
|
|
112
|
+
): Extract<AdapterEvent, { type: "error" }> | undefined {
|
|
113
|
+
if (failure.nonReplayable) return undefined;
|
|
114
|
+
const code = failure.event?.code ?? failure.upstreamCode;
|
|
115
|
+
if (failure.event?.retryable === false && code !== "context_length_exceeded") return undefined;
|
|
116
|
+
if (!isSidecarContextOverflow(failure.status, failure.message, code)) return undefined;
|
|
117
|
+
return sidecarContextOverflowEvent(failure.event?.status ?? failure.status, failure.message);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The in-stream terminal for a sidecar loop failure that carries no adapter event, or
|
|
122
|
+
* `undefined` to keep the loop's existing generic event. An overflow ends as the overflow
|
|
123
|
+
* terminal. A failure this classifier deliberately kept (non-replayable, or an upstream
|
|
124
|
+
* `rate_limit_exceeded`) gets an explicit code whenever the bridge's wording heuristics would
|
|
125
|
+
* otherwise rename it `context_length_exceeded` and undo that verdict on the wire.
|
|
126
|
+
*/
|
|
127
|
+
export function sidecarLoopTerminalEvent(
|
|
128
|
+
failure: SidecarLoopFailure,
|
|
129
|
+
): Extract<AdapterEvent, { type: "error" }> | undefined {
|
|
130
|
+
const overflow = sidecarLoopOverflowEvent(failure);
|
|
131
|
+
if (overflow || failure.event) return overflow;
|
|
132
|
+
if (classifyError(failure.status, "upstream_error", failure.message).code !== "context_length_exceeded") return undefined;
|
|
133
|
+
const rateLimited = failure.upstreamCode === "rate_limit_exceeded";
|
|
134
|
+
return {
|
|
135
|
+
type: "error",
|
|
136
|
+
status: failure.status,
|
|
137
|
+
message: failure.message,
|
|
138
|
+
errorType: rateLimited ? "rate_limit_error" : "upstream_error",
|
|
139
|
+
code: failure.upstreamCode ?? "upstream_error",
|
|
140
|
+
...(failure.nonReplayable ? { retryable: false } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Read a provider's `error.code` from an already bounded, display-safe error body. */
|
|
145
|
+
export function upstreamErrorCode(text: string): string | undefined {
|
|
146
|
+
try {
|
|
147
|
+
const code = (JSON.parse(text) as { error?: { code?: unknown } } | null)?.error?.code;
|
|
148
|
+
return typeof code === "string" && code.length <= 128 ? code : undefined;
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const sidecarOverflowResponses = new WeakSet<Response>();
|
|
155
|
+
|
|
156
|
+
/** Tag a loop's first-iteration JSON failure that the sidecar boundary should map to an overflow. */
|
|
157
|
+
export function markSidecarContextOverflow(response: Response, failure: SidecarLoopFailure): Response {
|
|
158
|
+
if (sidecarLoopOverflowEvent(failure)) sidecarOverflowResponses.add(response);
|
|
159
|
+
return response;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Map a sidecar loop's pre-stream HTTP failure onto the main path's overflow contract (#7316).
|
|
164
|
+
*
|
|
165
|
+
* Both loops answer a failed first iteration with their own small JSON error, so the overflow
|
|
166
|
+
* would otherwise reach Codex as a bare non-2xx that it resends unchanged through its retry
|
|
167
|
+
* budget. The loop classified the failure while it still had the provider's status, wording,
|
|
168
|
+
* code and replay verdict, and tagged the response; anything untagged is returned untouched.
|
|
169
|
+
* Combo attempts do not come through here: the combo layer reads the raw failure for its hop
|
|
170
|
+
* decision and maps a final 413 itself. A failure answered with `x-should-retry: false` keeps
|
|
171
|
+
* its status and header, so that contract never turns into an invitation to resend.
|
|
172
|
+
*/
|
|
173
|
+
export function sidecarContextOverflowResponse(
|
|
174
|
+
response: Response,
|
|
175
|
+
options: { stream: boolean; modelId: string; translatorBudget: TranslatorBudget },
|
|
176
|
+
): Response {
|
|
177
|
+
if (response.ok || !sidecarOverflowResponses.has(response)) return response;
|
|
178
|
+
if (response.headers.get("x-should-retry") === "false") return response;
|
|
179
|
+
void response.body?.cancel().catch(() => {});
|
|
180
|
+
return options.stream
|
|
181
|
+
? streamingContextOverflowResponse(options.modelId, options.translatorBudget)
|
|
182
|
+
: jsonContextOverflowResponse();
|
|
183
|
+
}
|
|
184
|
+
|
|
46
185
|
/**
|
|
47
186
|
* Convert a pre-stream provider 413 into the terminal Responses event Codex understands.
|
|
48
187
|
*
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureAccountAutoPauseAttempt, wasPermanentAccountRefusal } from "../../oauth/account-auto-pause";
|
|
1
2
|
import type { OcxConfig, OcxProviderConfig, OcxParsedRequest } from "../../types";
|
|
2
3
|
import type { CodexAuthContext, CodexAuthPolicyConfig } from "../../codex/auth-context";
|
|
3
4
|
import type { CodexUpstreamOutcome } from "../../codex/routing";
|
|
@@ -144,7 +145,7 @@ export function recordCodexPreOutputRefusalOutcome(
|
|
|
144
145
|
modelId: string,
|
|
145
146
|
): void {
|
|
146
147
|
if (!isCodexWsPreOutputRefusal(response) || codexWsPreOutputRefusalOutcomeRecorded(response)) return;
|
|
147
|
-
if (!usesCodexForwardPoolAuth(authCtx, provider)) return;
|
|
148
|
+
if (!usesCodexForwardPoolAuth(authCtx, provider) || wasPermanentAccountRefusal(response)) return;
|
|
148
149
|
noteCodexWsPreOutputRefusalOutcome(response);
|
|
149
150
|
recordCodexUpstreamOutcome(config, authCtx.accountId, response.status, {
|
|
150
151
|
threadId: authCtx.affinityKey,
|
|
@@ -855,6 +856,10 @@ export async function retryCodexPoolOnAlternateAccount(
|
|
|
855
856
|
options.deferCodexResetDerivedCooldown,
|
|
856
857
|
);
|
|
857
858
|
const recordFirstOutcome = (): void => {
|
|
859
|
+
if (wasPermanentAccountRefusal(firstResponse)) {
|
|
860
|
+
noteCodexWsPreOutputRefusalOutcome(firstResponse);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
858
863
|
recordCodexUpstreamOutcome(config, firstAuthCtx.accountId, outcomeStatus, {
|
|
859
864
|
...quotaMeta,
|
|
860
865
|
threadId: firstAuthCtx.affinityKey,
|
|
@@ -975,8 +980,8 @@ export async function retryCodexPoolOnAlternateAccount(
|
|
|
975
980
|
const maxRetrySends = retrySameConfirmedAccount ? 7 : 1;
|
|
976
981
|
let retrySendCount = 0;
|
|
977
982
|
// The gated-model 400 has no roster kind; it stays unattributed as before.
|
|
978
|
-
const moveRecovery: AttemptRecoveryKind | undefined = outcomeStatus === 429
|
|
979
|
-
? "oauth-account-
|
|
983
|
+
const moveRecovery: AttemptRecoveryKind | undefined = outcomeStatus === 429 ? "oauth-account-429"
|
|
984
|
+
: outcomeStatus === 402 ? "oauth-account-402"
|
|
980
985
|
: isTransientUpstreamStatus(outcomeStatus) ? "transient-5xx" : undefined;
|
|
981
986
|
let upstreamResponse: Response;
|
|
982
987
|
try {
|
|
@@ -1032,6 +1037,7 @@ export async function retryCodexPoolOnAlternateAccount(
|
|
|
1032
1037
|
providerName: route.providerName,
|
|
1033
1038
|
modelId: route.modelId,
|
|
1034
1039
|
preOutputRefusalHold: args.preOutputRefusalHold,
|
|
1040
|
+
accountAutoPause: (name, provider, headers) => captureAccountAutoPauseAttempt(name, provider, { config, codex: retryAuthCtx }, headers),
|
|
1035
1041
|
onCodexWsQuota: codexWsQuotaObserver(retryAuthCtx, route.provider, route.modelId),
|
|
1036
1042
|
beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
|
|
1037
1043
|
? createCodexAuthDispatchGuard(retryAuthCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
|