@bitkyc08/opencodex 2.57.0 → 2.59.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-C5IebErG.js +136 -0
- package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
- 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 +2 -2
- package/src/adapters/base.ts +11 -1
- package/src/adapters/codebuddy/scaffold-guard.ts +5 -4
- package/src/adapters/command-code.ts +13 -4
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/cursor-errors.ts +15 -0
- package/src/adapters/cursor/discovery.ts +65 -1
- 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 +5 -1
- package/src/adapters/cursor/message-mapper.ts +3 -2
- package/src/adapters/cursor/protobuf-events.ts +110 -11
- package/src/adapters/cursor/protobuf-request.ts +27 -6
- package/src/adapters/cursor/request-builder.ts +14 -3
- package/src/adapters/cursor/text-toolcall.ts +230 -0
- package/src/adapters/cursor/thread-continuity.ts +141 -0
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor/types.ts +5 -0
- package/src/adapters/cursor.ts +97 -6
- package/src/adapters/devin/cloud-direct/chat.ts +11 -2
- package/src/adapters/devin/cloud-direct/index.ts +7 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
- package/src/adapters/devin.ts +75 -13
- package/src/adapters/google-antigravity-wire.ts +29 -2
- package/src/adapters/google-http.ts +45 -13
- package/src/adapters/google.ts +23 -4
- package/src/adapters/mimo-free.ts +32 -17
- package/src/adapters/ollama-native.ts +42 -8
- package/src/adapters/openai-chat/response-events.ts +61 -0
- package/src/adapters/openai-chat.ts +5 -10
- package/src/adapters/openai-responses/passthrough.ts +40 -5
- package/src/adapters/openai-responses/request-strips.ts +43 -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/physical-send.ts +50 -0
- package/src/adapters/responses-tool-schema.ts +76 -46
- package/src/adapters/run-turn-queue.ts +17 -4
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +166 -25
- package/src/claude/context-windows.ts +22 -0
- package/src/claude/outbound.ts +46 -5
- package/src/cli/account-api.ts +4 -3
- package/src/cli/account-extended.ts +22 -2
- 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/config-command.ts +35 -18
- package/src/cli/dispatch.ts +71 -4
- package/src/cli/doctor.ts +197 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +132 -22
- package/src/cli/models-runtime.ts +33 -4
- 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 +72 -1
- package/src/cli/uninstall-client-state.ts +12 -0
- package/src/client/machine-api.ts +4 -3
- package/src/client/machine-listener.ts +14 -1
- package/src/clients/config-export/constants.ts +2 -3
- package/src/clients/config-export.ts +5 -5
- package/src/codex/account-store.ts +81 -5
- package/src/codex/auth-api/pool-quota-probe.ts +14 -3
- package/src/codex/auth-api/routes.ts +17 -2
- package/src/codex/auth-context.ts +58 -20
- 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 +1 -0
- package/src/codex/catalog/model-hints.ts +37 -5
- package/src/codex/catalog/parsing.ts +83 -5
- package/src/codex/catalog/reserve-warn.ts +96 -0
- package/src/codex/catalog/retained-sync.ts +19 -0
- package/src/codex/catalog/routed-gather.ts +42 -3
- package/src/codex/cli-installation-identity.ts +210 -0
- package/src/codex/cli-installation-targets.ts +158 -0
- package/src/codex/convergence.ts +5 -0
- package/src/codex/desktop-switches.ts +145 -0
- package/src/codex/history-job.ts +5 -1
- package/src/codex/history-provider.ts +37 -5
- package/src/codex/history-state-open.ts +105 -0
- package/src/codex/history-worker.ts +14 -1
- package/src/codex/inject/config-toml.ts +44 -2
- package/src/codex/inject/remove.ts +145 -7
- package/src/codex/inject/restore.ts +204 -32
- package/src/codex/inject.ts +6 -9
- package/src/codex/lineage.ts +83 -32
- package/src/codex/loopback-target.ts +40 -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-entitlements.ts +60 -1
- package/src/codex/native-profile-startup.ts +64 -20
- package/src/codex/observed-model-denials.ts +137 -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 +282 -12
- 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 +13 -15
- package/src/codex/subagent-model-fallback.ts +94 -0
- package/src/codex/windows-installation-files.ts +224 -0
- package/src/combos/failover.ts +122 -5
- package/src/config/atomic-write.ts +83 -8
- package/src/config/diagnostics.ts +21 -0
- package/src/config/load-degrade.ts +15 -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 +23 -5
- package/src/config/schema/config-schema.ts +23 -0
- package/src/config/schema/leaf-validators.ts +65 -17
- package/src/generated/compatibility-version.json +337 -201
- package/src/generated/model-metadata.ts +1 -1
- package/src/lib/bounded-body.ts +4 -2
- package/src/lib/bounded-subprocess.ts +62 -10
- package/src/lib/destination-policy.ts +48 -6
- package/src/lib/errors.ts +3 -15
- package/src/lib/local-destinations.ts +32 -5
- package/src/lib/provider-outbound.ts +3 -3
- 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 +631 -0
- package/src/lib/spend-reservation-ledger.ts +115 -9
- package/src/lib/windows-secret-acl.ts +151 -15
- package/src/lib/windows-user-principal.ts +5 -1
- package/src/lib/workflow-budget.ts +145 -8
- package/src/oauth/account-quota-rank.ts +72 -15
- package/src/oauth/generic-account-failover.ts +40 -27
- 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 +6 -0
- package/src/providers/devin-provider-merge-migration.ts +33 -12
- package/src/providers/free-directory.ts +20 -2
- package/src/providers/key-failover.ts +261 -7
- package/src/providers/model-discovery.ts +19 -7
- package/src/providers/model-rename-migration.ts +1 -0
- package/src/providers/openai-sidecar.ts +4 -0
- package/src/providers/opencode-go-transport.ts +14 -5
- package/src/providers/quota/report-cache.ts +3 -0
- package/src/providers/registry/entries-core.ts +11 -0
- package/src/providers/registry/entries-extended.ts +146 -28
- package/src/providers/registry/model-seeds.ts +136 -29
- package/src/providers/registry/types.ts +9 -0
- package/src/responses/apply-patch-envelope.ts +44 -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/hosted-tool-policy.ts +85 -2
- package/src/responses/schema.ts +9 -2
- package/src/responses/spill-store.ts +17 -0
- package/src/responses/state/body-policy.ts +25 -0
- package/src/responses/state/spill-queue.ts +8 -6
- package/src/responses/state.ts +3 -22
- package/src/router.ts +4 -0
- package/src/server/auth-cors.ts +27 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/chat-native-sse.ts +26 -9
- package/src/server/chat-native.ts +10 -4
- package/src/server/claude-messages.ts +24 -2
- package/src/server/gui-static.ts +36 -2
- package/src/server/inbound-body-admission.ts +187 -0
- package/src/server/index/websocket-handler.ts +48 -1
- package/src/server/index.ts +15 -19
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/config-routes.ts +57 -10
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- package/src/server/models-capabilities.ts +24 -3
- package/src/server/proxy-liveness.ts +97 -2
- package/src/server/relay.ts +17 -24
- package/src/server/request-log.ts +25 -1
- package/src/server/responses/adapter-continuation.ts +71 -27
- package/src/server/responses/adapter-delivery.ts +39 -8
- package/src/server/responses/adapter-dispatch.ts +52 -24
- package/src/server/responses/codex-ws-exchange.ts +65 -4
- package/src/server/responses/combo-stream-preflight.ts +68 -5
- package/src/server/responses/compact.ts +60 -11
- package/src/server/responses/core-codex-account.ts +83 -22
- package/src/server/responses/core-combo.ts +26 -0
- package/src/server/responses/core-normalize.ts +12 -5
- package/src/server/responses/core-options.ts +3 -0
- package/src/server/responses/fetch-helpers.ts +72 -3
- package/src/server/responses/native-injection-protocol.ts +42 -0
- package/src/server/responses/native-injection-replay.ts +105 -0
- package/src/server/responses/native-injection.ts +242 -0
- package/src/server/responses/native-response-control.ts +56 -0
- package/src/server/responses/native-response-json.ts +14 -0
- package/src/server/responses/native-response-output.ts +37 -0
- package/src/server/responses/native-steering-log.ts +44 -0
- package/src/server/responses/native-steering-policy.ts +49 -0
- package/src/server/responses/native-steering-replay.ts +126 -0
- package/src/server/responses/native-steering-settings.ts +76 -0
- package/src/server/responses/native-steering.ts +400 -0
- package/src/server/responses/native-tool-results.ts +130 -0
- package/src/server/responses/passthrough-delivery.ts +21 -1
- package/src/server/responses/passthrough-dispatch.ts +146 -49
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/request-prepare.ts +70 -0
- package/src/server/responses/request-send-budget.ts +84 -7
- package/src/server/responses/request-sidecar-auth.ts +16 -8
- package/src/server/responses/request-spend.ts +38 -9
- package/src/server/responses/request-transport.ts +13 -10
- package/src/server/responses/run-turn-execution.ts +20 -5
- package/src/server/responses/sidecar-execution.ts +2 -0
- package/src/server/responses/ws-upstream.ts +23 -2
- package/src/server/responses-custom-tool-repair.ts +2 -2
- package/src/server/sse-frame-buffer.ts +12 -10
- package/src/server/sse-payload-rewrite.ts +36 -9
- package/src/server/stop-teardown.ts +8 -1
- 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/server/ws-bridge.ts +16 -1
- package/src/service/cli.ts +29 -7
- package/src/service/guards.ts +10 -0
- package/src/service/health.ts +43 -0
- package/src/service/state.ts +7 -2
- package/src/types/accounts.ts +4 -0
- package/src/types/config.ts +104 -3
- package/src/types/provider.ts +32 -0
- package/src/types/request.ts +7 -1
- package/src/types/wire.ts +9 -1
- package/src/usage/expected-prices.ts +28 -0
- package/src/usage/log.ts +87 -4
- package/src/web-search/passthrough-bridge.ts +39 -5
- package/gui/dist/assets/index-Cz7CLdif.js +0 -128
|
@@ -36,6 +36,15 @@ export const CODEX_CAPACITY_MAX_QUOTA_AGE_MS = 30 * 60_000;
|
|
|
36
36
|
export type CodexCapacityQuota = {
|
|
37
37
|
fiveHourPercent?: number;
|
|
38
38
|
fiveHourResetAt?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Local observation time for the burst-window percentage.
|
|
41
|
+
*
|
|
42
|
+
* Carried because the terminal-short-window rule needs it whenever the reading has no reset
|
|
43
|
+
* instant, and the dashboard's account-switch warning evaluates that rule against this DTO.
|
|
44
|
+
* Dropping it here made the warning return "no opinion" for a snapshot routing was already
|
|
45
|
+
* refusing on (#5045).
|
|
46
|
+
*/
|
|
47
|
+
shortObservedAt?: number;
|
|
39
48
|
weeklyPercent?: number;
|
|
40
49
|
weeklyResetAt?: number;
|
|
41
50
|
monthlyPercent?: number;
|
package/src/providers/derive.ts
CHANGED
|
@@ -266,6 +266,9 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
|
|
|
266
266
|
...(entry.requiresAdjacentResponsesToolResults !== undefined
|
|
267
267
|
? { requiresAdjacentResponsesToolResults: entry.requiresAdjacentResponsesToolResults }
|
|
268
268
|
: {}),
|
|
269
|
+
...(entry.requiresPairedResponsesToolResults !== undefined
|
|
270
|
+
? { requiresPairedResponsesToolResults: entry.requiresPairedResponsesToolResults }
|
|
271
|
+
: {}),
|
|
269
272
|
...(entry.annotateEmptyToolOutputs !== undefined
|
|
270
273
|
? { annotateEmptyToolOutputs: entry.annotateEmptyToolOutputs }
|
|
271
274
|
: {}),
|
|
@@ -541,6 +544,9 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
|
|
|
541
544
|
if (prov.requiresAdjacentResponsesToolResults === undefined && seed.requiresAdjacentResponsesToolResults !== undefined) {
|
|
542
545
|
prov.requiresAdjacentResponsesToolResults = seed.requiresAdjacentResponsesToolResults;
|
|
543
546
|
}
|
|
547
|
+
if (prov.requiresPairedResponsesToolResults === undefined && seed.requiresPairedResponsesToolResults !== undefined) {
|
|
548
|
+
prov.requiresPairedResponsesToolResults = seed.requiresPairedResponsesToolResults;
|
|
549
|
+
}
|
|
544
550
|
if (prov.annotateEmptyToolOutputs === undefined && seed.annotateEmptyToolOutputs !== undefined) {
|
|
545
551
|
prov.annotateEmptyToolOutputs = seed.annotateEmptyToolOutputs;
|
|
546
552
|
}
|
|
@@ -195,33 +195,54 @@ const DEFAULT_DEPS: DevinProviderMergeStartupDeps = {
|
|
|
195
195
|
* the snapshot is taken strictly before the save, and a backup failure throws
|
|
196
196
|
* rather than writing without a rollback point.
|
|
197
197
|
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
198
|
+
* Both destination slots are inspected before either account-bound file is
|
|
199
|
+
* changed. The auth write itself is deliberately detached. `startServer` is
|
|
200
|
+
* synchronous — an `await` in the boot window would suspend the composition root — and
|
|
200
201
|
* `mutateStore` is async-only, so the rekey is fired after its snapshot and
|
|
201
|
-
* its outcome is logged when it lands.
|
|
202
|
-
*
|
|
203
|
-
* refuses by design, and a failed rekey simply retries on the next boot.
|
|
202
|
+
* its outcome is logged when it lands. A late concurrent conflict refuses by
|
|
203
|
+
* design, and a failed rekey simply retries on the next boot.
|
|
204
204
|
*/
|
|
205
205
|
export function runDevinProviderMergeStartupMigration(
|
|
206
206
|
config: OcxConfig,
|
|
207
207
|
deps: DevinProviderMergeStartupDeps = DEFAULT_DEPS,
|
|
208
208
|
): OcxConfig {
|
|
209
209
|
const projection = deps.project(config);
|
|
210
|
-
|
|
210
|
+
const hasSourceConfig = config.providers?.[FROM_ID] !== undefined;
|
|
211
|
+
const hasSourceAuth = deps.hasAuthSlot(FROM_ID);
|
|
212
|
+
const hasDestinationAuth = deps.hasAuthSlot(TO_ID);
|
|
213
|
+
|
|
214
|
+
// A configured provider and its credentials are one account-bound unit. Do
|
|
215
|
+
// not move either half if the config projection refused, or if the target
|
|
216
|
+
// credential slot could belong to another account.
|
|
217
|
+
if (hasSourceConfig && (!projection.changed || hasDestinationAuth)) {
|
|
218
|
+
// Projection warnings still matter on a no-op: a config collision is the warning.
|
|
219
|
+
for (const warning of projection.warnings) console.warn(`[devin-provider-merge] ${warning}`);
|
|
220
|
+
if (projection.changed && hasDestinationAuth) {
|
|
221
|
+
console.warn(
|
|
222
|
+
`[devin-provider-merge] auth.json already has a "${TO_ID}" credential slot; `
|
|
223
|
+
+ `provider "${FROM_ID}" and both credential slots were left untouched. Remove the `
|
|
224
|
+
+ "unused destination credential manually, then restart.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return config;
|
|
228
|
+
}
|
|
229
|
+
|
|
211
230
|
for (const warning of projection.warnings) console.warn(`[devin-provider-merge] ${warning}`);
|
|
231
|
+
|
|
212
232
|
let result = config;
|
|
213
233
|
if (projection.changed) {
|
|
234
|
+
// Snapshot both account-bound files before changing either one.
|
|
235
|
+
if (hasSourceAuth) deps.backupAuth();
|
|
214
236
|
deps.backupConfig();
|
|
215
237
|
deps.save(projection.config);
|
|
216
238
|
result = projection.config;
|
|
217
239
|
}
|
|
218
240
|
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
223
|
-
if (!deps.
|
|
224
|
-
deps.backupAuth();
|
|
241
|
+
// With no legacy config row, a `devin-cli` credential slot is orphaned and
|
|
242
|
+
// can still be rekeyed under the helper's refuse-on-occupied rule. A refused
|
|
243
|
+
// config migration returned above so its account-bound slot stays put.
|
|
244
|
+
if (!hasSourceAuth) return result;
|
|
245
|
+
if (!projection.changed) deps.backupAuth();
|
|
225
246
|
void deps.rekey(FROM_ID, TO_ID).then(outcome => {
|
|
226
247
|
if (outcome === "conflict") {
|
|
227
248
|
console.warn(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getProviderRegistryEntry } from "./registry";
|
|
2
|
+
|
|
1
3
|
export type ProviderAccessGroup =
|
|
2
4
|
| "recurring-or-keyless"
|
|
3
5
|
| "recurring-uncapped"
|
|
@@ -18,7 +20,7 @@ export const FREE_PROVIDER_ACCESS_GROUPS = {
|
|
|
18
20
|
],
|
|
19
21
|
"recurring-credit": ["bytez", "nous-research"],
|
|
20
22
|
"signup-credit": [
|
|
21
|
-
"agentrouter", "ai21", "baichuan", "baseten", "deepinfra", "deepseek", "doubao", "fireworks", "freemodel-dev", "glm-cn",
|
|
23
|
+
"agentrouter", "ai21", "baichuan", "baseten", "crusoe", "deepinfra", "deepseek", "doubao", "fireworks", "freemodel-dev", "glm-cn",
|
|
22
24
|
"hyperbolic", "longcat", "monsterapi", "nebius", "novita", "nscale", "nvidia", "predibase", "publicai", "qoder", "qoder-cn",
|
|
23
25
|
"scaleway", "sensenova", "stepfun", "together", "vertex",
|
|
24
26
|
],
|
|
@@ -72,6 +74,21 @@ const openAi = (baseUrl: string, dashboardUrl: string, extra: Partial<Connectabl
|
|
|
72
74
|
...extra,
|
|
73
75
|
});
|
|
74
76
|
|
|
77
|
+
function registryLiveKeyProvider(id: string): ConnectableOverride {
|
|
78
|
+
const entry = getProviderRegistryEntry(id);
|
|
79
|
+
if (!entry || entry.authKind !== "key" || !entry.dashboardUrl || entry.liveModels !== true) {
|
|
80
|
+
throw new TypeError(`Free directory registry projection requires a live key provider with a dashboard: ${id}`);
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
baseUrl: entry.baseUrl,
|
|
84
|
+
dashboardUrl: entry.dashboardUrl,
|
|
85
|
+
adapter: entry.adapter,
|
|
86
|
+
authKind: "key",
|
|
87
|
+
discovery: "live",
|
|
88
|
+
liveModels: true,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
// API roots are limited to documented or primary-source integrations. Consumer-web/session
|
|
76
93
|
// providers remain reference-only: this directory never asks users to paste cookies or bypass WAFs.
|
|
77
94
|
const CONNECTABLE: Record<string, ConnectableOverride> = {
|
|
@@ -127,6 +144,7 @@ const CONNECTABLE: Record<string, ConnectableOverride> = {
|
|
|
127
144
|
// Verified end-to-end 2026-07-30: /v1/models returns the OpenAI-shaped live catalog (13 models),
|
|
128
145
|
// and a chat completion against moonshotai/Kimi-K3 returned a standard chat.completion payload.
|
|
129
146
|
baseten: openAi("https://inference.baseten.co/v1", "https://app.baseten.co/settings/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://docs.baseten.co/inference/model-apis/overview", modelsUrl: "https://inference.baseten.co/v1/models", lastVerified: "2026-07-30" }),
|
|
147
|
+
crusoe: { ...registryLiveKeyProvider("crusoe"), supportLevel: "supported", verification: "official", documentationUrl: "https://docs.crusoecloud.com/quickstart/getting-started-with-serverless-inference", modelsUrl: "https://api.inference.crusoecloud.com/v1/models", lastVerified: "2026-09-11" },
|
|
130
148
|
deepinfra: openAi("https://api.deepinfra.com/v1/openai", "https://deepinfra.com/dash/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://deepinfra.com/docs/openai_api" }),
|
|
131
149
|
deepseek: openAi("https://api.deepseek.com", "https://platform.deepseek.com/api_keys", { supportLevel: "supported", verification: "official", documentationUrl: "https://api-docs.deepseek.com/api/list-models" }),
|
|
132
150
|
doubao: openAi("https://ark.cn-beijing.volces.com/api/v3", "https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey", { verification: "official" }),
|
|
@@ -181,7 +199,7 @@ const LABELS: Record<string, string> = {
|
|
|
181
199
|
"t3-web": "T3 Web", uncloseai: "UncloseAI", ainative: "AI Native", baidu: "Baidu Qianfan",
|
|
182
200
|
glm: "Z.AI GLM", "glm-cn": "BigModel GLM (CN)", "kilo-gateway": "Kilo Gateway", "opencode-zen": "OpenCode Zen",
|
|
183
201
|
sealion: "SEA-LION", bytez: "Bytez", "nous-research": "Nous Research", agentrouter: "AgentRouter",
|
|
184
|
-
ai21: "AI21", baichuan: "Baichuan", deepinfra: "DeepInfra", deepseek: "DeepSeek", doubao: "Doubao",
|
|
202
|
+
ai21: "AI21", baichuan: "Baichuan", crusoe: "Crusoe", deepinfra: "DeepInfra", deepseek: "DeepSeek", doubao: "Doubao",
|
|
185
203
|
"freemodel-dev": "FreeModel.dev", sambanova: "SambaNova Cloud", nebius: "Nebius Token Factory",
|
|
186
204
|
novita: "Novita", nscale: "Nscale", nvidia: "NVIDIA NIM",
|
|
187
205
|
publicai: "PublicAI", qoder: "Qoder", "qoder-cn": "Qoder CN", sensenova: "SenseNova", stepfun: "StepFun", vertex: "Google Vertex AI",
|
|
@@ -29,6 +29,243 @@ interface KeyCooldown {
|
|
|
29
29
|
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
30
30
|
const MAX_COOLDOWN_MS = 10 * 60_000; // cap at 10 min for api-key rotation
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Cap for a cooldown the upstream itself dated, as opposed to one we inferred.
|
|
34
|
+
*
|
|
35
|
+
* `MAX_COOLDOWN_MS` is deliberately short because an undated 429 is a guess: ten
|
|
36
|
+
* minutes bounds how long a transient limit can park a working key. A free-tier
|
|
37
|
+
* quota is not a guess — OpenRouter replies `Weekly/Monthly Limit Exhausted ...
|
|
38
|
+
* will reset at <date>`, and until that date the key cannot serve anything. Held
|
|
39
|
+
* for ten minutes instead, it comes back, takes a 429, and rotates again, every
|
|
40
|
+
* ten minutes for the rest of the week (#4024).
|
|
41
|
+
*
|
|
42
|
+
* 32 days rather than unbounded. The wording this parses is
|
|
43
|
+
* `Weekly/Monthly Limit Exhausted`, so the cap has to clear a monthly window —
|
|
44
|
+
* 31 days plus a day of slack for timezone and month length. An earlier 8-day
|
|
45
|
+
* cap looked generous against the weekly case in the issue and silently clamped
|
|
46
|
+
* every monthly reset to ~23 days early, which puts the key back into exactly
|
|
47
|
+
* the 429 loop this exists to stop. Caught by the cap's own test.
|
|
48
|
+
*
|
|
49
|
+
* Bounded at all because the date is upstream-controlled input: a malformed or
|
|
50
|
+
* hostile `reset at 2999-01-01` must not park a working key past any horizon an
|
|
51
|
+
* operator would think to look at.
|
|
52
|
+
*/
|
|
53
|
+
const MAX_QUOTA_COOLDOWN_MS = 32 * 24 * 60 * 60_000;
|
|
54
|
+
const QUOTA_RESET_PEEK_TIMEOUT_MS = 250;
|
|
55
|
+
|
|
56
|
+
interface QuotaResetReadOptions {
|
|
57
|
+
now?: number;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
timeoutMs?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rebuiltResponse(response: Response, body: ReadableStream<Uint8Array>): Response {
|
|
63
|
+
return new Response(body, {
|
|
64
|
+
status: response.status,
|
|
65
|
+
statusText: response.statusText,
|
|
66
|
+
headers: response.headers,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function replayOnlyResponse(response: Response, chunks: readonly Uint8Array[]): Response {
|
|
71
|
+
return rebuiltResponse(response, new ReadableStream<Uint8Array>({
|
|
72
|
+
start(controller) {
|
|
73
|
+
for (const chunk of chunks) controller.enqueue(chunk);
|
|
74
|
+
controller.close();
|
|
75
|
+
},
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read a bounded prefix of a 429 body and pull the upstream's declared reset instant.
|
|
81
|
+
*
|
|
82
|
+
* The returned response replays the bounded prefix and any boundary-chunk overflow
|
|
83
|
+
* before streaming the unread remainder. A rotation storm must not be gated on
|
|
84
|
+
* reading N full error payloads. Any failure — no body, already consumed, slow,
|
|
85
|
+
* malformed — returns undefined and leaves the `Retry-After` path in charge.
|
|
86
|
+
*/
|
|
87
|
+
export async function readQuotaResetAt(
|
|
88
|
+
response: Response,
|
|
89
|
+
nowOrOptions: number | QuotaResetReadOptions = {},
|
|
90
|
+
): Promise<{ at: number | undefined; response: Response }> {
|
|
91
|
+
if (!response.body) return { at: undefined, response };
|
|
92
|
+
const options = typeof nowOrOptions === "number" ? { now: nowOrOptions } : nowOrOptions;
|
|
93
|
+
const now = options.now ?? Date.now();
|
|
94
|
+
let reader: ReadableStreamDefaultReader<Uint8Array>;
|
|
95
|
+
try {
|
|
96
|
+
reader = response.body.getReader();
|
|
97
|
+
} catch {
|
|
98
|
+
return { at: undefined, response };
|
|
99
|
+
}
|
|
100
|
+
const chunks: Uint8Array[] = [];
|
|
101
|
+
let transferred = false;
|
|
102
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
103
|
+
const deadline = new AbortController();
|
|
104
|
+
const timeoutReason = new DOMException("Quota reset body peek timed out", "TimeoutError");
|
|
105
|
+
try {
|
|
106
|
+
const decoder = new TextDecoder();
|
|
107
|
+
let seen = 0;
|
|
108
|
+
let text = "";
|
|
109
|
+
timer = setTimeout(() => deadline.abort(timeoutReason), options.timeoutMs ?? QUOTA_RESET_PEEK_TIMEOUT_MS);
|
|
110
|
+
const signal = options.signal
|
|
111
|
+
? AbortSignal.any([options.signal, deadline.signal])
|
|
112
|
+
: deadline.signal;
|
|
113
|
+
while (seen < QUOTA_RESET_SCAN_BYTES) {
|
|
114
|
+
const read = reader.read();
|
|
115
|
+
let rejectAbort: ((reason: unknown) => void) | undefined;
|
|
116
|
+
const onAbort = () => rejectAbort?.(signal.reason);
|
|
117
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
118
|
+
rejectAbort = reject;
|
|
119
|
+
if (signal.aborted) reject(signal.reason);
|
|
120
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
121
|
+
});
|
|
122
|
+
// Derived from the reader rather than named directly: Bun's lib types
|
|
123
|
+
// `ReadableStreamDefaultReader.read()` as returning
|
|
124
|
+
// `ReadableStreamDefaultReadResult`, which is not assignable to the
|
|
125
|
+
// `ReadableStreamReadResult` alias.
|
|
126
|
+
let result: Awaited<ReturnType<typeof reader.read>>;
|
|
127
|
+
try {
|
|
128
|
+
result = await Promise.race([read, aborted]);
|
|
129
|
+
} finally {
|
|
130
|
+
signal.removeEventListener("abort", onAbort);
|
|
131
|
+
}
|
|
132
|
+
const { done, value } = result;
|
|
133
|
+
if (done) break;
|
|
134
|
+
const remaining = QUOTA_RESET_SCAN_BYTES - seen;
|
|
135
|
+
const prefix = value.byteLength > remaining ? value.subarray(0, remaining) : value;
|
|
136
|
+
const overflow = value.byteLength > remaining ? value.subarray(remaining) : undefined;
|
|
137
|
+
chunks.push(prefix);
|
|
138
|
+
if (overflow?.byteLength) chunks.push(overflow);
|
|
139
|
+
seen += prefix.byteLength;
|
|
140
|
+
text += decoder.decode(prefix, { stream: true });
|
|
141
|
+
}
|
|
142
|
+
// Hand back a Response carrying the bytes already pulled followed by whatever
|
|
143
|
+
// is left, so the caller can still read or cancel it. `response.clone()` is
|
|
144
|
+
// NOT usable here: it tees, and with the original branch undrained the tee
|
|
145
|
+
// stalls once its buffer fills — a 5MB error body hangs the rotation path,
|
|
146
|
+
// which is worse than the unbounded read this replaced.
|
|
147
|
+
const rest = new ReadableStream<Uint8Array>({
|
|
148
|
+
start(controller) {
|
|
149
|
+
for (const c of chunks) controller.enqueue(c);
|
|
150
|
+
},
|
|
151
|
+
async pull(controller) {
|
|
152
|
+
try {
|
|
153
|
+
const { done, value } = await reader.read();
|
|
154
|
+
if (done) {
|
|
155
|
+
controller.close();
|
|
156
|
+
reader.releaseLock();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
controller.enqueue(value);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
controller.error(error);
|
|
162
|
+
reader.releaseLock();
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
async cancel(reason) {
|
|
166
|
+
try {
|
|
167
|
+
await reader.cancel(reason);
|
|
168
|
+
} finally {
|
|
169
|
+
reader.releaseLock();
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
transferred = true;
|
|
174
|
+
return { at: parseQuotaResetAt(text, now), response: rebuiltResponse(response, rest) };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const clientAborted = options.signal?.aborted === true;
|
|
177
|
+
void reader.cancel(error).catch(() => {}).finally(() => {
|
|
178
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
179
|
+
});
|
|
180
|
+
if (clientAborted) throw options.signal!.reason ?? error;
|
|
181
|
+
return { at: undefined, response: replayOnlyResponse(response, chunks) };
|
|
182
|
+
} finally {
|
|
183
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
184
|
+
if (!transferred) {
|
|
185
|
+
try { reader.releaseLock(); } catch { /* already released */ }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* How much of a 429 body is read and scanned for the reset instant.
|
|
192
|
+
*
|
|
193
|
+
* Bounds the READ, not just the parse: this runs on the rotation path, once per
|
|
194
|
+
* rotated key under a rate-limit storm, and the body is upstream-controlled.
|
|
195
|
+
* OpenRouter's rate_limit_error JSON is a few hundred bytes.
|
|
196
|
+
*/
|
|
197
|
+
const QUOTA_RESET_SCAN_BYTES = 4_096;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Reset instant an upstream declared in a 429 *body*, in epoch ms.
|
|
201
|
+
*
|
|
202
|
+
* Only the body carries this: OpenRouter sends no `Retry-After` for a quota
|
|
203
|
+
* exhaustion, so the header path (`parseRetryAfterMs`) sees nothing and falls
|
|
204
|
+
* back to `DEFAULT_COOLDOWN_MS`. Returns undefined for anything it cannot read
|
|
205
|
+
* as a date, so an unparsable body keeps today's behaviour exactly.
|
|
206
|
+
*/
|
|
207
|
+
/**
|
|
208
|
+
* Whether `YYYY-MM-DD…` names a day that exists.
|
|
209
|
+
*
|
|
210
|
+
* `Date.parse` does NOT reject an out-of-range day: measured on Bun,
|
|
211
|
+
* `2026-02-30T00:00:00Z` yields March 2 and `2026-04-31T00:00:00Z` yields
|
|
212
|
+
* May 1, so a malformed upstream body would park a key past the instant it
|
|
213
|
+
* actually named. Only the month is rejected outright (`2026-13-01` is NaN).
|
|
214
|
+
*
|
|
215
|
+
* Checked on the date text alone rather than by round-tripping the parsed
|
|
216
|
+
* instant, because a value carrying an explicit offset (`…T23:00+05:30`)
|
|
217
|
+
* legitimately lands on a different UTC day than the one written.
|
|
218
|
+
*/
|
|
219
|
+
function isRealCalendarDate(value: string): boolean {
|
|
220
|
+
const [year, month, day] = value.slice(0, 10).split("-").map(Number);
|
|
221
|
+
if (month < 1 || month > 12 || day < 1) return false;
|
|
222
|
+
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
|
223
|
+
const lengths = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
224
|
+
return day <= lengths[month - 1]!;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function parseQuotaResetAt(body: string | null | undefined, now = Date.now()): number | undefined {
|
|
228
|
+
const text = body?.slice(0, QUOTA_RESET_SCAN_BYTES);
|
|
229
|
+
if (!text) return undefined;
|
|
230
|
+
let parsed: unknown;
|
|
231
|
+
try {
|
|
232
|
+
parsed = JSON.parse(text);
|
|
233
|
+
} catch {
|
|
234
|
+
return undefined;
|
|
235
|
+
}
|
|
236
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
237
|
+
const error = (parsed as { error?: unknown }).error;
|
|
238
|
+
if (!error || typeof error !== "object") return undefined;
|
|
239
|
+
const code = (error as { code?: unknown }).code;
|
|
240
|
+
const message = (error as { message?: unknown }).message;
|
|
241
|
+
if ((code !== "rate_limit_error" && code !== 429) || typeof message !== "string") return undefined;
|
|
242
|
+
if (!/^(?:Weekly|Monthly) Limit Exhausted\b/i.test(message.trim())) return undefined;
|
|
243
|
+
// `will reset at 2026-09-09 03:30:06` / `... at 2026-09-09T03:30:06Z` / `resets at <date>`
|
|
244
|
+
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);
|
|
245
|
+
if (!match) return undefined;
|
|
246
|
+
// Pin a bare `YYYY-MM-DD hh:mm:ss` to UTC explicitly.
|
|
247
|
+
//
|
|
248
|
+
// ECMA-262 says a date-TIME form with no offset is LOCAL time, and Node follows
|
|
249
|
+
// that: `Date.parse("2026-09-09 03:30:06")` differs from the UTC reading by the
|
|
250
|
+
// host offset (7h on a PDT box, measured). Bun currently returns the UTC value
|
|
251
|
+
// for the same string, so on this runtime the normalisation is a no-op today —
|
|
252
|
+
// which is exactly why it is written out rather than relied upon. If Bun ever
|
|
253
|
+
// conforms, an un-normalised parse would silently shift every park-until by the
|
|
254
|
+
// operator's offset, and the early direction resumes the 429 loop.
|
|
255
|
+
//
|
|
256
|
+
// A consequence worth knowing: no Bun test can observe this branch being
|
|
257
|
+
// removed. The explicit-zone case below is the part the suite can pin.
|
|
258
|
+
const raw = match[1].includes("T") || /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/.test(match[1])
|
|
259
|
+
? match[1]
|
|
260
|
+
: `${match[1].replace(" ", "T")}Z`;
|
|
261
|
+
if (!isRealCalendarDate(match[1])) return undefined;
|
|
262
|
+
const at = Date.parse(raw);
|
|
263
|
+
if (!Number.isFinite(at)) return undefined;
|
|
264
|
+
// Already past, or beyond the cap: not usable as a park-until instant.
|
|
265
|
+
if (at <= now) return undefined;
|
|
266
|
+
return Math.min(at, now + MAX_QUOTA_COOLDOWN_MS);
|
|
267
|
+
}
|
|
268
|
+
|
|
32
269
|
/**
|
|
33
270
|
* Default same-target 429 retry policy used when a provider opts in via a bare
|
|
34
271
|
* `retryOn429: {}` (presence = opt-in with these defaults).
|
|
@@ -303,11 +540,11 @@ export function rateLimitRetryPolicyFor(
|
|
|
303
540
|
|
|
304
541
|
/**
|
|
305
542
|
* Normalize a provider's `transientRetryOn5xx` policy, or return null when it is absent,
|
|
306
|
-
* explicitly disabled, not key-auth, or not
|
|
543
|
+
* explicitly disabled, not key-auth, or not an adapter this policy governs.
|
|
307
544
|
*
|
|
308
|
-
* The adapter gate is part of the accepted scope, not incidental:
|
|
309
|
-
*
|
|
310
|
-
*
|
|
545
|
+
* The adapter gate is part of the accepted scope, not incidental: it names the adapters whose
|
|
546
|
+
* lanes actually read this policy, so no generic key-auth adapter can opt in by accident.
|
|
547
|
+
* Auth mode follows the same fail-closed rule as `rateLimitRetryPolicyFor` —
|
|
311
548
|
* explicit `key` or the documented omitted default, never OAuth, forward, local, or an
|
|
312
549
|
* unknown value.
|
|
313
550
|
*/
|
|
@@ -316,7 +553,14 @@ export function transientRetryPolicyFor(
|
|
|
316
553
|
): Required<TransientRetryPolicy> | null {
|
|
317
554
|
const policy = provider.transientRetryOn5xx;
|
|
318
555
|
if (!policy || policy.enabled === false) return null;
|
|
319
|
-
|
|
556
|
+
// Both adapters this policy governs. The first version covered chat only, which left a
|
|
557
|
+
// key-auth Responses provider unable to tune its ladder in either direction, because the
|
|
558
|
+
// Responses passthrough lane hard-coded TRANSIENT_RETRY_MAX_ATTEMPTS (#4893). Widening this
|
|
559
|
+
// gate is necessary and not sufficient: the lane also has to call this function, which it
|
|
560
|
+
// now does. Still an explicit list, so no generic key-auth adapter opts in by accident, and
|
|
561
|
+
// the auth check below keeps the ChatGPT forward pool out -- those providers are
|
|
562
|
+
// `authMode: "forward"` and keep the default ladder they have always had.
|
|
563
|
+
if (provider.adapter !== "openai-chat" && provider.adapter !== "openai-responses") return null;
|
|
320
564
|
if (provider.authMode !== undefined && provider.authMode !== "key") return null;
|
|
321
565
|
return {
|
|
322
566
|
enabled: policy.enabled ?? DEFAULT_TRANSIENT_RETRY.enabled,
|
|
@@ -363,6 +607,7 @@ function rotateKeyAfterFailure(
|
|
|
363
607
|
now = Date.now(),
|
|
364
608
|
attemptedKey?: string,
|
|
365
609
|
attemptedSelection?: ProviderApiKeySelection,
|
|
610
|
+
quotaResetAt?: number,
|
|
366
611
|
): OcxProviderConfig | null {
|
|
367
612
|
const provider = config.providers[providerName];
|
|
368
613
|
if (!provider) return null;
|
|
@@ -421,7 +666,12 @@ function rotateKeyAfterFailure(
|
|
|
421
666
|
// full cap instead of the 429 default so a dead key is not re-tried once a minute.
|
|
422
667
|
const cooldownMs = failureStatus === 401
|
|
423
668
|
? MAX_COOLDOWN_MS
|
|
424
|
-
|
|
669
|
+
// A reset instant the upstream dated outranks both the header and the
|
|
670
|
+
// default: it is the only one of the three that knows when the quota
|
|
671
|
+
// actually returns (#4024).
|
|
672
|
+
: quotaResetAt !== undefined
|
|
673
|
+
? Math.max(quotaResetAt - now, 1)
|
|
674
|
+
: parseRetryAfterMs(retryAfterHeader, now) ?? DEFAULT_COOLDOWN_MS;
|
|
425
675
|
keyCooldowns.set(cooldownKey(providerName, outcome.value.failedId), { cooldownUntil: now + cooldownMs });
|
|
426
676
|
sweepExpiredOnWrite(now);
|
|
427
677
|
}
|
|
@@ -448,8 +698,9 @@ export function rotateKeyOn429(
|
|
|
448
698
|
now = Date.now(),
|
|
449
699
|
attemptedKey?: string,
|
|
450
700
|
attemptedSelection?: ProviderApiKeySelection,
|
|
701
|
+
quotaResetAt?: number,
|
|
451
702
|
): OcxProviderConfig | null {
|
|
452
|
-
return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey, attemptedSelection);
|
|
703
|
+
return rotateKeyAfterFailure(config, providerName, 429, retryAfterHeader, now, attemptedKey, attemptedSelection, quotaResetAt);
|
|
453
704
|
}
|
|
454
705
|
|
|
455
706
|
/**
|
|
@@ -482,6 +733,8 @@ export function sweepExpiredApiKeyCooldowns(now = Date.now()): number {
|
|
|
482
733
|
|
|
483
734
|
interface RotateProviderTransportOptions {
|
|
484
735
|
retryAfter?: string | null;
|
|
736
|
+
/** Epoch ms from `parseQuotaResetAt`, when the upstream dated the reset in its body. */
|
|
737
|
+
quotaResetAt?: number;
|
|
485
738
|
now?: number;
|
|
486
739
|
attemptedKey?: string;
|
|
487
740
|
attemptedSelection?: ProviderApiKeySelection;
|
|
@@ -507,6 +760,7 @@ export function rotateProviderTransportOn429(
|
|
|
507
760
|
options.now,
|
|
508
761
|
options.attemptedKey,
|
|
509
762
|
options.attemptedSelection ?? routedProvider._apiKeyAttempt,
|
|
763
|
+
options.quotaResetAt,
|
|
510
764
|
);
|
|
511
765
|
if (!rotated) return null;
|
|
512
766
|
return applyRotatedTransport(providerName, routedProvider, rotated, options.promptCacheKey);
|
|
@@ -129,6 +129,14 @@ export function providerModelDiscoverySpecError(spec: ProviderModelDiscoverySpec
|
|
|
129
129
|
if (queryEntries.some(([key, value]) => !key.trim() || key.length > 128 || typeof value !== "string" || value.length > 512)) {
|
|
130
130
|
return "discovery query keys/values exceed their bounds";
|
|
131
131
|
}
|
|
132
|
+
for (const [field, value] of [
|
|
133
|
+
["envelopeKey", spec.envelopeKey],
|
|
134
|
+
["idField", spec.idField],
|
|
135
|
+
] as const) {
|
|
136
|
+
if (value !== undefined && (
|
|
137
|
+
typeof value !== "string" || !value || value !== value.trim() || value.length > 128
|
|
138
|
+
)) return `${field} must be a nonblank field name up to 128 characters`;
|
|
139
|
+
}
|
|
132
140
|
for (const [field, value, hardLimit] of [
|
|
133
141
|
["maxResponseBytes", spec.maxResponseBytes, MODEL_DISCOVERY_MAX_RESPONSE_BYTES],
|
|
134
142
|
["maxModels", spec.maxModels, MODEL_DISCOVERY_MAX_MODELS],
|
|
@@ -422,7 +430,7 @@ export function extractModelEnvelopeRows(
|
|
|
422
430
|
return { ok: true, rows };
|
|
423
431
|
}
|
|
424
432
|
|
|
425
|
-
/** Validate, bound, deduplicate, and
|
|
433
|
+
/** Validate, bound, deduplicate, and filter the declared envelope or a top-level array (Together `#617`). */
|
|
426
434
|
/**
|
|
427
435
|
* Metadata a sibling `models[]` array may contribute to an ALREADY-ADMITTED
|
|
428
436
|
* `data[]` row (#1797).
|
|
@@ -501,24 +509,26 @@ export function extractProviderModelItems(
|
|
|
501
509
|
let data: unknown[];
|
|
502
510
|
let siblings: SiblingIndex | null = null;
|
|
503
511
|
if (Array.isArray(value)) {
|
|
504
|
-
// Together-style top-level /models arrays.
|
|
505
|
-
// `models` key on openai-chat responses as valid
|
|
512
|
+
// Together-style top-level /models arrays. The default contract must not treat a stray
|
|
513
|
+
// `models` key on openai-chat responses as valid; only a provider spec may opt into it.
|
|
506
514
|
if (value.length > limit) return { ok: false, reason: "too_many_models" };
|
|
507
515
|
data = value;
|
|
508
516
|
} else {
|
|
509
|
-
const
|
|
517
|
+
const envelopeKey = discovery.spec?.envelopeKey ?? "data";
|
|
518
|
+
const envelope = extractModelEnvelopeRows(value, discovery.maxModels, [envelopeKey]);
|
|
510
519
|
if (!envelope.ok) return envelope;
|
|
511
520
|
data = envelope.rows;
|
|
512
|
-
siblings = buildSiblingIndex(value, limit);
|
|
521
|
+
siblings = envelopeKey === "data" ? buildSiblingIndex(value, limit) : null;
|
|
513
522
|
}
|
|
514
523
|
|
|
515
524
|
const items: ProviderModelsApiItem[] = [];
|
|
516
525
|
const seen = new Set<string>();
|
|
526
|
+
const idField = discovery.spec?.idField ?? "id";
|
|
517
527
|
for (const raw of data) {
|
|
518
528
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
519
529
|
return { ok: false, reason: "invalid_shape" };
|
|
520
530
|
}
|
|
521
|
-
const id = (raw as
|
|
531
|
+
const id = (raw as Record<string, unknown>)[idField];
|
|
522
532
|
if (!isValidModelDiscoveryModelId(id)) return { ok: false, reason: "invalid_shape" };
|
|
523
533
|
const prefix = discovery.spec?.stripIdPrefix;
|
|
524
534
|
let finalId = id;
|
|
@@ -526,7 +536,9 @@ export function extractProviderModelItems(
|
|
|
526
536
|
finalId = finalId.slice(prefix.length);
|
|
527
537
|
if (!isValidModelDiscoveryModelId(finalId)) continue;
|
|
528
538
|
}
|
|
529
|
-
const item = finalId === id
|
|
539
|
+
const item = finalId === id && idField === "id"
|
|
540
|
+
? raw as ProviderModelsApiItem
|
|
541
|
+
: { ...(raw as Record<string, unknown>), id: finalId };
|
|
530
542
|
// Admission is decided on the ORIGINAL `data[]` row, before any sibling
|
|
531
543
|
// enrichment. Merging first let a `models[]` entry supply the very field a
|
|
532
544
|
// provider filter requires — reproduced against the real Chutes policy,
|
|
@@ -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
|
};
|
|
@@ -12,10 +12,12 @@ function hasHeaderCaseInsensitive(
|
|
|
12
12
|
return Object.keys(headers ?? {}).some(key => key.toLowerCase() === target);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
/** Derive a provider-scoped opaque value without exposing Codex task or subagent ids. */
|
|
16
|
-
export function deriveOpenCodeGoSessionId(sessionLane: string): string {
|
|
15
|
+
/** Derive a provider- and wire-scoped opaque value without exposing Codex task or subagent ids. */
|
|
16
|
+
export function deriveOpenCodeGoSessionId(sessionLane: string, wireProtocol: string): string {
|
|
17
17
|
const digest = createHash("sha256")
|
|
18
|
-
.update("opencodex/opencode-go/session/
|
|
18
|
+
.update("opencodex/opencode-go/session/v2\0")
|
|
19
|
+
.update(wireProtocol)
|
|
20
|
+
.update("\0")
|
|
19
21
|
.update(sessionLane)
|
|
20
22
|
.digest("hex")
|
|
21
23
|
.slice(0, 32);
|
|
@@ -30,12 +32,19 @@ export function deriveOpenCodeGoSessionId(sessionLane: string): string {
|
|
|
30
32
|
* request reaching this helper from the proxy always carries a lane. The `!sessionLane` guard stays
|
|
31
33
|
* for direct callers that have no request context; it is not a per-request identity of its own, and
|
|
32
34
|
* minting one here would hand each retry a different value.
|
|
35
|
+
*
|
|
36
|
+
* `provider` is already settled onto the final wire, while `destinationProvider` is the original
|
|
37
|
+
* routed row. Keeping both explicit prevents an Anthropic hard pin from defeating the registry's
|
|
38
|
+
* adapter-sensitive destination recognition.
|
|
33
39
|
*/
|
|
34
40
|
export function resolveOpenCodeGoTransport<T extends OcxProviderConfig>(
|
|
35
41
|
provider: T,
|
|
36
42
|
sessionLane: string | undefined,
|
|
43
|
+
destinationProvider:
|
|
44
|
+
Pick<OcxProviderConfig, "baseUrl" | "adapter">
|
|
45
|
+
& Partial<Pick<OcxProviderConfig, "authMode">>,
|
|
37
46
|
): T {
|
|
38
|
-
if (registryEntryForProviderDestination(
|
|
47
|
+
if (registryEntryForProviderDestination(destinationProvider)?.id !== "opencode-go") return provider;
|
|
39
48
|
if (!sessionLane) return provider;
|
|
40
49
|
if (hasHeaderCaseInsensitive(provider.headers, OPENCODE_GO_SESSION_HEADER)) return provider;
|
|
41
50
|
|
|
@@ -43,7 +52,7 @@ export function resolveOpenCodeGoTransport<T extends OcxProviderConfig>(
|
|
|
43
52
|
...provider,
|
|
44
53
|
headers: {
|
|
45
54
|
...(provider.headers ?? {}),
|
|
46
|
-
[OPENCODE_GO_SESSION_HEADER]: deriveOpenCodeGoSessionId(sessionLane),
|
|
55
|
+
[OPENCODE_GO_SESSION_HEADER]: deriveOpenCodeGoSessionId(sessionLane, provider.adapter),
|
|
47
56
|
},
|
|
48
57
|
};
|
|
49
58
|
}
|
|
@@ -151,6 +151,9 @@ export function providerQuotaFromCodexQuota(
|
|
|
151
151
|
const projected: CodexCapacityQuota = {
|
|
152
152
|
...(quota.shortPercent !== undefined ? { fiveHourPercent: quota.shortPercent } : {}),
|
|
153
153
|
...(quota.shortResetAt !== undefined ? { fiveHourResetAt: quota.shortResetAt } : {}),
|
|
154
|
+
// Freshness for the reset-less terminal rule. Without it the dashboard evaluates that rule
|
|
155
|
+
// with no evidence and returns null while routing refuses the same account (#5045).
|
|
156
|
+
...(quota.shortObservedAt !== undefined ? { shortObservedAt: quota.shortObservedAt } : {}),
|
|
154
157
|
...(quota.weeklyPercent !== undefined ? { weeklyPercent: quota.weeklyPercent } : {}),
|
|
155
158
|
...(quota.weeklyResetAt !== undefined ? { weeklyResetAt: quota.weeklyResetAt } : {}),
|
|
156
159
|
...(quota.monthlyPercent !== undefined ? { monthlyPercent: quota.monthlyPercent } : {}),
|