@bitkyc08/opencodex 2.58.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 +1 -1
- package/src/adapters/base.ts +11 -1
- package/src/adapters/cursor/catalog.ts +11 -0
- package/src/adapters/cursor/effort-map.ts +16 -2
- package/src/adapters/cursor/envelope-echo.ts +55 -2
- 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 +14 -3
- package/src/adapters/cursor/thread-continuity.ts +105 -31
- package/src/adapters/cursor/tool-guidance.ts +5 -4
- package/src/adapters/cursor.ts +42 -1
- 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 +8 -1
- package/src/adapters/google.ts +23 -4
- 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 +10 -1
- package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
- package/src/adapters/openai-responses/tool-schema.ts +19 -7
- 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 +165 -24
- 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 +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/dispatch.ts +54 -0
- package/src/cli/doctor.ts +197 -2
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +88 -20
- 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 +2 -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 +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 +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/history-provider.ts +4 -1
- 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/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-entitlements.ts +60 -1
- 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/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 +21 -0
- package/src/config/schema/leaf-validators.ts +64 -17
- package/src/generated/compatibility-version.json +235 -163
- package/src/generated/model-metadata.ts +1 -1
- package/src/lib/bounded-body.ts +4 -2
- 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/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/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-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-extended.ts +96 -0
- package/src/providers/registry/model-seeds.ts +78 -21
- 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/server/auth-cors.ts +26 -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.ts +15 -19
- package/src/server/management/api-access.ts +3 -4
- package/src/server/management/config-routes.ts +31 -6
- package/src/server/management/provider-capability-config.ts +35 -7
- package/src/server/management/provider-routes.ts +70 -18
- 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/compact.ts +60 -11
- package/src/server/responses/core-codex-account.ts +83 -22
- package/src/server/responses/core-normalize.ts +12 -5
- package/src/server/responses/fetch-helpers.ts +68 -2
- package/src/server/responses/passthrough-delivery.ts +10 -1
- package/src/server/responses/passthrough-dispatch.ts +113 -48
- package/src/server/responses/passthrough-execution.ts +11 -1
- package/src/server/responses/request-prepare.ts +29 -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 +2 -1
- 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/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/types/accounts.ts +4 -0
- package/src/types/config.ts +100 -3
- package/src/types/provider.ts +19 -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-BbrHOIY0.js +0 -128
|
@@ -22,6 +22,8 @@ import {
|
|
|
22
22
|
hasHeadroomEvidence,
|
|
23
23
|
isAccountQuotaExhausted,
|
|
24
24
|
rankAccountsByHeadroom,
|
|
25
|
+
classifyModelFamilyForQuota,
|
|
26
|
+
type QuotaModelFamily,
|
|
25
27
|
} from "./account-quota-rank";
|
|
26
28
|
import {
|
|
27
29
|
genericPoolKey,
|
|
@@ -81,13 +83,14 @@ const health = new Map<string, AccountHealth>();
|
|
|
81
83
|
/** Provider -> recent eligible-account count. TTL-bounded; never holds credential material. */
|
|
82
84
|
const presence = new Map<string, PresenceEntry>();
|
|
83
85
|
|
|
84
|
-
const healthKey = (provider: string, accountId: string) =>
|
|
86
|
+
const healthKey = (provider: string, accountId: string, family?: QuotaModelFamily) =>
|
|
87
|
+
family ? `${provider}\u0000${accountId}\u0000${family}` : `${provider}\u0000${accountId}`;
|
|
85
88
|
|
|
86
|
-
function isCooled(provider: string, accountId: string, now: number): boolean {
|
|
87
|
-
const entry = health.get(healthKey(provider, accountId));
|
|
89
|
+
function isCooled(provider: string, accountId: string, now: number, family?: QuotaModelFamily): boolean {
|
|
90
|
+
const entry = health.get(healthKey(provider, accountId, family));
|
|
88
91
|
if (!entry) return false;
|
|
89
92
|
if (entry.cooldownUntil <= now) {
|
|
90
|
-
health.delete(healthKey(provider, accountId));
|
|
93
|
+
health.delete(healthKey(provider, accountId, family));
|
|
91
94
|
return false;
|
|
92
95
|
}
|
|
93
96
|
return true;
|
|
@@ -175,11 +178,11 @@ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, n
|
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
/** Accounts that may serve traffic right now: not cooled, not flagged for reauth. */
|
|
178
|
-
export function eligibleFailoverAccounts(providerName: string, now = Date.now()): string[] {
|
|
181
|
+
export function eligibleFailoverAccounts(providerName: string, now = Date.now(), family?: QuotaModelFamily): string[] {
|
|
179
182
|
const set = getAccountSet(providerName);
|
|
180
183
|
if (!set) return [];
|
|
181
184
|
return set.accounts
|
|
182
|
-
.filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now))
|
|
185
|
+
.filter(account => account.needsReauth !== true && !isCooled(providerName, account.id, now, family))
|
|
183
186
|
.map(account => account.id);
|
|
184
187
|
}
|
|
185
188
|
|
|
@@ -230,8 +233,8 @@ function stableGenericRoster(providerName: string): string[] {
|
|
|
230
233
|
* statement about observed usage, and treating "no observation" as "spent" would evacuate every
|
|
231
234
|
* quota-less provider off its active account on the very first request.
|
|
232
235
|
*/
|
|
233
|
-
function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number): boolean {
|
|
234
|
-
const headroom = accountHeadroomPercent(providerName, accountId);
|
|
236
|
+
function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null): boolean {
|
|
237
|
+
const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId);
|
|
235
238
|
if (headroom === null) return false;
|
|
236
239
|
return 100 - headroom >= threshold;
|
|
237
240
|
}
|
|
@@ -245,15 +248,17 @@ function pickFillFirstGenericAccount(
|
|
|
245
248
|
providerName: string,
|
|
246
249
|
activeId: string | undefined,
|
|
247
250
|
now: number,
|
|
251
|
+
requestedModelId?: string | null,
|
|
248
252
|
): string | null {
|
|
249
253
|
const stableAll = stableGenericRoster(providerName);
|
|
250
254
|
if (stableAll.length < 2) return null;
|
|
251
|
-
const
|
|
255
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
256
|
+
const eligible = new Set(eligibleFailoverAccounts(providerName, now, family));
|
|
252
257
|
const stored = config.providers?.[providerName]?.oauthAccountFailover?.autoSwitchThreshold;
|
|
253
258
|
const threshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100
|
|
254
259
|
? stored
|
|
255
260
|
: DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD;
|
|
256
|
-
if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold)) {
|
|
261
|
+
if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold, requestedModelId)) {
|
|
257
262
|
return null;
|
|
258
263
|
}
|
|
259
264
|
const start = activeId ? stableAll.indexOf(activeId) : -1;
|
|
@@ -277,11 +282,17 @@ function pickFillFirstGenericAccount(
|
|
|
277
282
|
* advance and round-robin would propose the same account forever. This is the same shape
|
|
278
283
|
* `commitAnthropicSelectionRouting` already commits with.
|
|
279
284
|
*/
|
|
280
|
-
export function noteGenericPoolSelection(
|
|
285
|
+
export function noteGenericPoolSelection(
|
|
286
|
+
config: OcxConfig,
|
|
287
|
+
providerName: string,
|
|
288
|
+
accountId: string,
|
|
289
|
+
requestedModelId?: string | null,
|
|
290
|
+
): void {
|
|
281
291
|
if (activeGenericStrategy(config, providerName) !== "round-robin") return;
|
|
282
292
|
const poolKey = genericPoolKey(providerName);
|
|
283
293
|
const limit = genericStickyLimit(config, providerName);
|
|
284
|
-
const
|
|
294
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
295
|
+
const picked = pickRoundRobinAccount(poolKey, eligibleFailoverAccounts(providerName, Date.now(), family), limit);
|
|
285
296
|
// The resolver may have admitted a different account than the ring proposed: a removal, a
|
|
286
297
|
// reauth verdict or a manual selection can land during credential resolution. Realign the
|
|
287
298
|
// cursor onto what actually served rather than leaving it on a road not taken.
|
|
@@ -302,6 +313,7 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
302
313
|
failedAccountId: string,
|
|
303
314
|
retryAfterHeader: string | null | undefined,
|
|
304
315
|
now = Date.now(),
|
|
316
|
+
requestedModelId?: string | null,
|
|
305
317
|
): string | null {
|
|
306
318
|
if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
|
|
307
319
|
const set = getAccountSet(providerName);
|
|
@@ -314,13 +326,14 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
314
326
|
// A Retry-After from upstream still wins — it is the server's own instruction.
|
|
315
327
|
const exhausted = parsed === undefined ? exhaustedCooldownMs(providerName, failedAccountId, now) : null;
|
|
316
328
|
const cooldownMs = exhausted ?? Math.min(parsed ?? DEFAULT_COOLDOWN_MS, MAX_COOLDOWN_MS);
|
|
317
|
-
|
|
329
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
330
|
+
health.set(healthKey(providerName, failedAccountId, family), {
|
|
318
331
|
cooldownUntil: now + cooldownMs,
|
|
319
332
|
cooldownSource: parsed ? "retry-after" : "default",
|
|
320
333
|
});
|
|
321
334
|
sweepExpiredOnWrite(now);
|
|
322
335
|
|
|
323
|
-
const eligible = eligibleFailoverAccounts(providerName, now).filter(id => id !== failedAccountId);
|
|
336
|
+
const eligible = eligibleFailoverAccounts(providerName, now, family).filter(id => id !== failedAccountId);
|
|
324
337
|
if (eligible.length === 0) return null;
|
|
325
338
|
// A rotation means the roster in use just changed; do not answer the next activation question
|
|
326
339
|
// from a count read before the failure.
|
|
@@ -359,7 +372,7 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
359
372
|
}
|
|
360
373
|
// With no quota evidence this returns the ring untouched, so providers without
|
|
361
374
|
// per-account quota keep exactly the traversal they have today.
|
|
362
|
-
return rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
|
|
375
|
+
return rankAccountsByHeadroom(providerName, candidates, requestedModelId)[0] ?? null;
|
|
363
376
|
}
|
|
364
377
|
|
|
365
378
|
/**
|
|
@@ -392,6 +405,7 @@ export function preferredInitialAccount(
|
|
|
392
405
|
config: OcxConfig,
|
|
393
406
|
providerName: string,
|
|
394
407
|
now = Date.now(),
|
|
408
|
+
requestedModelId?: string | null,
|
|
395
409
|
): string | null {
|
|
396
410
|
// The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
|
|
397
411
|
// refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
|
|
@@ -411,7 +425,8 @@ export function preferredInitialAccount(
|
|
|
411
425
|
// would never reach its own test. Cooldowns and reauth are still honoured inside each pick.
|
|
412
426
|
const strategy = activeGenericStrategy(config, providerName);
|
|
413
427
|
if (strategy === "round-robin") {
|
|
414
|
-
const
|
|
428
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
429
|
+
const eligibleNow = eligibleFailoverAccounts(providerName, now, family);
|
|
415
430
|
if (eligibleNow.length === 0) return null;
|
|
416
431
|
// PEEK, not pick: this proposal is discardable, and advancing the ring for an account the
|
|
417
432
|
// resolver then rejects would skip a turn for nothing. noteGenericPoolSelection commits.
|
|
@@ -423,26 +438,26 @@ export function preferredInitialAccount(
|
|
|
423
438
|
return picked && picked !== active ? picked : null;
|
|
424
439
|
}
|
|
425
440
|
if (strategy === "fill-first") {
|
|
426
|
-
const picked = pickFillFirstGenericAccount(config, providerName, active, now);
|
|
441
|
+
const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId);
|
|
427
442
|
return picked && picked !== active ? picked : null;
|
|
428
443
|
}
|
|
429
444
|
|
|
430
445
|
const activeRow = selected.accounts.find(account => account.id === active);
|
|
431
446
|
if (activeRow && activeRow.needsReauth !== true
|
|
432
|
-
&& !isCooled(providerName, activeRow.id, now)
|
|
433
|
-
&& !isAccountQuotaExhausted(providerName, activeRow.id)) return null;
|
|
447
|
+
&& !isCooled(providerName, activeRow.id, now, classifyModelFamilyForQuota(providerName, requestedModelId))
|
|
448
|
+
&& !isAccountQuotaExhausted(providerName, activeRow.id, requestedModelId)) return null;
|
|
434
449
|
|
|
435
450
|
// Evidence is required BEFORE eligibility narrows the field. Without this, a provider
|
|
436
451
|
// with no quota data at all could still be redirected: cool the active account with a
|
|
437
452
|
// 429 and the eligible list collapses to one candidate, which any ranking returns
|
|
438
453
|
// unchanged — an answer that looks ranked but was never measured. The no-op guarantee
|
|
439
454
|
// for quota-less providers has to be checked on the full roster.
|
|
440
|
-
if (!hasHeadroomEvidence(providerName, order)) return null;
|
|
455
|
+
if (!hasHeadroomEvidence(providerName, order, requestedModelId)) return null;
|
|
441
456
|
|
|
442
457
|
// Cooldowns are respected here, unlike in the presence count: this picks the account to
|
|
443
458
|
// send to right now, and one inside its 429 window is the single candidate we hold
|
|
444
459
|
// positive evidence against.
|
|
445
|
-
const eligible = order.filter(id => !isCooled(providerName, id, now));
|
|
460
|
+
const eligible = order.filter(id => !isCooled(providerName, id, now, classifyModelFamilyForQuota(providerName, requestedModelId)));
|
|
446
461
|
if (eligible.length === 0) return null;
|
|
447
462
|
|
|
448
463
|
// Start the ring at the active account so an unranked outcome reproduces today's choice.
|
|
@@ -451,7 +466,7 @@ export function preferredInitialAccount(
|
|
|
451
466
|
const candidates = ring.filter(id => eligible.includes(id));
|
|
452
467
|
if (candidates.length === 0) return null;
|
|
453
468
|
|
|
454
|
-
const best = rankAccountsByHeadroom(providerName, candidates)[0] ?? null;
|
|
469
|
+
const best = rankAccountsByHeadroom(providerName, candidates, requestedModelId)[0] ?? null;
|
|
455
470
|
// Nothing to do when the ranking agrees with the account we would have used anyway.
|
|
456
471
|
//
|
|
457
472
|
// A proposal still needs guarded selection commit after credential resolution: a
|
|
@@ -461,12 +476,10 @@ export function preferredInitialAccount(
|
|
|
461
476
|
|
|
462
477
|
/** Earliest remaining cooldown, for a client-facing Retry-After when every account is cooled. */
|
|
463
478
|
export function genericFailoverRetryAfterSeconds(providerName: string, now = Date.now()): number | null {
|
|
464
|
-
const
|
|
465
|
-
if (!set) return null;
|
|
479
|
+
const prefix = `${providerName}\u0000`;
|
|
466
480
|
let earliest: number | null = null;
|
|
467
|
-
for (const
|
|
468
|
-
|
|
469
|
-
if (!entry || entry.cooldownUntil <= now) continue;
|
|
481
|
+
for (const [key, entry] of health) {
|
|
482
|
+
if (!key.startsWith(prefix) || entry.cooldownUntil <= now) continue;
|
|
470
483
|
if (earliest === null || entry.cooldownUntil < earliest) earliest = entry.cooldownUntil;
|
|
471
484
|
}
|
|
472
485
|
return earliest === null ? null : Math.max(1, Math.ceil((earliest - now) / 1000));
|
package/src/oauth/orcarouter.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { OAuthCallbackFlow, type OAuthCallbackFlowOptions } from "./callback-server";
|
|
3
3
|
import { generatePKCE } from "./pkce";
|
|
4
4
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
5
|
+
import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body";
|
|
5
6
|
|
|
6
7
|
export const ORCAROUTER_DEFAULT_API_BASE_URL = "https://api.orcarouter.ai";
|
|
7
8
|
export const ORCAROUTER_DEFAULT_AUTH_BASE_URL = "https://www.orcarouter.ai";
|
|
@@ -147,6 +148,7 @@ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow {
|
|
|
147
148
|
|
|
148
149
|
async exchangeToken(code: string, _state: string, _redirectUri: string): Promise<OAuthCredentials> {
|
|
149
150
|
if (!this.#verifier) throw new Error("OrcaRouter PKCE verifier was not initialized");
|
|
151
|
+
const signal = requestSignal(this.ctrl.signal);
|
|
150
152
|
let response: Response;
|
|
151
153
|
try {
|
|
152
154
|
response = await fetch(new URL("/api/v1/auth/keys", this.#authBaseUrl), {
|
|
@@ -158,7 +160,7 @@ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow {
|
|
|
158
160
|
code_challenge_method: "S256",
|
|
159
161
|
}),
|
|
160
162
|
redirect: "error",
|
|
161
|
-
signal
|
|
163
|
+
signal,
|
|
162
164
|
});
|
|
163
165
|
} catch (error) {
|
|
164
166
|
if (this.ctrl.signal?.aborted) {
|
|
@@ -171,9 +173,20 @@ export class OrcaRouterOAuthFlow extends OAuthCallbackFlow {
|
|
|
171
173
|
// never turn a code, verifier, or accidentally returned key into console output.
|
|
172
174
|
throw new Error(`OrcaRouter key exchange failed with HTTP ${response.status}`);
|
|
173
175
|
}
|
|
176
|
+
const { bytes, oversized } = await readBoundedResponseBytes(response, {
|
|
177
|
+
maxBytes: BOUNDED_BODY_MAX_BYTES,
|
|
178
|
+
signal,
|
|
179
|
+
}).catch(() => {
|
|
180
|
+
if (signal.aborted) throw signal.reason;
|
|
181
|
+
// Preserve the existing non-reflective error for response-body failures.
|
|
182
|
+
throw new Error("OrcaRouter key exchange returned invalid JSON");
|
|
183
|
+
});
|
|
184
|
+
if (oversized) {
|
|
185
|
+
throw new Error(`OrcaRouter key exchange response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`);
|
|
186
|
+
}
|
|
174
187
|
let payload: unknown;
|
|
175
188
|
try {
|
|
176
|
-
payload =
|
|
189
|
+
payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
177
190
|
} catch {
|
|
178
191
|
throw new Error("OrcaRouter key exchange returned invalid JSON");
|
|
179
192
|
}
|
package/src/oauth/store.ts
CHANGED
|
@@ -437,6 +437,14 @@ function backupLegacyOnce(): void {
|
|
|
437
437
|
try {
|
|
438
438
|
copyFileSync(path, backup);
|
|
439
439
|
try { chmodSync(backup, 0o600); } catch { /* best-effort */ }
|
|
440
|
+
try {
|
|
441
|
+
// Register only the copy we just created. An unowned home still needs downgrade recovery.
|
|
442
|
+
if (!recordOwnedConfigPath(getConfigDir(), backup)) {
|
|
443
|
+
console.warn("[oauth] Recovery backup created, but uninstall ownership registration failed.");
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
console.warn("[oauth] Recovery backup created, but uninstall ownership registration failed.");
|
|
447
|
+
}
|
|
440
448
|
} catch { /* best-effort */ }
|
|
441
449
|
}
|
|
442
450
|
|
|
@@ -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;
|
|
@@ -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",
|