@bitkyc08/opencodex 2.58.0 → 2.60.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 +28 -10
- package/gui/dist/assets/index-BTuCbqQd.css +1 -0
- package/gui/dist/assets/index-DoBVdPHP.js +134 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/crusoe.svg +1 -0
- package/gui/dist/provider-icons/opper.svg +3 -0
- package/package.json +4 -1
- package/src/adapters/anthropic-image-codec.ts +16 -2
- package/src/adapters/anthropic-image-normalize.ts +49 -2
- package/src/adapters/anthropic.ts +4 -1
- package/src/adapters/base.ts +34 -1
- package/src/adapters/coding-agent/turn.ts +22 -2
- package/src/adapters/command-code.ts +50 -3
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/checkpoint-store.ts +3 -0
- package/src/adapters/cursor/discovery.ts +11 -8
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- package/src/adapters/cursor/live-transport.ts +26 -9
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-request.ts +8 -5
- package/src/adapters/cursor/request-builder.ts +21 -4
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/transport.ts +19 -0
- package/src/adapters/cursor.ts +45 -2
- package/src/adapters/devin/cloud-direct/chat.ts +14 -3
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
- package/src/adapters/devin.ts +125 -26
- package/src/adapters/google-antigravity-replay.ts +1 -1
- package/src/adapters/google-antigravity-wire.ts +55 -4
- package/src/adapters/google-http.ts +57 -11
- package/src/adapters/google-tool-schema.ts +595 -31
- package/src/adapters/google-wire-compiler.ts +93 -10
- package/src/adapters/google-wire-shape.ts +461 -0
- package/src/adapters/google.ts +60 -10
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat-images.ts +3 -1
- package/src/adapters/openai-chat.ts +10 -11
- package/src/adapters/openai-responses/image-gen.ts +8 -6
- package/src/adapters/openai-responses/passthrough.ts +25 -4
- package/src/adapters/openai-responses/reasoning.ts +7 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- package/src/adapters/opencode-go-additional-tools.ts +12 -2
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +28 -35
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +35 -4
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +26 -6
- package/src/cli/account-orca-import.ts +63 -0
- package/src/cli/account.ts +32 -4
- package/src/cli/capabilities.ts +40 -0
- package/src/cli/claude.ts +29 -1
- package/src/cli/codex-cli-update.ts +97 -2
- package/src/cli/dispatch.ts +57 -3
- package/src/cli/doctor.ts +218 -4
- package/src/cli/help.ts +4 -1
- package/src/cli/hub.ts +3 -2
- package/src/cli/index.ts +95 -22
- package/src/cli/models-runtime.ts +33 -4
- package/src/cli/opencode.ts +2 -2
- package/src/cli/provider.ts +13 -1
- package/src/cli/registry.ts +11 -1
- package/src/cli/runtime-api.ts +44 -0
- package/src/cli/start-args.ts +94 -0
- package/src/cli/system-command.ts +2 -0
- package/src/client/machine-api.ts +6 -5
- package/src/client/machine-listener.ts +16 -3
- package/src/client/runtime.ts +26 -2
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +146 -5
- package/src/codex/auth-api/account-list.ts +19 -11
- package/src/codex/auth-api/pool-quota-probe.ts +44 -10
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +16 -12
- package/src/codex/catalog/build-entries.ts +25 -4
- package/src/codex/catalog/derive-entry.ts +8 -1
- package/src/codex/catalog/effort.ts +10 -6
- package/src/codex/catalog/gather-capture.ts +22 -2
- package/src/codex/catalog/model-hints.ts +66 -33
- package/src/codex/catalog/parsing.ts +90 -5
- package/src/codex/catalog/provider-models.ts +19 -2
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +41 -26
- package/src/codex/catalog/routed-gather.ts +61 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/context-compat.ts +5 -2
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desired-state.ts +4 -1
- package/src/codex/history-job.ts +6 -6
- package/src/codex/history-provider.ts +24 -167
- package/src/codex/history-rollout-read.ts +174 -0
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject.ts +3 -2
- package/src/codex/internal/catalog-writer.ts +33 -1
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +31 -0
- package/src/codex/main-account-hard-lock.ts +2 -1
- package/src/codex/main-account.ts +10 -3
- package/src/codex/main-device-reauth.ts +17 -9
- package/src/codex/model-cache.ts +47 -0
- package/src/codex/model-entitlements.ts +80 -2
- package/src/codex/observed-model-denials.ts +230 -0
- package/src/codex/orca-auth-source.ts +94 -0
- package/src/codex/orca-import.ts +219 -0
- package/src/codex/prompt-text-probe.ts +289 -16
- package/src/codex/quota-401-recovery.ts +12 -0
- package/src/codex/quota-types.ts +65 -0
- package/src/codex/quota.ts +24 -19
- package/src/codex/routing/cooldown-math.ts +8 -47
- package/src/codex/routing/pin-drain.ts +57 -0
- package/src/codex/routing.ts +20 -16
- package/src/codex/shim.ts +1 -1
- package/src/codex/subagent-model-fallback.ts +114 -2
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +125 -5
- package/src/config/admitted-identity.ts +222 -0
- package/src/config/diagnostics.ts +43 -1
- package/src/config/feature-flags.ts +5 -0
- package/src/config/load-degrade.ts +33 -0
- package/src/config/pending-teardown.ts +8 -0
- package/src/config/process-state.ts +36 -3
- package/src/config/provider-relative-send-path.ts +16 -0
- package/src/config/proxy-env.ts +31 -7
- package/src/config/schema/compaction-triggers.ts +11 -0
- package/src/config/schema/config-schema.ts +25 -0
- package/src/config/schema/leaf-validators.ts +76 -17
- package/src/config.ts +2 -2
- package/src/generated/compatibility-version.json +410 -258
- package/src/generated/model-metadata.ts +1 -1
- package/src/grok/reset-coupons.ts +38 -19
- package/src/images/loop.ts +6 -1
- package/src/integrations/aside-profile-context.ts +37 -3
- package/src/integrations/aside-profile-journal.ts +68 -3
- package/src/integrations/aside-profiles.ts +128 -3
- package/src/integrations/mutation-plan.ts +815 -0
- package/src/integrations/writer.ts +85 -99
- package/src/lab/live/transport.ts +4 -0
- package/src/lab/live/types.ts +5 -0
- package/src/lab/subject/behavior-fingerprint.ts +1 -1
- package/src/lib/admin-secrets.ts +9 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/debug-log-buffer.ts +6 -1
- package/src/lib/debug.ts +23 -0
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +82 -15
- package/src/lib/http-response-semantics.ts +57 -0
- package/src/lib/lab-live-pinned-sender.ts +26 -12
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/pinned-http.ts +142 -2
- package/src/lib/plain-data.ts +103 -0
- package/src/lib/process-control.ts +13 -5
- package/src/lib/provider-outbound.ts +53 -5
- package/src/lib/proxy-env.ts +70 -3
- package/src/lib/request-execution-budget.ts +11 -3
- package/src/lib/response-body-inactivity.ts +193 -0
- package/src/lib/retry-delay.ts +69 -0
- package/src/lib/socks5-fetch.ts +741 -0
- package/src/lib/spend-ledger-owner.ts +364 -0
- package/src/lib/spend-reservation-ledger.ts +332 -35
- package/src/lib/windows-system-proxy.ts +16 -11
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/callback-server.ts +4 -3
- package/src/oauth/generic-account-failover.ts +41 -27
- package/src/oauth/health.ts +12 -1
- package/src/oauth/index.ts +3 -107
- package/src/oauth/login-flow-state.ts +127 -0
- package/src/oauth/orcarouter.ts +15 -2
- package/src/oauth/store.ts +8 -0
- package/src/providers/codex-capacity.ts +9 -0
- package/src/providers/derive.ts +34 -17
- package/src/providers/devin-cli-authmode-migration.ts +14 -10
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +327 -25
- package/src/providers/model-rename-migration.ts +56 -1
- package/src/providers/model-rename-startup.ts +7 -5
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/openai-virtual-models.ts +42 -2
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/antigravity.ts +22 -2
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/quota/vendor-probes-key.ts +1 -1
- package/src/providers/registry/entries-core.ts +39 -17
- package/src/providers/registry/entries-extended.ts +131 -1
- package/src/providers/registry/model-ids.ts +168 -0
- package/src/providers/registry/model-seeds.ts +87 -21
- package/src/providers/registry/types.ts +2 -0
- package/src/providers/resolved-model-policy-merge.ts +167 -0
- package/src/providers/resolved-model-policy.ts +406 -0
- package/src/providers/stale-vision-classification-migration.ts +137 -0
- package/src/responses/apply-patch-envelope.ts +32 -11
- package/src/responses/bridge-search-replay-cache.ts +152 -0
- package/src/responses/code-mode-helper-compat.ts +26 -16
- package/src/responses/custom-tool-compat.ts +1 -1
- package/src/responses/freeform-wrapper-scan.ts +279 -0
- package/src/responses/hosted-tool-policy.ts +85 -2
- package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
- package/src/responses/progressive-freeform-input.ts +130 -0
- package/src/responses/reasoning-envelope.ts +30 -0
- package/src/responses/schema.ts +9 -2
- package/src/responses/state.ts +5 -12
- package/src/responses/tool-name-aliases.ts +15 -1
- package/src/router.ts +91 -115
- package/src/routing/compatibility/behavior.ts +9 -0
- package/src/routing/compatibility/subject.ts +16 -1
- package/src/server/adapter-resolve.ts +9 -0
- package/src/server/auth-cors.ts +29 -0
- package/src/server/chat-completions.ts +13 -5
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +30 -5
- package/src/server/effort-row.ts +11 -3
- package/src/server/grok-responses-control-frame.ts +160 -1
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/serve-options.ts +86 -29
- package/src/server/index/spend-ledger-lifecycle.ts +66 -0
- package/src/server/index/websocket-handler.ts +6 -1
- package/src/server/index.ts +24 -25
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/aside-profile-routes.ts +266 -7
- package/src/server/management/config-routes.ts +48 -7
- package/src/server/management/context.ts +3 -0
- package/src/server/management/integration-routes.ts +287 -5
- package/src/server/management/metrics-routes.ts +20 -0
- package/src/server/management/model-rows.ts +224 -12
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/management/route-registry.ts +14 -0
- package/src/server/management/shared.ts +10 -3
- package/src/server/management/system-restart.ts +7 -2
- package/src/server/management/system-routes.ts +2 -0
- package/src/server/management/usage-aggregate-cache.ts +4 -0
- package/src/server/management-api.ts +2 -0
- package/src/server/management-auth.ts +15 -1
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/readiness.ts +29 -10
- package/src/server/relay-eager.ts +24 -2
- package/src/server/relay.ts +136 -34
- package/src/server/request-log.ts +80 -3
- package/src/server/request-metrics.ts +236 -0
- package/src/server/responses/adapter-continuation.ts +74 -30
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +63 -30
- package/src/server/responses/compact.ts +89 -18
- package/src/server/responses/compaction-routing.ts +111 -0
- package/src/server/responses/core-codex-account.ts +90 -24
- package/src/server/responses/core-combo.ts +7 -7
- package/src/server/responses/core-normalize.ts +16 -15
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +4 -0
- package/src/server/responses/encrypted-payload.ts +20 -2
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +41 -15
- package/src/server/responses/passthrough-dispatch.ts +145 -53
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/policy-fallback.ts +5 -13
- package/src/server/responses/request-prepare.ts +96 -17
- package/src/server/responses/request-send-budget.ts +89 -8
- package/src/server/responses/request-sidecar-auth.ts +17 -9
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +15 -12
- package/src/server/responses/run-turn-execution.ts +45 -8
- package/src/server/responses/sidecar-execution.ts +19 -2
- package/src/server/responses/ws-upstream.ts +16 -28
- package/src/server/responses-custom-tool-repair.ts +29 -56
- package/src/server/responses-undeclared-tool-guard.ts +31 -1
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +37 -10
- package/src/server/system-env-shell.ts +5 -1
- package/src/server/system-env.ts +7 -1
- package/src/server/workflow-refusal.ts +56 -2
- package/src/service/cli.ts +16 -6
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/tray/windows-tray.ps1 +155 -3
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +111 -6
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +9 -1
- package/src/types/tools.ts +14 -0
- package/src/types/wire.ts +9 -1
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/vision/eligibility.ts +88 -9
- package/src/vision/plan.ts +34 -10
- package/src/web-search/executor.ts +41 -2
- package/src/web-search/loop.ts +6 -1
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-BbrHOIY0.js +0 -128
- package/gui/dist/assets/index-C5-RdDmD.css +0 -1
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
import { commitProviderApiKeySelection } from "./api-key-selection";
|
|
12
12
|
import type { ProviderApiKeySelection } from "../types/provider";
|
|
13
13
|
import { routedProviderConfig } from "../router";
|
|
14
|
+
import { getProviderRegistryEntry } from "./registry";
|
|
15
|
+
import { normalizedBaseUrl } from "./quota/vendor-probes-key";
|
|
14
16
|
import type { OcxConfig, OcxProviderConfig, RateLimitRetryPolicy, TransientRetryPolicy } from "../types";
|
|
15
17
|
import { OPENCODE_GO_SESSION_HEADER } from "./opencode-go-transport";
|
|
16
18
|
import { resolveProviderTransport, type OcxProviderTransport } from "./xai-transport";
|
|
@@ -29,6 +31,243 @@ interface KeyCooldown {
|
|
|
29
31
|
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
30
32
|
const MAX_COOLDOWN_MS = 10 * 60_000; // cap at 10 min for api-key rotation
|
|
31
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Cap for a cooldown the upstream itself dated, as opposed to one we inferred.
|
|
36
|
+
*
|
|
37
|
+
* `MAX_COOLDOWN_MS` is deliberately short because an undated 429 is a guess: ten
|
|
38
|
+
* minutes bounds how long a transient limit can park a working key. A free-tier
|
|
39
|
+
* quota is not a guess — OpenRouter replies `Weekly/Monthly Limit Exhausted ...
|
|
40
|
+
* will reset at <date>`, and until that date the key cannot serve anything. Held
|
|
41
|
+
* for ten minutes instead, it comes back, takes a 429, and rotates again, every
|
|
42
|
+
* ten minutes for the rest of the week (#4024).
|
|
43
|
+
*
|
|
44
|
+
* 32 days rather than unbounded. The wording this parses is
|
|
45
|
+
* `Weekly/Monthly Limit Exhausted`, so the cap has to clear a monthly window —
|
|
46
|
+
* 31 days plus a day of slack for timezone and month length. An earlier 8-day
|
|
47
|
+
* cap looked generous against the weekly case in the issue and silently clamped
|
|
48
|
+
* every monthly reset to ~23 days early, which puts the key back into exactly
|
|
49
|
+
* the 429 loop this exists to stop. Caught by the cap's own test.
|
|
50
|
+
*
|
|
51
|
+
* Bounded at all because the date is upstream-controlled input: a malformed or
|
|
52
|
+
* hostile `reset at 2999-01-01` must not park a working key past any horizon an
|
|
53
|
+
* operator would think to look at.
|
|
54
|
+
*/
|
|
55
|
+
const MAX_QUOTA_COOLDOWN_MS = 32 * 24 * 60 * 60_000;
|
|
56
|
+
const QUOTA_RESET_PEEK_TIMEOUT_MS = 250;
|
|
57
|
+
|
|
58
|
+
interface QuotaResetReadOptions {
|
|
59
|
+
now?: number;
|
|
60
|
+
signal?: AbortSignal;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function rebuiltResponse(response: Response, body: ReadableStream<Uint8Array>): Response {
|
|
65
|
+
return new Response(body, {
|
|
66
|
+
status: response.status,
|
|
67
|
+
statusText: response.statusText,
|
|
68
|
+
headers: response.headers,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function replayOnlyResponse(response: Response, chunks: readonly Uint8Array[]): Response {
|
|
73
|
+
return rebuiltResponse(response, new ReadableStream<Uint8Array>({
|
|
74
|
+
start(controller) {
|
|
75
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
76
|
+
controller.close();
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read a bounded prefix of a 429 body and pull the upstream's declared reset instant.
|
|
83
|
+
*
|
|
84
|
+
* The returned response replays the bounded prefix and any boundary-chunk overflow
|
|
85
|
+
* before streaming the unread remainder. A rotation storm must not be gated on
|
|
86
|
+
* reading N full error payloads. Any failure — no body, already consumed, slow,
|
|
87
|
+
* malformed — returns undefined and leaves the `Retry-After` path in charge.
|
|
88
|
+
*/
|
|
89
|
+
export async function readQuotaResetAt(
|
|
90
|
+
response: Response,
|
|
91
|
+
nowOrOptions: number | QuotaResetReadOptions = {},
|
|
92
|
+
): Promise<{ at: number | undefined; response: Response }> {
|
|
93
|
+
if (!response.body) return { at: undefined, response };
|
|
94
|
+
const options = typeof nowOrOptions === "number" ? { now: nowOrOptions } : nowOrOptions;
|
|
95
|
+
const now = options.now ?? Date.now();
|
|
96
|
+
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
97
|
+
try {
|
|
98
|
+
reader = response.body.getReader();
|
|
99
|
+
} catch {
|
|
100
|
+
return { at: undefined, response };
|
|
101
|
+
}
|
|
102
|
+
const chunks: Uint8Array[] = [];
|
|
103
|
+
let transferred = false;
|
|
104
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
+
const deadline = new AbortController();
|
|
106
|
+
const timeoutReason = new DOMException("Quota reset body peek timed out", "TimeoutError");
|
|
107
|
+
try {
|
|
108
|
+
const decoder = new TextDecoder();
|
|
109
|
+
let seen = 0;
|
|
110
|
+
let text = "";
|
|
111
|
+
timer = setTimeout(() => deadline.abort(timeoutReason), options.timeoutMs ?? QUOTA_RESET_PEEK_TIMEOUT_MS);
|
|
112
|
+
const signal = options.signal
|
|
113
|
+
? AbortSignal.any([options.signal, deadline.signal])
|
|
114
|
+
: deadline.signal;
|
|
115
|
+
while (seen < QUOTA_RESET_SCAN_BYTES) {
|
|
116
|
+
const read = reader.read();
|
|
117
|
+
let rejectAbort: ((reason: unknown) => void) | undefined;
|
|
118
|
+
const onAbort = () => rejectAbort?.(signal.reason);
|
|
119
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
120
|
+
rejectAbort = reject;
|
|
121
|
+
if (signal.aborted) reject(signal.reason);
|
|
122
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
123
|
+
});
|
|
124
|
+
// Derived from the reader rather than named directly: Bun's lib types
|
|
125
|
+
// `ReadableStreamDefaultReader.read()` as returning
|
|
126
|
+
// `ReadableStreamDefaultReadResult`, which is not assignable to the
|
|
127
|
+
// `ReadableStreamReadResult` alias.
|
|
128
|
+
let result: Awaited<ReturnType<typeof reader.read>>;
|
|
129
|
+
try {
|
|
130
|
+
result = await Promise.race([read, aborted]);
|
|
131
|
+
} finally {
|
|
132
|
+
signal.removeEventListener("abort", onAbort);
|
|
133
|
+
}
|
|
134
|
+
const { done, value } = result;
|
|
135
|
+
if (done) break;
|
|
136
|
+
const remaining = QUOTA_RESET_SCAN_BYTES - seen;
|
|
137
|
+
const prefix = value.byteLength > remaining ? value.subarray(0, remaining) : value;
|
|
138
|
+
const overflow = value.byteLength > remaining ? value.subarray(remaining) : undefined;
|
|
139
|
+
chunks.push(prefix);
|
|
140
|
+
if (overflow?.byteLength) chunks.push(overflow);
|
|
141
|
+
seen += prefix.byteLength;
|
|
142
|
+
text += decoder.decode(prefix, { stream: true });
|
|
143
|
+
}
|
|
144
|
+
// Hand back a Response carrying the bytes already pulled followed by whatever
|
|
145
|
+
// is left, so the caller can still read or cancel it. `response.clone()` is
|
|
146
|
+
// NOT usable here: it tees, and with the original branch undrained the tee
|
|
147
|
+
// stalls once its buffer fills — a 5MB error body hangs the rotation path,
|
|
148
|
+
// which is worse than the unbounded read this replaced.
|
|
149
|
+
const rest = new ReadableStream<Uint8Array>({
|
|
150
|
+
start(controller) {
|
|
151
|
+
for (const c of chunks) controller.enqueue(c);
|
|
152
|
+
},
|
|
153
|
+
async pull(controller) {
|
|
154
|
+
try {
|
|
155
|
+
const { done, value } = await reader.read();
|
|
156
|
+
if (done) {
|
|
157
|
+
controller.close();
|
|
158
|
+
reader.releaseLock();
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
controller.enqueue(value);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
controller.error(error);
|
|
164
|
+
reader.releaseLock();
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
async cancel(reason) {
|
|
168
|
+
try {
|
|
169
|
+
await reader.cancel(reason);
|
|
170
|
+
} finally {
|
|
171
|
+
reader.releaseLock();
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
transferred = true;
|
|
176
|
+
return { at: parseQuotaResetAt(text, now), response: rebuiltResponse(response, rest) };
|
|
177
|
+
} catch (error) {
|
|
178
|
+
const clientAborted = options.signal?.aborted === true;
|
|
179
|
+
void reader.cancel(error).catch(() => {}).finally(() => {
|
|
180
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
181
|
+
});
|
|
182
|
+
if (clientAborted) throw options.signal!.reason ?? error;
|
|
183
|
+
return { at: undefined, response: replayOnlyResponse(response, chunks) };
|
|
184
|
+
} finally {
|
|
185
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
186
|
+
if (!transferred) {
|
|
187
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* How much of a 429 body is read and scanned for the reset instant.
|
|
194
|
+
*
|
|
195
|
+
* Bounds the READ, not just the parse: this runs on the rotation path, once per
|
|
196
|
+
* rotated key under a rate-limit storm, and the body is upstream-controlled.
|
|
197
|
+
* OpenRouter's rate_limit_error JSON is a few hundred bytes.
|
|
198
|
+
*/
|
|
199
|
+
const QUOTA_RESET_SCAN_BYTES = 4_096;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Reset instant an upstream declared in a 429 *body*, in epoch ms.
|
|
203
|
+
*
|
|
204
|
+
* Only the body carries this: OpenRouter sends no `Retry-After` for a quota
|
|
205
|
+
* exhaustion, so the header path (`parseRetryAfterMs`) sees nothing and falls
|
|
206
|
+
* back to `DEFAULT_COOLDOWN_MS`. Returns undefined for anything it cannot read
|
|
207
|
+
* as a date, so an unparsable body keeps today's behaviour exactly.
|
|
208
|
+
*/
|
|
209
|
+
/**
|
|
210
|
+
* Whether `YYYY-MM-DD…` names a day that exists.
|
|
211
|
+
*
|
|
212
|
+
* `Date.parse` does NOT reject an out-of-range day: measured on Bun,
|
|
213
|
+
* `2026-02-30T00:00:00Z` yields March 2 and `2026-04-31T00:00:00Z` yields
|
|
214
|
+
* May 1, so a malformed upstream body would park a key past the instant it
|
|
215
|
+
* actually named. Only the month is rejected outright (`2026-13-01` is NaN).
|
|
216
|
+
*
|
|
217
|
+
* Checked on the date text alone rather than by round-tripping the parsed
|
|
218
|
+
* instant, because a value carrying an explicit offset (`…T23:00+05:30`)
|
|
219
|
+
* legitimately lands on a different UTC day than the one written.
|
|
220
|
+
*/
|
|
221
|
+
function isRealCalendarDate(value: string): boolean {
|
|
222
|
+
const [year, month, day] = value.slice(0, 10).split("-").map(Number);
|
|
223
|
+
if (month < 1 || month > 12 || day < 1) return false;
|
|
224
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
225
|
+
const lengths = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
226
|
+
return day <= lengths[month - 1]!;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function parseQuotaResetAt(body: string | null | undefined, now = Date.now()): number | undefined {
|
|
230
|
+
const text = body?.slice(0, QUOTA_RESET_SCAN_BYTES);
|
|
231
|
+
if (!text) return undefined;
|
|
232
|
+
let parsed: unknown;
|
|
233
|
+
try {
|
|
234
|
+
parsed = JSON.parse(text);
|
|
235
|
+
} catch {
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
239
|
+
const error = (parsed as { error?: unknown }).error;
|
|
240
|
+
if (!error || typeof error !== "object") return undefined;
|
|
241
|
+
const code = (error as { code?: unknown }).code;
|
|
242
|
+
const message = (error as { message?: unknown }).message;
|
|
243
|
+
if ((code !== "rate_limit_error" && code !== 429) || typeof message !== "string") return undefined;
|
|
244
|
+
if (!/^(?:Weekly|Monthly) Limit Exhausted\b/i.test(message.trim())) return undefined;
|
|
245
|
+
// `will reset at 2026-09-09 03:30:06` / `... at 2026-09-09T03:30:06Z` / `resets at <date>`
|
|
246
|
+
const match = /reset[s]?\s+at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}(?:[T ][0-9]{2}:[0-9]{2}(?::[0-9]{2})?(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:?[0-9]{2})?)?)/i.exec(message);
|
|
247
|
+
if (!match) return undefined;
|
|
248
|
+
// Pin a bare `YYYY-MM-DD hh:mm:ss` to UTC explicitly.
|
|
249
|
+
//
|
|
250
|
+
// ECMA-262 says a date-TIME form with no offset is LOCAL time, and Node follows
|
|
251
|
+
// that: `Date.parse("2026-09-09 03:30:06")` differs from the UTC reading by the
|
|
252
|
+
// host offset (7h on a PDT box, measured). Bun currently returns the UTC value
|
|
253
|
+
// for the same string, so on this runtime the normalisation is a no-op today —
|
|
254
|
+
// which is exactly why it is written out rather than relied upon. If Bun ever
|
|
255
|
+
// conforms, an un-normalised parse would silently shift every park-until by the
|
|
256
|
+
// operator's offset, and the early direction resumes the 429 loop.
|
|
257
|
+
//
|
|
258
|
+
// A consequence worth knowing: no Bun test can observe this branch being
|
|
259
|
+
// removed. The explicit-zone case below is the part the suite can pin.
|
|
260
|
+
const raw = match[1].includes("T") || /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/.test(match[1])
|
|
261
|
+
? match[1]
|
|
262
|
+
: `${match[1].replace(" ", "T")}Z`;
|
|
263
|
+
if (!isRealCalendarDate(match[1])) return undefined;
|
|
264
|
+
const at = Date.parse(raw);
|
|
265
|
+
if (!Number.isFinite(at)) return undefined;
|
|
266
|
+
// Already past, or beyond the cap: not usable as a park-until instant.
|
|
267
|
+
if (at <= now) return undefined;
|
|
268
|
+
return Math.min(at, now + MAX_QUOTA_COOLDOWN_MS);
|
|
269
|
+
}
|
|
270
|
+
|
|
32
271
|
/**
|
|
33
272
|
* Default same-target 429 retry policy used when a provider opts in via a bare
|
|
34
273
|
* `retryOn429: {}` (presence = opt-in with these defaults).
|
|
@@ -41,6 +280,43 @@ const DEFAULT_RATE_LIMIT_RETRY = {
|
|
|
41
280
|
respectRetryAfter: true,
|
|
42
281
|
} as const satisfies Required<RateLimitRetryPolicy>;
|
|
43
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Patient same-target 429 fallback for the OpenCode Go destination
|
|
285
|
+
* (https://opencode.ai/zen/go/v1), which serves subscription traffic such as Muse Spark.
|
|
286
|
+
* Single-key pools cannot fail over, so without this a burst 429 surfaces immediately and
|
|
287
|
+
* the client retry budget aborts the goal. Six 10s-paced attempts absorb a short burst
|
|
288
|
+
* window (effective replays are additionally bounded by the shared per-request send
|
|
289
|
+
* budget); Retry-After is still honored and capped. An explicit `retryOn429` (including
|
|
290
|
+
* `enabled: false`) always overrides this fallback.
|
|
291
|
+
*/
|
|
292
|
+
const OPENCODE_GO_RATE_LIMIT_RETRY = {
|
|
293
|
+
enabled: true,
|
|
294
|
+
attempts: 6,
|
|
295
|
+
intervalMs: 10_000,
|
|
296
|
+
maxIntervalMs: 60_000,
|
|
297
|
+
respectRetryAfter: true,
|
|
298
|
+
} as const satisfies Required<RateLimitRetryPolicy>;
|
|
299
|
+
|
|
300
|
+
/** True when the provider row points at the OpenCode Go destination. */
|
|
301
|
+
export function isOpenCodeGoDestination(
|
|
302
|
+
provider: Partial<Pick<OcxProviderConfig, "baseUrl" | "authMode">>,
|
|
303
|
+
): boolean {
|
|
304
|
+
const raw = typeof provider.baseUrl === "string" ? provider.baseUrl : "";
|
|
305
|
+
if (!raw.trim()) return false;
|
|
306
|
+
// Endpoint identity, not adapter identity: the runtime adapter is already overridden
|
|
307
|
+
// per model by the time the recovery loop runs (muse-spark rides `openai-responses`
|
|
308
|
+
// while the preset declares `openai-chat`), so an adapter-strict lookup misses it.
|
|
309
|
+
// Canonicalize with the shared quota-probe normalizer so host case and explicit
|
|
310
|
+
// default ports compare equal; userinfo, query, and fragment never match
|
|
311
|
+
// (follow-up to the review on #5067).
|
|
312
|
+
const endpoint = normalizedBaseUrl(raw.trim());
|
|
313
|
+
if (!endpoint) return false;
|
|
314
|
+
const entry = getProviderRegistryEntry("opencode-go");
|
|
315
|
+
if (!entry) return false;
|
|
316
|
+
const candidates = [entry.baseUrl, ...(entry.destinationAliases ?? []).map(alias => alias.baseUrl)];
|
|
317
|
+
return candidates.some(url => normalizedBaseUrl(url.trim()) === endpoint);
|
|
318
|
+
}
|
|
319
|
+
|
|
44
320
|
/**
|
|
45
321
|
* Default transient-5xx retry used when a provider opts in with a bare
|
|
46
322
|
* `transientRetryOn5xx: {}`. `attempts` is a TOTAL send budget, not extra retries.
|
|
@@ -276,38 +552,47 @@ export function selectProactiveApiKeyTransport(
|
|
|
276
552
|
}
|
|
277
553
|
|
|
278
554
|
/**
|
|
279
|
-
* Normalize a provider's `retryOn429` policy
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
555
|
+
* Normalize a provider's `retryOn429` policy. An explicit object always wins (including
|
|
556
|
+
* `enabled: false` to opt out). When the knob is absent, the OpenCode Go destination
|
|
557
|
+
* (subscription traffic such as Muse Spark) falls back to a patient same-key policy so a
|
|
558
|
+
* burst 429 waits and replays instead of surfacing to the client and aborting a long
|
|
559
|
+
* session; every other provider without the knob keeps today's fail-fast behavior.
|
|
560
|
+
* OAuth/forward/local credentials are never replayed on the same token. The returned
|
|
561
|
+
* policy is fully defaulted so callers never re-check fields.
|
|
284
562
|
*/
|
|
285
563
|
export function rateLimitRetryPolicyFor(
|
|
286
|
-
provider: Pick<OcxProviderConfig, "retryOn429" | "authMode"
|
|
564
|
+
provider: Pick<OcxProviderConfig, "retryOn429" | "authMode"> &
|
|
565
|
+
Partial<Pick<OcxProviderConfig, "baseUrl" | "adapter">>,
|
|
287
566
|
): Required<RateLimitRetryPolicy> | null {
|
|
288
567
|
const policy = provider.retryOn429;
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
568
|
+
if (policy) {
|
|
569
|
+
if (policy.enabled === false) return null;
|
|
570
|
+
// Fail closed: only explicit key auth or the documented omitted-default (undefined == key for
|
|
571
|
+
// custom API-key providers) may use same-key replays. OAuth/forward are never replayed on the
|
|
572
|
+
// same token, local runtimes have no remote key to preserve, and unknown/custom values are
|
|
573
|
+
// rejected rather than guessed at.
|
|
574
|
+
if (provider.authMode !== undefined && provider.authMode !== "key") return null;
|
|
575
|
+
return {
|
|
576
|
+
enabled: policy.enabled ?? DEFAULT_RATE_LIMIT_RETRY.enabled,
|
|
577
|
+
attempts: policy.attempts ?? DEFAULT_RATE_LIMIT_RETRY.attempts,
|
|
578
|
+
intervalMs: policy.intervalMs ?? DEFAULT_RATE_LIMIT_RETRY.intervalMs,
|
|
579
|
+
maxIntervalMs: policy.maxIntervalMs ?? DEFAULT_RATE_LIMIT_RETRY.maxIntervalMs,
|
|
580
|
+
respectRetryAfter: policy.respectRetryAfter ?? DEFAULT_RATE_LIMIT_RETRY.respectRetryAfter,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
// No explicit knob: patient fallback for the OpenCode Go destination only.
|
|
294
584
|
if (provider.authMode !== undefined && provider.authMode !== "key") return null;
|
|
295
|
-
return
|
|
296
|
-
|
|
297
|
-
attempts: policy.attempts ?? DEFAULT_RATE_LIMIT_RETRY.attempts,
|
|
298
|
-
intervalMs: policy.intervalMs ?? DEFAULT_RATE_LIMIT_RETRY.intervalMs,
|
|
299
|
-
maxIntervalMs: policy.maxIntervalMs ?? DEFAULT_RATE_LIMIT_RETRY.maxIntervalMs,
|
|
300
|
-
respectRetryAfter: policy.respectRetryAfter ?? DEFAULT_RATE_LIMIT_RETRY.respectRetryAfter,
|
|
301
|
-
};
|
|
585
|
+
if (!isOpenCodeGoDestination(provider)) return null;
|
|
586
|
+
return { ...OPENCODE_GO_RATE_LIMIT_RETRY };
|
|
302
587
|
}
|
|
303
588
|
|
|
304
589
|
/**
|
|
305
590
|
* Normalize a provider's `transientRetryOn5xx` policy, or return null when it is absent,
|
|
306
|
-
* explicitly disabled, not key-auth, or not
|
|
591
|
+
* explicitly disabled, not key-auth, or not an adapter this policy governs.
|
|
307
592
|
*
|
|
308
|
-
* The adapter gate is part of the accepted scope, not incidental:
|
|
309
|
-
*
|
|
310
|
-
*
|
|
593
|
+
* The adapter gate is part of the accepted scope, not incidental: it names the adapters whose
|
|
594
|
+
* lanes actually read this policy, so no generic key-auth adapter can opt in by accident.
|
|
595
|
+
* Auth mode follows the same fail-closed rule as `rateLimitRetryPolicyFor` —
|
|
311
596
|
* explicit `key` or the documented omitted default, never OAuth, forward, local, or an
|
|
312
597
|
* unknown value.
|
|
313
598
|
*/
|
|
@@ -316,7 +601,14 @@ export function transientRetryPolicyFor(
|
|
|
316
601
|
): Required<TransientRetryPolicy> | null {
|
|
317
602
|
const policy = provider.transientRetryOn5xx;
|
|
318
603
|
if (!policy || policy.enabled === false) return null;
|
|
319
|
-
|
|
604
|
+
// Both adapters this policy governs. The first version covered chat only, which left a
|
|
605
|
+
// key-auth Responses provider unable to tune its ladder in either direction, because the
|
|
606
|
+
// Responses passthrough lane hard-coded TRANSIENT_RETRY_MAX_ATTEMPTS (#4893). Widening this
|
|
607
|
+
// gate is necessary and not sufficient: the lane also has to call this function, which it
|
|
608
|
+
// now does. Still an explicit list, so no generic key-auth adapter opts in by accident, and
|
|
609
|
+
// the auth check below keeps the ChatGPT forward pool out -- those providers are
|
|
610
|
+
// `authMode: "forward"` and keep the default ladder they have always had.
|
|
611
|
+
if (provider.adapter !== "openai-chat" && provider.adapter !== "openai-responses") return null;
|
|
320
612
|
if (provider.authMode !== undefined && provider.authMode !== "key") return null;
|
|
321
613
|
return {
|
|
322
614
|
enabled: policy.enabled ?? DEFAULT_TRANSIENT_RETRY.enabled,
|
|
@@ -363,6 +655,7 @@ function rotateKeyAfterFailure(
|
|
|
363
655
|
now = Date.now(),
|
|
364
656
|
attemptedKey?: string,
|
|
365
657
|
attemptedSelection?: ProviderApiKeySelection,
|
|
658
|
+
quotaResetAt?: number,
|
|
366
659
|
): OcxProviderConfig | null {
|
|
367
660
|
const provider = config.providers[providerName];
|
|
368
661
|
if (!provider) return null;
|
|
@@ -421,7 +714,12 @@ function rotateKeyAfterFailure(
|
|
|
421
714
|
// full cap instead of the 429 default so a dead key is not re-tried once a minute.
|
|
422
715
|
const cooldownMs = failureStatus === 401
|
|
423
716
|
? MAX_COOLDOWN_MS
|
|
424
|
-
|
|
717
|
+
// A reset instant the upstream dated outranks both the header and the
|
|
718
|
+
// default: it is the only one of the three that knows when the quota
|
|
719
|
+
// actually returns (#4024).
|
|
720
|
+
: quotaResetAt !== undefined
|
|
721
|
+
? Math.max(quotaResetAt - now, 1)
|
|
722
|
+
: parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
|
|
425
723
|
keyCooldowns.set(cooldownKey(providerName, outcome.value.failedId), { cooldownUntil: now + cooldownMs });
|
|
426
724
|
sweepExpiredOnWrite(now);
|
|
427
725
|
}
|
|
@@ -448,8 +746,9 @@ export function rotateKeyOn429(
|
|
|
448
746
|
now = Date.now(),
|
|
449
747
|
attemptedKey?: string,
|
|
450
748
|
attemptedSelection?: ProviderApiKeySelection,
|
|
749
|
+
quotaResetAt?: number,
|
|
451
750
|
): OcxProviderConfig | null {
|
|
452
|
-
return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey, attemptedSelection);
|
|
751
|
+
return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey, attemptedSelection, quotaResetAt);
|
|
453
752
|
}
|
|
454
753
|
|
|
455
754
|
/**
|
|
@@ -482,6 +781,8 @@ export function sweepExpiredApiKeyCooldowns(now = Date.now()): number {
|
|
|
482
781
|
|
|
483
782
|
interface RotateProviderTransportOptions {
|
|
484
783
|
retryAfter?: string | null;
|
|
784
|
+
/** Epoch ms from `parseQuotaResetAt`, when the upstream dated the reset in its body. */
|
|
785
|
+
quotaResetAt?: number;
|
|
485
786
|
now?: number;
|
|
486
787
|
attemptedKey?: string;
|
|
487
788
|
attemptedSelection?: ProviderApiKeySelection;
|
|
@@ -507,6 +808,7 @@ export function rotateProviderTransportOn429(
|
|
|
507
808
|
options.now,
|
|
508
809
|
options.attemptedKey,
|
|
509
810
|
options.attemptedSelection ?? routedProvider._apiKeyAttempt,
|
|
811
|
+
options.quotaResetAt,
|
|
510
812
|
);
|
|
511
813
|
if (!rotated) return null;
|
|
512
814
|
return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
|
|
@@ -14,9 +14,12 @@
|
|
|
14
14
|
// This migration is deliberately NOT general reconciliation. It rewrites exactly
|
|
15
15
|
// one thing: an id this file declares retired, on a provider that still carries
|
|
16
16
|
// the registry's transport, and only when the registry currently seeds the
|
|
17
|
-
// replacement
|
|
17
|
+
// replacement — and it skips a field where the retired entry is registry residue
|
|
18
|
+
// with nothing left to carry forward (see `isRegistryResidue`, issue #5066).
|
|
19
|
+
// Everything else in the row is left alone.
|
|
18
20
|
|
|
19
21
|
import { PROVIDER_REGISTRY } from "./registry";
|
|
22
|
+
import { providerConfigSeed } from "./derive";
|
|
20
23
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
21
24
|
|
|
22
25
|
export interface ModelRename {
|
|
@@ -86,6 +89,7 @@ const MODEL_KEYED_RECORDS = [
|
|
|
86
89
|
"modelMaxOutputTokens",
|
|
87
90
|
"modelInputModalities",
|
|
88
91
|
"modelReasoningEfforts",
|
|
92
|
+
"modelSuppressSyntheticMax",
|
|
89
93
|
"modelDefaultReasoningEfforts",
|
|
90
94
|
"modelReasoningEffortMap",
|
|
91
95
|
] as const;
|
|
@@ -193,6 +197,54 @@ function registrySeedsTarget(rename: ModelRename): boolean {
|
|
|
193
197
|
return !!entry?.models?.includes(rename.to);
|
|
194
198
|
}
|
|
195
199
|
|
|
200
|
+
/** The row the registry contributes to a saved config, or `undefined` when it dropped the id. */
|
|
201
|
+
function registrySeed(provider: string): Record<string, unknown> | undefined {
|
|
202
|
+
const entry = PROVIDER_REGISTRY.find(row => row.id === provider);
|
|
203
|
+
return entry ? (providerConfigSeed(entry) as unknown as Record<string, unknown>) : undefined;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Whether a saved list or model-keyed record names this id at all. */
|
|
207
|
+
function publishes(container: unknown, id: string): boolean {
|
|
208
|
+
if (Array.isArray(container)) return container.includes(id);
|
|
209
|
+
if (!container || typeof container !== "object") return false;
|
|
210
|
+
return id in (container as Record<string, unknown>);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Whether this field’s retired entry is registry residue rather than something to repair.
|
|
215
|
+
*
|
|
216
|
+
* Two things have to be true. The registry’s own seed — `providerConfigSeed`, the row both the
|
|
217
|
+
* OAuth preset (`deriveOAuthProviderConfig`) and `enrichProviderFromRegistry` contribute — must
|
|
218
|
+
* still publish the retired id in this field, so the entry arrives in the config on its own. And
|
|
219
|
+
* the saved field must already carry the supported id, so the rename would only DELETE a key,
|
|
220
|
+
* with nothing to carry forward.
|
|
221
|
+
*
|
|
222
|
+
* Under those two conditions the rewrite is worse than a no-op, because it never converges.
|
|
223
|
+
* `startServer` runs this migration and then `reconcileOAuthProviders`, and
|
|
224
|
+
* `applyOAuthPresetCatalog` copies the preset’s record over the saved one whenever the two
|
|
225
|
+
* differ. The deletion is undone inside the same boot, the config is written twice, and the user
|
|
226
|
+
* is told about a rename that never sticks on every single start. Issue #5066 is exactly that:
|
|
227
|
+
* `ANTIGRAVITY_MODEL_CONTEXT_WINDOWS` derives a window for every compatibility alias, so
|
|
228
|
+
* `modelContextWindows` holds a key for all nine retired Flash ids beside the live ones — the
|
|
229
|
+
* nine `[model-rename-migration]` lines the reporter saw at every `ocx start`, none of which
|
|
230
|
+
* required them to have selected any of those nine models.
|
|
231
|
+
*
|
|
232
|
+
* Those registry entries are deliberate and stay: a request that still names `gemini-3.6-flash`
|
|
233
|
+
* routes to 3.7 and needs a context window under the id it asked for. The second condition is
|
|
234
|
+
* what keeps this narrow. A row that saved `{ "gemini-3.6-flash": 1048576 }` and nothing else
|
|
235
|
+
* still gets the value carried onto the supported id, because dropping it there would blank the
|
|
236
|
+
* record instead of moving it (`tests/adapters/google/gemini-37-flash-migration.test.ts`).
|
|
237
|
+
*/
|
|
238
|
+
function isRegistryResidue(
|
|
239
|
+
seed: Record<string, unknown> | undefined,
|
|
240
|
+
field: string,
|
|
241
|
+
saved: unknown,
|
|
242
|
+
rename: ModelRename,
|
|
243
|
+
): boolean {
|
|
244
|
+
if (!seed) return false;
|
|
245
|
+
return publishes(seed[field], rename.from) && publishes(saved, rename.to);
|
|
246
|
+
}
|
|
247
|
+
|
|
196
248
|
export interface ModelRenameProjection {
|
|
197
249
|
config: OcxConfig;
|
|
198
250
|
changed: boolean;
|
|
@@ -225,14 +277,17 @@ export function projectModelRenames(
|
|
|
225
277
|
// Provider config is a closed interface, so index through one unknown-cast
|
|
226
278
|
// view rather than casting at each assignment.
|
|
227
279
|
const row = prov as unknown as Record<string, unknown>;
|
|
280
|
+
const seed = registrySeed(rename.provider);
|
|
228
281
|
let touched = false;
|
|
229
282
|
for (const field of MODEL_ID_LISTS) {
|
|
283
|
+
if (isRegistryResidue(seed, field, row[field], rename)) continue;
|
|
230
284
|
const next = renameInList(row[field], rename.from, rename.to);
|
|
231
285
|
if (!next) continue;
|
|
232
286
|
row[field] = next;
|
|
233
287
|
touched = true;
|
|
234
288
|
}
|
|
235
289
|
for (const field of MODEL_KEYED_RECORDS) {
|
|
290
|
+
if (isRegistryResidue(seed, field, row[field], rename)) continue;
|
|
236
291
|
const next = rename.dropReasoningEffortMap && field === "modelReasoningEffortMap"
|
|
237
292
|
? dropFromRecord(row[field], rename.from)
|
|
238
293
|
: renameInRecord(row[field], rename.from, rename.to);
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { mutatePersistedConfig } from "../config";
|
|
2
2
|
import { projectModelRenames } from "./model-rename-migration";
|
|
3
3
|
import { projectStaleContextWindows } from "./stale-context-window-migration";
|
|
4
|
+
import { projectStaleVisionClassifications } from "./stale-vision-classification-migration";
|
|
4
5
|
import { projectDevinCliAuthMode } from "./devin-cli-authmode-migration";
|
|
5
6
|
import type { OcxConfig } from "../types";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
|
-
* The startup projection: registry model renames, then the context-window
|
|
9
|
-
*
|
|
9
|
+
* The startup projection: registry model renames, then the context-window and
|
|
10
|
+
* vision-classification repairs. All three fix a saved row the registry can no longer reach on its own —
|
|
10
11
|
* `enrichProviderFromRegistry` backfills a missing field and never rewrites a
|
|
11
12
|
* present one — so they share this pass rather than adding a second boot step
|
|
12
13
|
* with its own persistence, adopt, and failure handling.
|
|
@@ -14,11 +15,12 @@ import type { OcxConfig } from "../types";
|
|
|
14
15
|
export function projectStartupConfigRepairs(config: OcxConfig): ReturnType<typeof projectModelRenames> {
|
|
15
16
|
const renames = projectModelRenames(config);
|
|
16
17
|
const windows = projectStaleContextWindows(renames.config);
|
|
17
|
-
const
|
|
18
|
+
const vision = projectStaleVisionClassifications(windows.config);
|
|
19
|
+
const devinCli = projectDevinCliAuthMode(vision.config);
|
|
18
20
|
return {
|
|
19
21
|
config: devinCli.config,
|
|
20
|
-
changed: renames.changed || windows.changed || devinCli.changed,
|
|
21
|
-
warnings: [...renames.warnings, ...windows.warnings, ...devinCli.warnings],
|
|
22
|
+
changed: renames.changed || windows.changed || vision.changed || devinCli.changed,
|
|
23
|
+
warnings: [...renames.warnings, ...windows.warnings, ...vision.warnings, ...devinCli.warnings],
|
|
22
24
|
};
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -32,6 +32,8 @@ export interface ResolvedOpenAiForwardSidecar extends OpenAiForwardSidecarCandid
|
|
|
32
32
|
authContext: CodexAuthContext;
|
|
33
33
|
headers: Headers;
|
|
34
34
|
recordOutcome?: (outcome: CodexUpstreamOutcome) => void;
|
|
35
|
+
/** Hand back an acquired recovery probe when no sidecar request reached upstream. */
|
|
36
|
+
releaseProbeLease?: () => void;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
/**
|
|
@@ -189,6 +191,7 @@ export async function resolveFirstUsableOpenAiSidecar(
|
|
|
189
191
|
...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
|
|
190
192
|
},
|
|
191
193
|
),
|
|
194
|
+
releaseProbeLease: () => releaseCodexAuthContextProbeLease(authContext),
|
|
192
195
|
};
|
|
193
196
|
}
|
|
194
197
|
if (candidate.accountMode === "direct") {
|
|
@@ -237,6 +240,7 @@ export async function resolveFirstUsableOpenAiSidecar(
|
|
|
237
240
|
...(authContext.kind === "pool" ? { credentialGeneration: authContext.generation } : {}),
|
|
238
241
|
},
|
|
239
242
|
),
|
|
243
|
+
releaseProbeLease: () => releaseCodexAuthContextProbeLease(authContext),
|
|
240
244
|
}
|
|
241
245
|
: {}),
|
|
242
246
|
};
|
|
@@ -3,6 +3,9 @@ import { OPENAI_API_PROVIDER_ID } from "./openai-tiers";
|
|
|
3
3
|
import type { OcxParsedRequest } from "../types";
|
|
4
4
|
import type { RouteResult } from "../router";
|
|
5
5
|
import type { RequestLogContext } from "../server/request-log";
|
|
6
|
+
import type { InboundWire } from "./registry/types";
|
|
7
|
+
import { providerMatchesRegistryTransportWithStaticGuards } from "./static-model-discovery";
|
|
8
|
+
import { resolveModelPolicy } from "./resolved-model-policy";
|
|
6
9
|
|
|
7
10
|
export interface OpenAiVirtualModelResolution {
|
|
8
11
|
selectedModelId: string;
|
|
@@ -49,18 +52,55 @@ export function resolveOpenAiVirtualModel(
|
|
|
49
52
|
return validateOpenAiVirtualModelDefinition(selectedModelId, definition);
|
|
50
53
|
}
|
|
51
54
|
|
|
55
|
+
export function captureOpenAiVirtualWirePolicy(
|
|
56
|
+
route: RouteResult,
|
|
57
|
+
resolution: OpenAiVirtualModelResolution,
|
|
58
|
+
inboundWire: InboundWire = "responses",
|
|
59
|
+
): void {
|
|
60
|
+
// applyOpenAiVirtualModel is also a public mutation helper with older focused callers that
|
|
61
|
+
// construct only providerName/modelId/provider. Production RouteResult builders always capture
|
|
62
|
+
// staticPolicy; a legacy partial shape has no authority to recapture and keeps the old rewrite.
|
|
63
|
+
if (!route.staticPolicy) return;
|
|
64
|
+
const entry = PROVIDER_REGISTRY.find(candidate => candidate.id === route.providerName);
|
|
65
|
+
route.staticPolicy = resolveModelPolicy({
|
|
66
|
+
providerName: route.providerName,
|
|
67
|
+
modelId: resolution.wireModelId,
|
|
68
|
+
provider: route.provider,
|
|
69
|
+
registryEntry: entry,
|
|
70
|
+
transportMatchedRegistry: !!entry
|
|
71
|
+
&& providerMatchesRegistryTransportWithStaticGuards(route.providerName, route.provider),
|
|
72
|
+
inboundWire,
|
|
73
|
+
modelCapabilities: route.provider.modelCapabilities?.[resolution.wireModelId],
|
|
74
|
+
...(route.provider.authMode ? { effectiveAuth: { authMode: route.provider.authMode } } : {}),
|
|
75
|
+
...(route.staticPolicy?.effectiveAlias !== undefined
|
|
76
|
+
? { effectiveAlias: route.staticPolicy.effectiveAlias }
|
|
77
|
+
: {}),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
52
81
|
export function applyOpenAiVirtualModel(
|
|
53
82
|
parsed: OcxParsedRequest,
|
|
54
83
|
route: RouteResult,
|
|
55
84
|
logCtx: RequestLogContext,
|
|
85
|
+
inboundWire: InboundWire = "responses",
|
|
56
86
|
): OpenAiVirtualModelResolution | undefined {
|
|
57
|
-
|
|
58
|
-
|
|
87
|
+
// Routing has already removed the provider namespace and resolved model aliases. Never use
|
|
88
|
+
// logCtx.model for identity: it is reporting state and can still contain a namespaced selector.
|
|
89
|
+
const resolution = resolveOpenAiVirtualModel(route.providerName, route.modelId)
|
|
90
|
+
?? (() => {
|
|
91
|
+
// Preserve the public helper's idempotent second-call contract from its own mutation
|
|
92
|
+
// provenance, without promoting reporting state back into routing authority.
|
|
93
|
+
const selected = parsed._openAiVirtualSelectedModelId;
|
|
94
|
+
if (typeof selected !== "string") return undefined;
|
|
95
|
+
const remembered = resolveOpenAiVirtualModel(route.providerName, selected);
|
|
96
|
+
return remembered?.wireModelId === route.modelId ? remembered : undefined;
|
|
97
|
+
})();
|
|
59
98
|
if (!resolution) return undefined;
|
|
60
99
|
|
|
61
100
|
logCtx.model = resolution.selectedModelId;
|
|
62
101
|
logCtx.resolvedModel = resolution.wireModelId;
|
|
63
102
|
route.modelId = resolution.wireModelId;
|
|
103
|
+
captureOpenAiVirtualWirePolicy(route, resolution, inboundWire);
|
|
64
104
|
parsed.modelId = resolution.wireModelId;
|
|
65
105
|
parsed._openAiVirtualSelectedModelId = resolution.selectedModelId;
|
|
66
106
|
|