@bitkyc08/opencodex 2.41.0 → 2.43.0-preview.20260906
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/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-VGeQEZ_v.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +54 -24
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/identity.ts +8 -2
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +259 -29
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +47 -13
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +49 -9
- package/src/cli/account-extended.ts +7 -1
- package/src/cli/capabilities.ts +15 -4
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/observe.ts +3 -1
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +272 -32
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +44 -5
- package/src/codex/catalog/metadata.ts +149 -14
- package/src/codex/catalog/native-models.ts +116 -4
- package/src/codex/catalog/parsing.ts +122 -8
- package/src/codex/catalog/provider-fetch.ts +154 -23
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/catalog.ts +1 -1
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +169 -0
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +99 -34
- package/src/codex/injected-marker.ts +30 -4
- package/src/codex/journal.ts +14 -0
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +474 -178
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/account-quota-rank.ts +40 -1
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/chatgpt-device.ts +187 -0
- package/src/oauth/chatgpt.ts +31 -4
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +140 -29
- package/src/oauth/log.ts +3 -0
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/muse-subscription-usage.ts +95 -0
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +625 -98
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +122 -28
- package/src/server/live.ts +18 -4
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +45 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +510 -57
- package/src/server/responses/empty-completion-guard.ts +4 -0
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/request.ts +8 -0
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-B2YjLA-i.css +0 -1
- package/gui/dist/assets/index-aPup8CKb.js +0 -112
|
@@ -152,6 +152,7 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
|
|
|
152
152
|
export function clearAnthropicAccountPoolState(): void {
|
|
153
153
|
upstreamHealth.clear();
|
|
154
154
|
sessionAffinity.clear();
|
|
155
|
+
quorumCache = null;
|
|
155
156
|
}
|
|
156
157
|
|
|
157
158
|
export function anthropicSessionAffinitySizeForTests(): number {
|
|
@@ -236,6 +237,79 @@ export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
|
|
|
236
237
|
.map(account => account.id);
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
/**
|
|
241
|
+
* How long a quorum answer may be reused before the store is consulted again.
|
|
242
|
+
*
|
|
243
|
+
* This predicate now runs on the INITIAL resolution of every Anthropic request, not just after a
|
|
244
|
+
* 429, so an uncached implementation puts a synchronous file read in front of ordinary traffic:
|
|
245
|
+
* `getAccountSet` goes through `loadAuthStore`, which has no cache of its own and chmods the
|
|
246
|
+
* config dir, chmods the secret, reads the whole file and normalizes it on every call.
|
|
247
|
+
*
|
|
248
|
+
* Two seconds matches the generic module's `PRESENCE_CACHE_TTL_MS` for the same reason: short
|
|
249
|
+
* enough that a login in another window is visible before the operator can switch back and send a
|
|
250
|
+
* prompt, long enough that a burst of requests shares one read. The cache holds a BOOLEAN derived
|
|
251
|
+
* from a count — never a credential, never an account id.
|
|
252
|
+
*
|
|
253
|
+
* Staleness is bounded by consequence, not only by the TTL. Explicit invalidation covers the
|
|
254
|
+
* roster mutations this module can see (rotation, pool-state reset, affinity clear on account
|
|
255
|
+
* removal, manual selection), but not one it cannot: a 401 elsewhere flagging an account
|
|
256
|
+
* `needsReauth` drops the real quorum to one while a cached `true` survives for up to 2s.
|
|
257
|
+
*
|
|
258
|
+
* That window is harmless in both directions, which is why it is left rather than plumbed
|
|
259
|
+
* through the store. A stale `true` only lets the caller ASK for an alternate;
|
|
260
|
+
* `pickAlternateAnthropicAccount` re-reads the roster through `getEligibleAnthropicAccounts`,
|
|
261
|
+
* skips the reauth-flagged account and returns `null`, so the 429 surfaces exactly as it would
|
|
262
|
+
* have. A stale `false` costs one un-rotated 429 and self-corrects on the next read. Neither
|
|
263
|
+
* can dispatch on an unusable credential, which is the only outcome worth adding a store hook
|
|
264
|
+
* to prevent.
|
|
265
|
+
*/
|
|
266
|
+
const QUORUM_CACHE_TTL_MS = 2_000;
|
|
267
|
+
|
|
268
|
+
let quorumCache: { value: boolean; readAt: number } | null = null;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Whether a 429 has somewhere to go: two or more accounts that could serve traffic if asked.
|
|
272
|
+
*
|
|
273
|
+
* Reactive failover is a safety net, not a routing policy. It runs only AFTER upstream refused,
|
|
274
|
+
* it cannot spread load across a healthy session, and it cannot fire at all unless the operator
|
|
275
|
+
* deliberately logged in twice. So it activates on presence, exactly like an `apiKeyPool` of two
|
|
276
|
+
* keys does in `providers/key-failover.ts` -- and unlike the PROACTIVE pool (affinity,
|
|
277
|
+
* quota-ranked new-session picks, `autoSwitchThreshold`, `strategy`), which changes which
|
|
278
|
+
* account serves a healthy request and therefore stays behind `anthropicAccountPool.enabled`.
|
|
279
|
+
*
|
|
280
|
+
* Cooldowns are deliberately ignored here. They are transient and per-request, while this
|
|
281
|
+
* answers the durable question "did the operator store a second account". Counting a cooled
|
|
282
|
+
* account as absent would switch the feature off for the length of the cooldown -- precisely
|
|
283
|
+
* when it is needed.
|
|
284
|
+
*
|
|
285
|
+
* `isPoolCredentialUsable` is still applied, so the fail-closed background `local-cli` rule
|
|
286
|
+
* holds: an expired background slot is not a quorum and cannot be adopted.
|
|
287
|
+
*/
|
|
288
|
+
export function hasAnthropicFailoverQuorum(now = Date.now()): boolean {
|
|
289
|
+
// Monotonic guard: a caller-supplied `now` that predates the cached read (tests pass explicit
|
|
290
|
+
// clocks) must not be served from a future entry.
|
|
291
|
+
if (quorumCache && now >= quorumCache.readAt && now - quorumCache.readAt < QUORUM_CACHE_TTL_MS) {
|
|
292
|
+
return quorumCache.value;
|
|
293
|
+
}
|
|
294
|
+
const set = getAccountSet(PROVIDER);
|
|
295
|
+
let value = false;
|
|
296
|
+
if (set) {
|
|
297
|
+
let usable = 0;
|
|
298
|
+
for (const account of set.accounts) {
|
|
299
|
+
if (account.needsReauth === true) continue;
|
|
300
|
+
if (!isPoolCredentialUsable(account.id, now)) continue;
|
|
301
|
+
if (++usable >= 2) { value = true; break; }
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
quorumCache = { value, readAt: now };
|
|
305
|
+
return value;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Test seam and manual-recovery hook: force the next quorum question to re-read the store. */
|
|
309
|
+
export function forgetAnthropicFailoverQuorum(): void {
|
|
310
|
+
quorumCache = null;
|
|
311
|
+
}
|
|
312
|
+
|
|
239
313
|
/** Earliest remaining cooldown among cooled Anthropic accounts, for client Retry-After. */
|
|
240
314
|
export function getAnthropicPoolRetryAfterSeconds(now = Date.now()): number | null {
|
|
241
315
|
const set = getAccountSet(PROVIDER);
|
|
@@ -559,6 +633,10 @@ export function clearAnthropicSessionAffinityForAccount(accountId: string): void
|
|
|
559
633
|
for (const [key, entry] of sessionAffinity) {
|
|
560
634
|
if (entry.accountId === accountId) sessionAffinity.delete(key);
|
|
561
635
|
}
|
|
636
|
+
// The roster just lost or changed a member. This is the account-removal path, so the next
|
|
637
|
+
// activation question must re-read rather than answer from a count taken while the account
|
|
638
|
+
// was still present -- otherwise a delete leaves a stale quorum for the length of the TTL.
|
|
639
|
+
quorumCache = null;
|
|
562
640
|
}
|
|
563
641
|
|
|
564
642
|
/**
|
|
@@ -573,7 +651,13 @@ export function rotateAnthropicAccountOn429(
|
|
|
573
651
|
sessionKey?: string | null,
|
|
574
652
|
now = Date.now(),
|
|
575
653
|
): string | null {
|
|
576
|
-
|
|
654
|
+
// Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
|
|
655
|
+
// session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
|
|
656
|
+
// of which move a HEALTHY request and stay opt-in. Rotating away from an account upstream has
|
|
657
|
+
// just rate-limited is a different thing: it only ever runs after a refusal, and stranding a
|
|
658
|
+
// 429 while a second logged-in account sits idle is a defect, not a configuration choice.
|
|
659
|
+
// Presence is the activation rule, the same one an apiKeyPool of two keys already uses.
|
|
660
|
+
if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)) return null;
|
|
577
661
|
|
|
578
662
|
const parsedRetry = parseRetryAfterMs(retryAfterHeader, now);
|
|
579
663
|
const cooldownMs = parsedRetry ?? DEFAULT_COOLDOWN_MS;
|
|
@@ -584,8 +668,17 @@ export function rotateAnthropicAccountOn429(
|
|
|
584
668
|
sweepExpiredOnWrite(now);
|
|
585
669
|
clearAnthropicSessionAffinityForAccount(failedAccountId);
|
|
586
670
|
notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId);
|
|
587
|
-
|
|
588
|
-
|
|
671
|
+
// A rotation means the roster in use just changed; do not answer the next activation question
|
|
672
|
+
// from a count read taken before the failure.
|
|
673
|
+
quorumCache = null;
|
|
674
|
+
|
|
675
|
+
// The pool's strategy is a PROACTIVE policy. When the pool is disabled, reactive
|
|
676
|
+
// presence-only recovery must not silently reactivate round-robin/fill-first merely
|
|
677
|
+
// because those dormant values remain in config. The quota picker is the neutral
|
|
678
|
+
// recovery policy already used by the default strategy.
|
|
679
|
+
const next = isAnthropicAccountPoolEnabled(config)
|
|
680
|
+
? pickAlternateAnthropicAccount(config, failedAccountId, now)
|
|
681
|
+
: pickLowestUsage(config, failedAccountId, now);
|
|
589
682
|
if (!next) {
|
|
590
683
|
console.warn("[anthropic-pool] all eligible Anthropic OAuth accounts are in cooldown; returning 429");
|
|
591
684
|
return null;
|
|
@@ -614,6 +707,9 @@ export function promoteAnthropicActiveAccount(accountId: string): void {
|
|
|
614
707
|
export function resetAnthropicRoutingForManualSelection(accountId: string): void {
|
|
615
708
|
sessionAffinity.clear();
|
|
616
709
|
seedPoolRotationAccount(POOL_KEY_ANTHROPIC, accountId);
|
|
710
|
+
// A manual account selection is an operator statement about the roster; do not answer the
|
|
711
|
+
// next activation question from a count read before it.
|
|
712
|
+
quorumCache = null;
|
|
617
713
|
}
|
|
618
714
|
|
|
619
715
|
/**
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
2
|
+
import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken } from "./chatgpt";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* OpenAI deviceauth (device-code) grant for the ChatGPT/Codex provider.
|
|
6
|
+
*
|
|
7
|
+
* The callback flow in `./chatgpt` needs a browser and a listener on
|
|
8
|
+
* localhost:1455. A hub running headless in a container or over SSH has
|
|
9
|
+
* neither, which left "copy the long redirect URL out of the browser error
|
|
10
|
+
* page" as the only way to add an account there (#3366).
|
|
11
|
+
*
|
|
12
|
+
* This is the same grant Codex CLI uses. Three steps, and the middle one is
|
|
13
|
+
* where it differs from RFC 8628: the poll returns an authorization code plus
|
|
14
|
+
* a SERVER-generated PKCE verifier, which is then spent at the ordinary token
|
|
15
|
+
* endpoint. We never generate the verifier ourselves here.
|
|
16
|
+
*/
|
|
17
|
+
const USERCODE_URL = "https://auth.openai.com/api/accounts/deviceauth/usercode";
|
|
18
|
+
const DEVICE_TOKEN_URL = "https://auth.openai.com/api/accounts/deviceauth/token";
|
|
19
|
+
const DEVICE_REDIRECT_URI = "https://auth.openai.com/deviceauth/callback";
|
|
20
|
+
|
|
21
|
+
/** Where the user types the short code. Fixed, and safe to show anywhere. */
|
|
22
|
+
export const DEVICE_VERIFICATION_URL = "https://auth.openai.com/codex/device";
|
|
23
|
+
|
|
24
|
+
/** The grant's own lifetime. Polling past this only produces a worse error message. */
|
|
25
|
+
const DEVICE_FLOW_TTL_MS = 15 * 60 * 1000;
|
|
26
|
+
const DEFAULT_POLL_INTERVAL_MS = 5_000;
|
|
27
|
+
const MIN_POLL_INTERVAL_MS = 1_000;
|
|
28
|
+
/**
|
|
29
|
+
* Above ~2^31 ms a timer overflows and fires immediately, which would turn a
|
|
30
|
+
* hostile or corrupt `interval` into a hot loop against an auth endpoint. The
|
|
31
|
+
* grant only lives 15 minutes, so anything longer is meaningless anyway.
|
|
32
|
+
*/
|
|
33
|
+
const MAX_POLL_INTERVAL_MS = DEVICE_FLOW_TTL_MS;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Upstream sends `interval` as a number in some responses and a string in
|
|
37
|
+
* others. A string would make `setTimeout` treat it as 0 and turn the poll
|
|
38
|
+
* into a hot loop against an auth endpoint, so coerce and floor it.
|
|
39
|
+
*/
|
|
40
|
+
function normalizeIntervalMs(raw: unknown): number {
|
|
41
|
+
const seconds = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
|
|
42
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return DEFAULT_POLL_INTERVAL_MS;
|
|
43
|
+
const ms = Math.round(seconds * 1000);
|
|
44
|
+
return Math.min(MAX_POLL_INTERVAL_MS, Math.max(MIN_POLL_INTERVAL_MS, ms));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nonEmptyString(value: unknown): string | undefined {
|
|
48
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
52
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
53
|
+
await new Promise<void>((resolve, reject) => {
|
|
54
|
+
const timer = setTimeout(() => {
|
|
55
|
+
signal?.removeEventListener("abort", onAbort);
|
|
56
|
+
resolve();
|
|
57
|
+
}, ms);
|
|
58
|
+
const onAbort = (): void => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
reject(new Error("Login cancelled"));
|
|
61
|
+
};
|
|
62
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Device-flow errors carry the HTTP status and nothing else.
|
|
68
|
+
*
|
|
69
|
+
* The callback flow's `safeErrorDescription` reflects the upstream body into
|
|
70
|
+
* the message, which is fine for an OAuth error envelope but not here: these
|
|
71
|
+
* endpoints can echo request material, and this message reaches CLI output,
|
|
72
|
+
* the GUI, and issue reports.
|
|
73
|
+
*/
|
|
74
|
+
function deviceError(stage: string, status: number): Error {
|
|
75
|
+
return new Error(`ChatGPT device authorization ${stage} failed: HTTP ${status}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface DeviceUserCode {
|
|
79
|
+
deviceAuthId: string;
|
|
80
|
+
userCode: string;
|
|
81
|
+
intervalMs: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function requestUserCode(signal?: AbortSignal): Promise<DeviceUserCode> {
|
|
85
|
+
const response = await fetch(USERCODE_URL, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
headers: { "Content-Type": "application/json" },
|
|
88
|
+
body: JSON.stringify({ client_id: CHATGPT_CLIENT_ID }),
|
|
89
|
+
signal,
|
|
90
|
+
});
|
|
91
|
+
if (!response.ok) throw deviceError("request", response.status);
|
|
92
|
+
const payload = (await response.json()) as Record<string, unknown>;
|
|
93
|
+
const deviceAuthId = nonEmptyString(payload.device_auth_id);
|
|
94
|
+
// Upstream accepts both spellings, so a response using the alias must not be
|
|
95
|
+
// rejected as malformed.
|
|
96
|
+
const userCode = nonEmptyString(payload.user_code) ?? nonEmptyString(payload.usercode);
|
|
97
|
+
if (!deviceAuthId || !userCode) {
|
|
98
|
+
throw new Error("ChatGPT device authorization response missing required fields");
|
|
99
|
+
}
|
|
100
|
+
return { deviceAuthId, userCode, intervalMs: normalizeIntervalMs(payload.interval) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface DeviceGrant {
|
|
104
|
+
authorizationCode: string;
|
|
105
|
+
codeVerifier: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Poll until the user finishes at the verification page.
|
|
110
|
+
*
|
|
111
|
+
* Pending is signalled by 403/404 rather than an `authorization_pending` body,
|
|
112
|
+
* so status is the whole protocol here: any other non-2xx is terminal, and
|
|
113
|
+
* treating it as pending would keep hammering a permanently failing endpoint.
|
|
114
|
+
*/
|
|
115
|
+
async function pollForGrant(
|
|
116
|
+
device: DeviceUserCode,
|
|
117
|
+
signal?: AbortSignal,
|
|
118
|
+
): Promise<DeviceGrant> {
|
|
119
|
+
const deadline = Date.now() + DEVICE_FLOW_TTL_MS;
|
|
120
|
+
while (Date.now() < deadline) {
|
|
121
|
+
if (signal?.aborted) throw new Error("Login cancelled");
|
|
122
|
+
const response = await fetch(DEVICE_TOKEN_URL, {
|
|
123
|
+
method: "POST",
|
|
124
|
+
headers: { "Content-Type": "application/json" },
|
|
125
|
+
body: JSON.stringify({ device_auth_id: device.deviceAuthId, user_code: device.userCode }),
|
|
126
|
+
signal,
|
|
127
|
+
});
|
|
128
|
+
if (response.status === 403 || response.status === 404) {
|
|
129
|
+
// Cap the wait at the time actually left. Sleeping a full interval past
|
|
130
|
+
// the deadline is how a 15-minute grant turns into a 20-minute wait.
|
|
131
|
+
const remaining = deadline - Date.now();
|
|
132
|
+
if (remaining <= 0) break;
|
|
133
|
+
await sleep(Math.min(device.intervalMs, remaining), signal);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (!response.ok) throw deviceError("poll", response.status);
|
|
137
|
+
// The deadline is checked again here, not only at the top of the loop: a
|
|
138
|
+
// single poll can itself outlive the grant, and accepting a code that
|
|
139
|
+
// expired mid-flight just moves the failure to the token exchange.
|
|
140
|
+
if (Date.now() >= deadline) break;
|
|
141
|
+
const payload = (await response.json()) as Record<string, unknown>;
|
|
142
|
+
const authorizationCode = nonEmptyString(payload.authorization_code);
|
|
143
|
+
const codeVerifier = nonEmptyString(payload.code_verifier);
|
|
144
|
+
if (!authorizationCode || !codeVerifier) {
|
|
145
|
+
throw new Error("ChatGPT device authorization response missing required fields");
|
|
146
|
+
}
|
|
147
|
+
return { authorizationCode, codeVerifier };
|
|
148
|
+
}
|
|
149
|
+
throw new Error("ChatGPT device authorization expired");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function exchangeGrant(grant: DeviceGrant, signal?: AbortSignal): Promise<OAuthCredentials> {
|
|
153
|
+
const response = await fetch(CHATGPT_TOKEN_URL, {
|
|
154
|
+
method: "POST",
|
|
155
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
156
|
+
body: new URLSearchParams({
|
|
157
|
+
grant_type: "authorization_code",
|
|
158
|
+
client_id: CHATGPT_CLIENT_ID,
|
|
159
|
+
code: grant.authorizationCode,
|
|
160
|
+
code_verifier: grant.codeVerifier,
|
|
161
|
+
redirect_uri: DEVICE_REDIRECT_URI,
|
|
162
|
+
}).toString(),
|
|
163
|
+
signal,
|
|
164
|
+
});
|
|
165
|
+
if (!response.ok) throw deviceError("token exchange", response.status);
|
|
166
|
+
return credsFromToken((await response.json()) as Record<string, unknown>);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Run the device flow to completion.
|
|
171
|
+
*
|
|
172
|
+
* `deviceCode` in the `onAuth` payload is the HUMAN code, matching kimi, nous,
|
|
173
|
+
* and github-copilot. The opaque `device_auth_id` never leaves this module:
|
|
174
|
+
* every device-code surface renders `deviceCode` verbatim, and the management
|
|
175
|
+
* login route also uses its presence to decide a flow must not be handed to a
|
|
176
|
+
* local browser spawn.
|
|
177
|
+
*/
|
|
178
|
+
export async function loginChatGPTDevice(ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
179
|
+
const device = await requestUserCode(ctrl.signal);
|
|
180
|
+
ctrl.onAuth?.({
|
|
181
|
+
url: DEVICE_VERIFICATION_URL,
|
|
182
|
+
instructions: `Enter code: ${device.userCode}`,
|
|
183
|
+
deviceCode: device.userCode,
|
|
184
|
+
});
|
|
185
|
+
const grant = await pollForGrant(device, ctrl.signal);
|
|
186
|
+
return exchangeGrant(grant, ctrl.signal);
|
|
187
|
+
}
|
package/src/oauth/chatgpt.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { generatePKCE } from "./pkce";
|
|
|
5
5
|
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
6
6
|
const AUTH_URL = "https://auth.openai.com/oauth/authorize";
|
|
7
7
|
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
8
|
+
|
|
9
|
+
/** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
|
|
10
|
+
export const CHATGPT_CLIENT_ID = CLIENT_ID;
|
|
11
|
+
export const CHATGPT_TOKEN_URL = TOKEN_URL;
|
|
8
12
|
const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
|
|
9
13
|
const CALLBACK_PORT = 1455;
|
|
10
14
|
const CALLBACK_PATH = "/auth/callback";
|
|
@@ -46,9 +50,17 @@ export function extractEmail(idToken?: string, accessToken?: string): string | u
|
|
|
46
50
|
return undefined;
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
53
|
+
export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
50
54
|
const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
|
|
51
|
-
|
|
55
|
+
// This parses a response from an external boundary, so the access token is
|
|
56
|
+
// validated rather than cast. A 200 carrying no access_token would otherwise
|
|
57
|
+
// resolve a login as successful with an undefined credential, which then gets
|
|
58
|
+
// silently declined at persistence — a success message and no account.
|
|
59
|
+
const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
|
|
60
|
+
? data.access_token
|
|
61
|
+
: undefined;
|
|
62
|
+
if (!accessToken) throw new Error("ChatGPT token response missing access token");
|
|
63
|
+
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
|
|
52
64
|
// ?? only guards null/undefined; NaN or a string expires_in would otherwise
|
|
53
65
|
// produce a NaN expiry that never compares as expired, and a negative duration
|
|
54
66
|
// would stamp an already-past expiry — both block refresh semantics.
|
|
@@ -62,7 +74,7 @@ function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
|
62
74
|
const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
|
|
63
75
|
return {
|
|
64
76
|
access: accessToken,
|
|
65
|
-
refresh:
|
|
77
|
+
refresh: refreshToken,
|
|
66
78
|
expires,
|
|
67
79
|
accountId: extractAccountId(idToken, accessToken),
|
|
68
80
|
email: extractEmail(idToken, accessToken),
|
|
@@ -135,7 +147,22 @@ function safeErrorDescription(resp: Response): Promise<string> {
|
|
|
135
147
|
});
|
|
136
148
|
}
|
|
137
149
|
|
|
138
|
-
|
|
150
|
+
/**
|
|
151
|
+
* How the user proves identity. `browser` runs the localhost:1455 callback flow;
|
|
152
|
+
* `device` runs the deviceauth grant, which needs no local browser or listener
|
|
153
|
+
* and is the only workable path on a headless or remote hub (#3366).
|
|
154
|
+
*/
|
|
155
|
+
export type ChatGPTLoginFlow = "browser" | "device";
|
|
156
|
+
|
|
157
|
+
export async function loginChatGPT(
|
|
158
|
+
ctrl: OAuthController,
|
|
159
|
+
opts?: { forceLogin?: boolean; flow?: ChatGPTLoginFlow },
|
|
160
|
+
): Promise<OAuthCredentials> {
|
|
161
|
+
if (opts?.flow === "device") {
|
|
162
|
+
// Imported lazily so the callback flow does not pay for a module it never uses.
|
|
163
|
+
const { loginChatGPTDevice } = await import("./chatgpt-device");
|
|
164
|
+
return loginChatGPTDevice(ctrl);
|
|
165
|
+
}
|
|
139
166
|
const flow = new ChatGPTOAuthFlow(ctrl);
|
|
140
167
|
if (opts?.forceLogin) flow.forceLogin = true;
|
|
141
168
|
return flow.login();
|
|
@@ -148,30 +148,51 @@ export function hasFailoverAccountQuorum(providerName: string, now = Date.now())
|
|
|
148
148
|
}
|
|
149
149
|
|
|
150
150
|
/**
|
|
151
|
-
* Whether
|
|
151
|
+
* Whether REACTIVE 429 rotation is active for this provider.
|
|
152
152
|
*
|
|
153
|
-
*
|
|
153
|
+
* Presence is the only rule: two or more eligible stored accounts. The
|
|
154
|
+
* `oauthAccountFailover.enabled` booleans no longer suppress it.
|
|
154
155
|
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
156
|
+
* That is a deliberate narrowing of #2568d. Rotation here runs only after upstream has already
|
|
157
|
+
* refused the request, so the choice the old knob offered was between "retry on the second
|
|
158
|
+
* account you deliberately logged in" and "return a 429 while that account sits idle". The
|
|
159
|
+
* second is a defect, not a preference — and an operator who does not want rotation expresses
|
|
160
|
+
* that by not storing a second account, exactly as they do for `apiKeyPool`.
|
|
160
161
|
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
162
|
+
* The knob is not gone. It still governs {@link isProactivePreferenceEnabled}, which decides
|
|
163
|
+
* whether a HEALTHY request may be steered to a different account before dispatch — a real
|
|
164
|
+
* behavioural choice that remains refusable — and it still carries `strategy` and
|
|
165
|
+
* `autoSwitchThreshold`.
|
|
163
166
|
*/
|
|
164
167
|
export function isGenericOAuthFailoverEnabled(
|
|
165
168
|
config: OcxConfig,
|
|
166
169
|
providerName: string,
|
|
167
170
|
now = Date.now(),
|
|
168
171
|
): boolean {
|
|
172
|
+
const provider = config.providers?.[providerName];
|
|
173
|
+
if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
|
|
174
|
+
return hasFailoverAccountQuorum(providerName, now);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Whether the pre-dispatch account PREFERENCE may run for this provider.
|
|
179
|
+
*
|
|
180
|
+
* Unlike reactive rotation, this moves a request that upstream has not refused, so it stays
|
|
181
|
+
* refusable: an explicit provider value wins over the global default, and a global `false`
|
|
182
|
+
* turns it off only when the provider has no override. A malformed value falls through rather
|
|
183
|
+
* than taking a provider out of service.
|
|
184
|
+
*/
|
|
185
|
+
function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, now: number): boolean {
|
|
169
186
|
const provider = config.providers?.[providerName];
|
|
170
187
|
if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
|
|
171
188
|
const perProvider = provider.oauthAccountFailover?.enabled;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
189
|
+
// Preserve the published narrow-over-broad precedence. A provider-specific true may
|
|
190
|
+
// opt this provider into proactive preference even when the global default is false;
|
|
191
|
+
// a provider-specific false refuses it even when the global setting is true.
|
|
192
|
+
if (typeof perProvider === "boolean") {
|
|
193
|
+
return perProvider && hasFailoverAccountQuorum(providerName, now);
|
|
194
|
+
}
|
|
195
|
+
if (config.oauthAccountFailover?.enabled === false) return false;
|
|
175
196
|
return hasFailoverAccountQuorum(providerName, now);
|
|
176
197
|
}
|
|
177
198
|
|
|
@@ -265,7 +286,9 @@ export function preferredInitialAccount(
|
|
|
265
286
|
providerName: string,
|
|
266
287
|
now = Date.now(),
|
|
267
288
|
): string | null {
|
|
268
|
-
|
|
289
|
+
// The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
|
|
290
|
+
// refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
|
|
291
|
+
if (!isProactivePreferenceEnabled(config, providerName, now)) return null;
|
|
269
292
|
// This runs on the initial resolution of EVERY request, and `loadAuthStore` has no
|
|
270
293
|
// cache: each call chmods the config dir, chmods the secret, reads the whole file and
|
|
271
294
|
// normalizes it (store.ts:136-151). So the store is consulted at most ONCE here, behind
|