@bitkyc08/opencodex 2.86.0 → 2.87.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/gui/dist/assets/App-ZfYbLHkB.js +58 -0
- package/gui/dist/assets/App-wT3mXqFz.css +1 -0
- package/gui/dist/assets/{Tray-DyR9yBee.js → Tray-DCd-A1az.js} +1 -1
- package/gui/dist/assets/index-CfYDMj34.js +94 -0
- package/gui/dist/assets/index-DPeB9HpV.css +1 -0
- package/gui/dist/assets/quota-summary-ChvmKGOk.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +9 -8
- package/src/adapters/anthropic/beta-body-pairs.ts +93 -0
- package/src/adapters/anthropic/passthrough.ts +7 -23
- package/src/adapters/anthropic-progress.ts +84 -0
- package/src/adapters/anthropic.ts +108 -21
- package/src/adapters/base.ts +2 -0
- package/src/adapters/claude-cli/adapter.ts +111 -23
- package/src/adapters/claude-cli/profiles.ts +2 -1
- package/src/adapters/claude-cli/stable-replay.ts +108 -0
- package/src/adapters/claude-cli/usage-admission.ts +230 -0
- package/src/adapters/coding-agent/protocol.ts +1 -1
- package/src/adapters/coding-agent/turn.ts +18 -2
- package/src/adapters/dsh-account.ts +97 -0
- package/src/adapters/google.ts +35 -8
- package/src/adapters/openai-chat/messages.ts +4 -3
- package/src/adapters/openai-chat/passthrough.ts +8 -2
- package/src/adapters/openai-chat/reasoning-replay.ts +52 -0
- package/src/adapters/openai-responses/compaction-search-history.ts +14 -7
- package/src/adapters/openai-responses/passthrough.ts +57 -125
- package/src/adapters/openai-responses/request-strips.ts +25 -3
- package/src/adapters/openai-responses/transport.ts +81 -0
- package/src/adapters/qoder/adapter.ts +4 -2
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/zed.ts +26 -1
- package/src/advisor/activity.ts +55 -0
- package/src/advisor/budget.ts +62 -0
- package/src/advisor/consult.ts +4 -1
- package/src/advisor/context.ts +16 -4
- package/src/advisor/evidence-tool.ts +21 -0
- package/src/advisor/evidence.ts +117 -0
- package/src/advisor/runtime.ts +79 -31
- package/src/advisor/settings.ts +11 -2
- package/src/bridge/errors.ts +19 -1
- package/src/bridge/internal.ts +3 -1
- package/src/bridge/response-json.ts +18 -3
- package/src/bridge/sse.ts +58 -8
- package/src/bridge/terminal-refusal.ts +59 -0
- package/src/claude/desktop-3p-library.ts +32 -7
- package/src/claude/desktop-3p.ts +55 -13
- package/src/claude/desktop-first-party.ts +1 -1
- package/src/claude/desktop-picker-platform.ts +4 -0
- package/src/claude/desktop-picker.ts +15 -8
- package/src/claude/desktop-system-proxy.ts +50 -4
- package/src/claude/inbound-content-options.ts +11 -0
- package/src/claude/inbound.ts +33 -0
- package/src/claude/intercept/picker-ca-startup.ts +2 -1
- package/src/claude/intercept/picker-runtime.ts +6 -5
- package/src/claude/intercept/picker-trust-windows.ts +338 -0
- package/src/claude/intercept/picker-trust.ts +19 -0
- package/src/claude/intercept/port-policy.ts +30 -0
- package/src/claude/intercept/runtime.ts +97 -94
- package/src/claude/outbound.ts +38 -13
- package/src/cli/account-api.ts +1 -0
- package/src/cli/account-policy-dto.ts +6 -0
- package/src/cli/account-policy.ts +34 -5
- package/src/cli/account-preservation.ts +44 -0
- package/src/cli/account.ts +8 -2
- package/src/cli/advisor.ts +17 -3
- package/src/cli/capabilities-accounts.ts +20 -1
- package/src/cli/capabilities-agents-routing.ts +5 -2
- package/src/cli/capabilities-base.ts +38 -10
- package/src/cli/capabilities-observe-system.ts +63 -3
- package/src/cli/capabilities-provider-models.ts +11 -0
- package/src/cli/codex-cli-update.ts +151 -41
- package/src/cli/codex-provider.ts +44 -0
- package/src/cli/combo-input.ts +10 -1
- package/src/cli/combo-result.ts +1 -1
- package/src/cli/combo.ts +2 -1
- package/src/cli/cross-home-owner.ts +17 -14
- package/src/cli/dispatch.ts +25 -4
- package/src/cli/doctor.ts +6 -1
- package/src/cli/ensure-desktop-cold-start.ts +60 -7
- package/src/cli/help.ts +3 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/integration-plan-dto.ts +1 -0
- package/src/cli/log-history.ts +172 -0
- package/src/cli/models-runtime-subcommands.ts +1 -0
- package/src/cli/models-runtime.ts +72 -8
- package/src/cli/observe-stream.ts +2 -0
- package/src/cli/observe.ts +16 -4
- package/src/cli/provider.ts +27 -5
- package/src/cli/registry.ts +28 -8
- package/src/cli/runtime-api.ts +5 -3
- package/src/cli/system-command.ts +40 -8
- package/src/cli/uninstall-client-state.ts +32 -0
- package/src/cli/usage-compare.ts +309 -0
- package/src/cli/usage-model-search.ts +4 -4
- package/src/cli/usage-report.ts +2 -2
- package/src/cli/v2.ts +1 -1
- package/src/client/machine-listener.ts +1 -3
- package/src/client/runtime.ts +1 -0
- package/src/clients/client-path.ts +51 -0
- package/src/clients/config-export/contracts.ts +2 -1
- package/src/clients/config-export.ts +18 -44
- package/src/clients/gentle-shell-path.ts +87 -0
- package/src/codex/account-auto-switch.ts +2 -0
- package/src/codex/account-identity.ts +29 -0
- package/src/codex/account-label.ts +1 -1
- package/src/codex/account-lifecycle.ts +21 -5
- package/src/codex/account-preservation.ts +28 -0
- package/src/codex/account-use-remaining.ts +23 -0
- package/src/codex/app-server-processes.ts +97 -7
- package/src/codex/app-server-restart-service.ts +55 -4
- package/src/codex/auth-api/account-list.ts +4 -0
- package/src/codex/auth-api/account-pause-group.ts +6 -50
- package/src/codex/auth-api/account-preservation.ts +57 -0
- package/src/codex/auth-api/routes.ts +8 -0
- package/src/codex/auth-api/use-remaining-route.ts +36 -0
- package/src/codex/auth-api.ts +0 -1
- package/src/codex/auth-context.ts +2 -2
- package/src/codex/catalog/auto-review.ts +20 -3
- package/src/codex/catalog/build-entries.ts +9 -1
- package/src/codex/catalog/combo-member.ts +14 -5
- package/src/codex/catalog/configured-tiers.ts +1 -1
- package/src/codex/catalog/gather-capture.ts +2 -0
- package/src/codex/catalog/metadata.ts +11 -9
- package/src/codex/catalog/model-hints.ts +21 -16
- package/src/codex/catalog/model-visibility.ts +3 -1
- package/src/codex/catalog/parsing.ts +30 -86
- package/src/codex/catalog/provider-models.ts +36 -34
- package/src/codex/catalog/routed-gather.ts +12 -13
- package/src/codex/cli-install-provenance.ts +105 -17
- package/src/codex/cli-update-plan.ts +545 -0
- package/src/codex/config-write-lock.ts +23 -2
- package/src/codex/desktop-app/picker-freshness.ts +96 -0
- package/src/codex/desktop-app-restart.ts +21 -0
- package/src/codex/desktop-compatibility/installed-build.ts +24 -6
- package/src/codex/desktop-compatibility/routing-preflight.ts +1 -1
- package/src/codex/desktop-compatibility/runtime.ts +12 -3
- package/src/codex/desktop-compatibility/windows-key-protection.ts +115 -9
- package/src/codex/features.ts +85 -45
- package/src/codex/inject/history-failure.ts +27 -0
- package/src/codex/inject/restore.ts +24 -2
- package/src/codex/inject/routing-classify.ts +4 -3
- package/src/codex/inject.ts +18 -27
- package/src/codex/main-account-hard-lock.ts +30 -1
- package/src/codex/native-profile-startup.ts +1 -1
- package/src/codex/ocx-compaction-history.ts +12 -10
- package/src/codex/paths.ts +86 -1
- package/src/codex/provider-routing.ts +174 -0
- package/src/codex/refresh.ts +2 -2
- package/src/codex/routing/health-store.ts +1 -1
- package/src/codex/routing/selection.ts +4 -3
- package/src/codex/routing/thread-affinity.ts +1 -1
- package/src/codex/signed-in-identity.ts +43 -0
- package/src/codex/subagent-model-fallback.ts +1 -1
- package/src/codex/sync.ts +6 -1
- package/src/codex/user-identity.ts +16 -5
- package/src/codex/windows-local-appdata.ts +84 -0
- package/src/combos/failover.ts +6 -1
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +46 -10
- package/src/combos/types.ts +15 -0
- package/src/config/diagnostics.ts +33 -6
- package/src/config/live-reconcile.ts +38 -2
- package/src/config/load-degrade.ts +17 -0
- package/src/config/opencode-wire-upgrade.ts +54 -0
- package/src/config/opencode-zen-upgrade.ts +52 -0
- package/src/config/owner-registry.ts +22 -17
- package/src/config/provider-validation.ts +45 -2
- package/src/config/schema/config-schema.ts +22 -0
- package/src/config/schema/leaf-validators.ts +38 -9
- package/src/config/schema/main-account-hard-lock-fallback.ts +26 -0
- package/src/config/schema/native-reasoning-retention.ts +26 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +608 -284
- package/src/grok/sync.ts +1 -1
- package/src/images/loop.ts +46 -8
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/mutation-plan.ts +1 -0
- package/src/integrations/registry.ts +8 -0
- package/src/lib/codex-restart-contract.ts +15 -0
- package/src/lib/desktop-upstream-tunnel.ts +18 -53
- package/src/lib/empty-tool-catalog.ts +62 -0
- package/src/lib/errors.ts +19 -3
- package/src/lib/gcp-adc.ts +96 -9
- package/src/lib/gcp-credential-input.ts +12 -0
- package/src/lib/lab-live-route-production.ts +5 -0
- package/src/lib/opaque-proxy-tunnel.ts +217 -0
- package/src/lib/redact.ts +2 -2
- package/src/oauth/account-quota-rank.ts +69 -2
- package/src/oauth/anthropic-account-refusal.ts +20 -2
- package/src/oauth/anthropic-oauth-definitions.ts +3 -1
- package/src/oauth/anthropic-routing.ts +1 -21
- package/src/oauth/anthropic.ts +38 -7
- package/src/oauth/callback-server.ts +27 -1
- package/src/oauth/chatgpt-device.ts +1 -1
- package/src/oauth/chatgpt-shared.ts +83 -0
- package/src/oauth/chatgpt.ts +8 -72
- package/src/oauth/dsh.ts +203 -0
- package/src/oauth/generic-account-failover.ts +33 -7
- package/src/oauth/generic-session-affinity.ts +125 -0
- package/src/oauth/index.ts +184 -109
- package/src/oauth/local-token-detect.ts +21 -1
- package/src/oauth/login-cli-errors.ts +44 -0
- package/src/oauth/login-cli.ts +69 -7
- package/src/oauth/login-flow-state.ts +371 -23
- package/src/oauth/meta-muse-errors.ts +41 -0
- package/src/oauth/meta-muse-keychain.ts +52 -0
- package/src/oauth/meta-muse.ts +51 -84
- package/src/oauth/model-discovery-auth.ts +3 -0
- package/src/oauth/pool-settings-capability.ts +4 -2
- package/src/oauth/refresh-flight-retirement.ts +48 -0
- package/src/oauth/session-affinity-key.ts +23 -0
- package/src/oauth/store-anthropic-instance.ts +9 -2
- package/src/oauth/store.ts +12 -5
- package/src/protocols/encoders/adapter-events.ts +5 -4
- package/src/protocols/encoders/messages.ts +9 -6
- package/src/protocols/provider-summary.ts +2 -1
- package/src/providers/api-key-cooldowns.ts +49 -0
- package/src/providers/api-key-resolve.ts +11 -6
- package/src/providers/api-key-response-ownership.ts +57 -0
- package/src/providers/api-key-selection.ts +31 -6
- package/src/providers/api-keys.ts +214 -3
- package/src/providers/claude-cli-usage.ts +96 -0
- package/src/providers/dsh-account-destination.ts +31 -0
- package/src/providers/dsh-account-models.ts +41 -0
- package/src/providers/gcp-credential-cleanup.ts +59 -0
- package/src/providers/key-failover.ts +54 -50
- package/src/providers/key-store.ts +7 -2
- package/src/providers/model-rename-fields.ts +3 -0
- package/src/providers/model-rename-migration.ts +2 -2
- package/src/providers/openai-tiers-destination.ts +11 -2
- package/src/providers/openai-tiers.ts +5 -12
- package/src/providers/opencode-go-transport.ts +3 -1
- package/src/providers/quota/account-cache.ts +6 -5
- package/src/providers/quota/account-row.ts +34 -0
- package/src/providers/quota/antigravity.ts +28 -7
- package/src/providers/quota/dsh-account.ts +70 -0
- package/src/providers/quota/vendor-probes-oauth.ts +1 -93
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +14 -20
- package/src/providers/registry/entries-core.ts +37 -8
- package/src/providers/registry/types.ts +1 -1
- package/src/providers/request-pacing.ts +77 -19
- package/src/providers/routed-config.ts +339 -0
- package/src/providers/service-tier.ts +2 -1
- package/src/quota/window-mapping.ts +5 -2
- package/src/responses/anthropic-progress.ts +34 -0
- package/src/responses/assistant-replay.ts +35 -0
- package/src/responses/compaction-history-framing.ts +65 -0
- package/src/responses/compaction.ts +6 -1
- package/src/responses/incomplete-reason.ts +31 -0
- package/src/responses/parser.ts +50 -5
- package/src/responses/reasoning-envelope.ts +4 -0
- package/src/responses/reasoning-replay-cache.ts +24 -2
- package/src/responses/reasoning-retention.ts +173 -0
- package/src/responses/retained-compaction.ts +46 -0
- package/src/responses/schema.ts +23 -0
- package/src/router.ts +8 -321
- package/src/routing/health.ts +37 -22
- package/src/routing/history/indexer.ts +6 -5
- package/src/routing/history/ingest.ts +6 -2
- package/src/routing/profile.ts +1 -1
- package/src/server/admission-model-scope.ts +17 -0
- package/src/server/auth-cors.ts +11 -0
- package/src/server/chat-completions.ts +7 -1
- package/src/server/chat-native-reasoning.ts +123 -0
- package/src/server/chat-native-sse.ts +110 -7
- package/src/server/chat-native.ts +76 -17
- package/src/server/claude-messages.ts +55 -414
- package/src/server/effort-policy.ts +1 -1
- package/src/server/images.ts +15 -2
- package/src/server/index/model-alias-id.ts +36 -0
- package/src/server/index/optional-listeners.ts +2 -0
- package/src/server/index/serve-options.ts +9 -1
- package/src/server/index/startup-warnings.ts +2 -0
- package/src/server/index.ts +1 -1
- package/src/server/inference/advisor-diagnostics.ts +31 -0
- package/src/server/management/advisor-routes.ts +15 -4
- package/src/server/management/claude-desktop-picker-routes.ts +1 -1
- package/src/server/management/combo-routes.ts +176 -50
- package/src/server/management/companion-routes.ts +1 -1
- package/src/server/management/config-routes.ts +75 -4
- package/src/server/management/cursor-integration-routes.ts +1 -1
- package/src/server/management/model-routes.ts +11 -6
- package/src/server/management/model-rows.ts +14 -0
- package/src/server/management/native-integration-routes.ts +2 -7
- package/src/server/management/native-reasoning-retention-routes.ts +41 -0
- package/src/server/management/oauth-account-routes.ts +87 -14
- package/src/server/management/provider-gcp-credential-routes.ts +29 -0
- package/src/server/management/provider-key-pacing-routes.ts +31 -0
- package/src/server/management/provider-overwrite-carry.ts +7 -0
- package/src/server/management/provider-routes.ts +25 -30
- package/src/server/management/route-registry.ts +10 -1
- package/src/server/management-api.ts +3 -1
- package/src/server/messages-native-tool-guard.ts +185 -0
- package/src/server/messages-native.ts +101 -21
- package/src/server/messages-shared.ts +431 -0
- package/src/server/relay.ts +1 -1
- package/src/server/request-log-terminal-status.ts +1 -1
- package/src/server/request-log.ts +6 -1
- package/src/server/responses/account-change-state.ts +8 -2
- package/src/server/responses/adapter-continuation.ts +13 -1
- package/src/server/responses/adapter-delivery.ts +19 -6
- package/src/server/responses/adapter-dispatch.ts +19 -2
- package/src/server/responses/advisor-retention.ts +117 -0
- package/src/server/responses/advisor-slot.ts +73 -41
- package/src/server/responses/codex-ws-wire.ts +15 -8
- package/src/server/responses/compact.ts +303 -125
- package/src/server/responses/compaction-recovery.ts +18 -10
- package/src/server/responses/compaction-retention.ts +61 -0
- package/src/server/responses/context-overflow.ts +11 -6
- package/src/server/responses/core-auth.ts +6 -0
- package/src/server/responses/core-codex-account.ts +11 -1
- package/src/server/responses/core-combo-failure.ts +2 -0
- package/src/server/responses/core-combo.ts +120 -36
- package/src/server/responses/core-errors.ts +3 -3
- package/src/server/responses/core-lifetime.ts +5 -2
- package/src/server/responses/core-opaque-recovery.ts +35 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-pipeline.ts +141 -0
- package/src/server/responses/core-replay.ts +18 -2
- package/src/server/responses/core.ts +2 -115
- package/src/server/responses/fetch-helpers.ts +73 -19
- package/src/server/responses/main-hard-lock-routing.ts +109 -0
- package/src/server/responses/native-response-control.ts +4 -2
- package/src/server/responses/passthrough-delivery.ts +3 -1
- package/src/server/responses/passthrough-dispatch.ts +15 -13
- package/src/server/responses/request-prepare.ts +76 -12
- package/src/server/responses/request-sidecar-auth.ts +2 -2
- package/src/server/responses/request-transport.ts +104 -27
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/run-turn-execution.ts +96 -30
- package/src/server/responses/sidecar-execution.ts +20 -2
- package/src/server/responses/terminal-guard.ts +2 -16
- package/src/service/auth-preflight.ts +71 -0
- package/src/service/cli.ts +4 -2
- package/src/service/definition.ts +97 -0
- package/src/service/guards.ts +1 -61
- package/src/service/health-sentinel-linux.ts +112 -0
- package/src/service/health-sentinel-main.ts +76 -57
- package/src/service/health.ts +5 -89
- package/src/service/launchd.ts +27 -7
- package/src/service/logging.ts +72 -9
- package/src/service/repair.ts +2 -1
- package/src/service/state.ts +7 -8
- package/src/service/systemd.ts +1 -1
- package/src/service/windows-guarded-manager.ts +2 -1
- package/src/service/windows-ops.ts +1 -1
- package/src/service/windows-scheduler.ts +2 -7
- package/src/service/windows-taskxml.ts +2 -2
- package/src/service/windows-wscript.ts +14 -0
- package/src/service.ts +4 -2
- package/src/sidecar/auth.ts +2 -0
- package/src/types/config.ts +64 -3
- package/src/types/opencode-go.ts +61 -0
- package/src/types/opencode-zen.ts +29 -0
- package/src/types/provider.ts +32 -6
- package/src/types/request.ts +23 -2
- package/src/types/wire.ts +29 -37
- package/src/types.ts +1 -0
- package/src/update/npm-invocation.mjs +12 -2
- package/src/usage/cost.ts +48 -12
- package/src/usage/expected-prices.ts +3 -0
- package/src/usage/ledger-retention.ts +2 -1
- package/src/usage/ledger-scanner.ts +6 -5
- package/src/usage/log.ts +17 -8
- package/src/usage/summary.ts +22 -9
- package/src/usage/user-cost-overlays.ts +14 -19
- package/src/usage/user-cost-validation.ts +38 -0
- package/src/vision/eligibility.ts +13 -0
- package/src/web-search/loop.ts +55 -5
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -1
- package/gui/dist/assets/App-BjlttVLy.js +0 -58
- package/gui/dist/assets/App-CSJvmjNs.css +0 -1
- package/gui/dist/assets/index-BB6uK6tU.css +0 -1
- package/gui/dist/assets/index-BMssP4k3.js +0 -94
- package/gui/dist/assets/quota-summary-D8M1bc_R.js +0 -1
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { OAuthCredentials } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the browser callback grant and the deviceauth grant share: the public PKCE client and the
|
|
5
|
+
* way a token response becomes credentials.
|
|
6
|
+
*
|
|
7
|
+
* This is a leaf on purpose. `./chatgpt-device` needs these, and `./chatgpt` needs the device
|
|
8
|
+
* grant for `flow: "device"`; importing them straight from `./chatgpt` made the pair a cycle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
12
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
13
|
+
|
|
14
|
+
/** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
|
|
15
|
+
export const CHATGPT_CLIENT_ID = CLIENT_ID;
|
|
16
|
+
export const CHATGPT_TOKEN_URL = TOKEN_URL;
|
|
17
|
+
|
|
18
|
+
export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
19
|
+
const parts = token.split(".");
|
|
20
|
+
if (parts.length !== 3 || !parts[1]) return undefined;
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function extractAccountId(idToken?: string, accessToken?: string): string | undefined {
|
|
29
|
+
for (const token of [idToken, accessToken]) {
|
|
30
|
+
if (!token) continue;
|
|
31
|
+
const payload = decodeJwtPayload(token);
|
|
32
|
+
if (!payload) continue;
|
|
33
|
+
if (typeof payload.chatgpt_account_id === "string") return payload.chatgpt_account_id;
|
|
34
|
+
const ns = payload["https://api.openai.com/auth"];
|
|
35
|
+
if (ns && typeof ns === "object" && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string") {
|
|
36
|
+
return (ns as Record<string, unknown>).chatgpt_account_id as string;
|
|
37
|
+
}
|
|
38
|
+
const orgs = payload.organizations;
|
|
39
|
+
if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string") return orgs[0].id as string;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
|
|
45
|
+
for (const token of [idToken, accessToken]) {
|
|
46
|
+
if (!token) continue;
|
|
47
|
+
const payload = decodeJwtPayload(token);
|
|
48
|
+
if (!payload) continue;
|
|
49
|
+
if (typeof payload.email === "string") return payload.email.toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
55
|
+
const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
|
|
56
|
+
// This parses a response from an external boundary, so the access token is
|
|
57
|
+
// validated rather than cast. A 200 carrying no access_token would otherwise
|
|
58
|
+
// resolve a login as successful with an undefined credential, which then gets
|
|
59
|
+
// silently declined at persistence — a success message and no account.
|
|
60
|
+
const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
|
|
61
|
+
? data.access_token
|
|
62
|
+
: undefined;
|
|
63
|
+
if (!accessToken) throw new Error("ChatGPT token response missing access token");
|
|
64
|
+
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
|
|
65
|
+
// ?? only guards null/undefined; NaN or a string expires_in would otherwise
|
|
66
|
+
// produce a NaN expiry that never compares as expired, and a negative duration
|
|
67
|
+
// would stamp an already-past expiry — both block refresh semantics.
|
|
68
|
+
const expiresIn =
|
|
69
|
+
typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0
|
|
70
|
+
? data.expires_in
|
|
71
|
+
: 3600;
|
|
72
|
+
// The computed timestamp itself must stay finite: Number.MAX_VALUE passes
|
|
73
|
+
// Number.isFinite but overflows to Infinity once multiplied by 1000.
|
|
74
|
+
const computedExpires = Date.now() + expiresIn * 1000;
|
|
75
|
+
const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
|
|
76
|
+
return {
|
|
77
|
+
access: accessToken,
|
|
78
|
+
refresh: refreshToken,
|
|
79
|
+
expires,
|
|
80
|
+
accountId: extractAccountId(idToken, accessToken),
|
|
81
|
+
email: extractEmail(idToken, accessToken),
|
|
82
|
+
};
|
|
83
|
+
}
|
package/src/oauth/chatgpt.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OAuthCallbackFlow } from "./callback-server";
|
|
2
|
+
import { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken, decodeJwtPayload, extractAccountId, extractEmail } from "./chatgpt-shared";
|
|
2
3
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
3
4
|
import { generatePKCE } from "./pkce";
|
|
4
5
|
import { classifyChatgptRefreshFailure } from "../codex/chatgpt-refresh-failure";
|
|
@@ -102,44 +103,20 @@ function chatGptTokenError(status: number, body: string, label: string): ChatGpt
|
|
|
102
103
|
);
|
|
103
104
|
}
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
/**
|
|
107
|
+
* The client and token decoding live in a leaf shared with the device grant.
|
|
108
|
+
* Re-exported so existing import sites keep their public helper surface.
|
|
109
|
+
*/
|
|
110
|
+
export { CHATGPT_CLIENT_ID, CHATGPT_TOKEN_URL, credsFromToken, decodeJwtPayload, extractAccountId, extractEmail };
|
|
111
|
+
const CLIENT_ID = CHATGPT_CLIENT_ID;
|
|
106
112
|
const AUTH_URL = "https://auth.openai.com/oauth/authorize";
|
|
107
|
-
const TOKEN_URL =
|
|
113
|
+
const TOKEN_URL = CHATGPT_TOKEN_URL;
|
|
108
114
|
|
|
109
|
-
/** Shared with the deviceauth grant in `./chatgpt-device`: same public PKCE client. */
|
|
110
|
-
export const CHATGPT_CLIENT_ID = CLIENT_ID;
|
|
111
|
-
export const CHATGPT_TOKEN_URL = TOKEN_URL;
|
|
112
115
|
const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
|
|
113
116
|
const CALLBACK_PORT = 1455;
|
|
114
117
|
const CALLBACK_PATH = "/auth/callback";
|
|
115
118
|
const ORIGINATOR = "opencodex";
|
|
116
119
|
|
|
117
|
-
export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
118
|
-
const parts = token.split(".");
|
|
119
|
-
if (parts.length !== 3 || !parts[1]) return undefined;
|
|
120
|
-
try {
|
|
121
|
-
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
|
|
122
|
-
} catch {
|
|
123
|
-
return undefined;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function extractAccountId(idToken?: string, accessToken?: string): string | undefined {
|
|
128
|
-
for (const token of [idToken, accessToken]) {
|
|
129
|
-
if (!token) continue;
|
|
130
|
-
const payload = decodeJwtPayload(token);
|
|
131
|
-
if (!payload) continue;
|
|
132
|
-
if (typeof payload.chatgpt_account_id === "string") return payload.chatgpt_account_id;
|
|
133
|
-
const ns = payload["https://api.openai.com/auth"];
|
|
134
|
-
if (ns && typeof ns === "object" && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string") {
|
|
135
|
-
return (ns as Record<string, unknown>).chatgpt_account_id as string;
|
|
136
|
-
}
|
|
137
|
-
const orgs = payload.organizations;
|
|
138
|
-
if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string") return orgs[0].id as string;
|
|
139
|
-
}
|
|
140
|
-
return undefined;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
120
|
/**
|
|
144
121
|
* Three-way answer to "is this token marked as belonging to the ChatGPT account domain".
|
|
145
122
|
* Only ChatGPT-specific claims count as markers: a top-level chatgpt_account_id or the
|
|
@@ -196,16 +173,6 @@ export function inspectChatGptDomainClaim(token: string): ChatGptDomainClaim {
|
|
|
196
173
|
return accountId ? { kind: "valid", accountId } : { kind: "invalid" };
|
|
197
174
|
}
|
|
198
175
|
|
|
199
|
-
export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
|
|
200
|
-
for (const token of [idToken, accessToken]) {
|
|
201
|
-
if (!token) continue;
|
|
202
|
-
const payload = decodeJwtPayload(token);
|
|
203
|
-
if (!payload) continue;
|
|
204
|
-
if (typeof payload.email === "string") return payload.email.toLowerCase();
|
|
205
|
-
}
|
|
206
|
-
return undefined;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
176
|
/**
|
|
210
177
|
* Identity-agreement view of one token for security-sensitive bindings. `accountId` follows the
|
|
211
178
|
* existing extractAccountId precedence (top-level, then namespaced, then organizations[0]).
|
|
@@ -233,37 +200,6 @@ export function extractAccountIdClaims(token?: string): { accountId: string | un
|
|
|
233
200
|
};
|
|
234
201
|
}
|
|
235
202
|
|
|
236
|
-
export function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
237
|
-
const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
|
|
238
|
-
// This parses a response from an external boundary, so the access token is
|
|
239
|
-
// validated rather than cast. A 200 carrying no access_token would otherwise
|
|
240
|
-
// resolve a login as successful with an undefined credential, which then gets
|
|
241
|
-
// silently declined at persistence — a success message and no account.
|
|
242
|
-
const accessToken = typeof data.access_token === "string" && data.access_token.length > 0
|
|
243
|
-
? data.access_token
|
|
244
|
-
: undefined;
|
|
245
|
-
if (!accessToken) throw new Error("ChatGPT token response missing access token");
|
|
246
|
-
const refreshToken = typeof data.refresh_token === "string" ? data.refresh_token : "";
|
|
247
|
-
// ?? only guards null/undefined; NaN or a string expires_in would otherwise
|
|
248
|
-
// produce a NaN expiry that never compares as expired, and a negative duration
|
|
249
|
-
// would stamp an already-past expiry — both block refresh semantics.
|
|
250
|
-
const expiresIn =
|
|
251
|
-
typeof data.expires_in === "number" && Number.isFinite(data.expires_in) && data.expires_in >= 0
|
|
252
|
-
? data.expires_in
|
|
253
|
-
: 3600;
|
|
254
|
-
// The computed timestamp itself must stay finite: Number.MAX_VALUE passes
|
|
255
|
-
// Number.isFinite but overflows to Infinity once multiplied by 1000.
|
|
256
|
-
const computedExpires = Date.now() + expiresIn * 1000;
|
|
257
|
-
const expires = Number.isFinite(computedExpires) ? computedExpires : Date.now() + 3600 * 1000;
|
|
258
|
-
return {
|
|
259
|
-
access: accessToken,
|
|
260
|
-
refresh: refreshToken,
|
|
261
|
-
expires,
|
|
262
|
-
accountId: extractAccountId(idToken, accessToken),
|
|
263
|
-
email: extractEmail(idToken, accessToken),
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
|
-
|
|
267
203
|
export class ChatGPTOAuthFlow extends OAuthCallbackFlow {
|
|
268
204
|
#verifier = "";
|
|
269
205
|
forceLogin = false;
|
package/src/oauth/dsh.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness Account OAuth implementation (Import-first).
|
|
3
|
+
*
|
|
4
|
+
* Imports existing authenticated account from DeepSeek Harness Desktop
|
|
5
|
+
* (`$DSH_HOME/.credentials.yaml`, default `~/.dsh/.credentials.yaml`).
|
|
6
|
+
*
|
|
7
|
+
* Invariant: The DSH credential store is strictly READ-ONLY to OpenCodeX.
|
|
8
|
+
* OpenCodeX NEVER mutates, overwrites, deletes, or changes permissions of `~/.dsh/.credentials.yaml`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
export const DSH_PLATFORM_ORIGIN = "https://platform.deepseek.com";
|
|
17
|
+
export const DSH_INFERENCE_ORIGIN = "https://api.deepseek.com/anthropic";
|
|
18
|
+
|
|
19
|
+
export interface DshAccountDetectionResult {
|
|
20
|
+
detected: boolean;
|
|
21
|
+
token?: string;
|
|
22
|
+
issuer?: string;
|
|
23
|
+
error?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface DshValidatedAccountIdentity {
|
|
27
|
+
valid: boolean;
|
|
28
|
+
accountId?: string;
|
|
29
|
+
email?: string;
|
|
30
|
+
name?: string;
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveDshCredentialsPath(): string {
|
|
35
|
+
const dshHome = process.env.DSH_HOME?.trim() || join(homedir(), ".dsh");
|
|
36
|
+
return join(dshHome, ".credentials.yaml");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pure read-only probe to detect whether DeepSeek Harness Desktop is installed
|
|
41
|
+
* and signed in on this machine.
|
|
42
|
+
* NEVER writes, creates, mutates, or deletes any file.
|
|
43
|
+
*/
|
|
44
|
+
export function detectDshAccount(): DshAccountDetectionResult {
|
|
45
|
+
const filePath = resolveDshCredentialsPath();
|
|
46
|
+
if (!existsSync(filePath)) {
|
|
47
|
+
return { detected: false };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const content = readFileSync(filePath, "utf8");
|
|
51
|
+
const data = Bun.YAML.parse(content) as Record<string, unknown> | null;
|
|
52
|
+
if (!data || typeof data !== "object") {
|
|
53
|
+
return { detected: false };
|
|
54
|
+
}
|
|
55
|
+
const records = data.records as Record<string, unknown> | undefined;
|
|
56
|
+
const accountRecord = records?.["deepseek-account-platform/default"] as {
|
|
57
|
+
kind?: string;
|
|
58
|
+
payload?: { version?: number; token?: string; issuer?: string };
|
|
59
|
+
} | undefined;
|
|
60
|
+
|
|
61
|
+
if (accountRecord?.kind === "grant" && typeof accountRecord.payload?.token === "string" && accountRecord.payload.token.length > 0) {
|
|
62
|
+
return {
|
|
63
|
+
detected: true,
|
|
64
|
+
token: accountRecord.payload.token,
|
|
65
|
+
issuer: accountRecord.payload.issuer ?? DSH_PLATFORM_ORIGIN,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { detected: false };
|
|
69
|
+
} catch {
|
|
70
|
+
return { detected: false, error: "CREDENTIAL_FILE_READ_FAILED" };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Validate token against DeepSeek's authoritative profile endpoint:
|
|
76
|
+
* GET https://platform.deepseek.com/auth-api/v0/users/current
|
|
77
|
+
*/
|
|
78
|
+
export async function validateDshAccountToken(
|
|
79
|
+
token: string,
|
|
80
|
+
signal?: AbortSignal,
|
|
81
|
+
): Promise<DshValidatedAccountIdentity> {
|
|
82
|
+
try {
|
|
83
|
+
const timeoutSignal = AbortSignal.timeout(10_000);
|
|
84
|
+
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
85
|
+
const response = await fetch(`${DSH_PLATFORM_ORIGIN}/auth-api/v0/users/current`, {
|
|
86
|
+
method: "GET",
|
|
87
|
+
headers: {
|
|
88
|
+
"x-dsh-auth-token": token,
|
|
89
|
+
"accept": "application/json",
|
|
90
|
+
},
|
|
91
|
+
redirect: "manual",
|
|
92
|
+
signal: combinedSignal,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
if (response.status === 401) {
|
|
96
|
+
return { valid: false, error: "ACCOUNT_TOKEN_INVALID" };
|
|
97
|
+
}
|
|
98
|
+
if (!response.ok) {
|
|
99
|
+
return { valid: false, error: `Platform returned HTTP ${response.status}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const body = (await response.json()) as {
|
|
103
|
+
code?: number;
|
|
104
|
+
data?: {
|
|
105
|
+
biz_code?: number;
|
|
106
|
+
biz_data?: {
|
|
107
|
+
id?: string | null;
|
|
108
|
+
email?: string;
|
|
109
|
+
id_profile?: { name?: string | null; picture?: string | null };
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
if (body.code !== 0 || body.data?.biz_code !== 0 || !body.data.biz_data) {
|
|
115
|
+
return { valid: false, error: `Platform error code: ${body.code ?? "unknown"}` };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const biz = body.data.biz_data;
|
|
119
|
+
// Account-store slot matching and reauthentication require the same stable upstream ID.
|
|
120
|
+
// Email/phone/display names and a shared fallback must never identify an imported account.
|
|
121
|
+
const accountId = typeof biz.id === "string" ? biz.id.trim() : "";
|
|
122
|
+
if (!accountId) return { valid: false, error: "ACCOUNT_IDENTITY_UNVERIFIED" };
|
|
123
|
+
const email = typeof biz.email === "string" ? biz.email.trim() || undefined : undefined;
|
|
124
|
+
const name = typeof biz.id_profile?.name === "string" ? biz.id_profile.name.trim() || undefined : undefined;
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
valid: true,
|
|
128
|
+
accountId,
|
|
129
|
+
email,
|
|
130
|
+
name,
|
|
131
|
+
};
|
|
132
|
+
} catch {
|
|
133
|
+
if (signal?.aborted) throw signal.reason ?? new DOMException("DSH login aborted", "AbortError");
|
|
134
|
+
// Parser and transport errors may contain profile data or credential-file contents.
|
|
135
|
+
return { valid: false, error: "PROFILE_VALIDATION_FAILED" };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface DshLoginOptions {
|
|
140
|
+
forceLogin?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Explicit user login action for DSH Account.
|
|
145
|
+
* Reuses the existing DSH local credential after live authoritative validation.
|
|
146
|
+
*/
|
|
147
|
+
export async function loginDshAccount(
|
|
148
|
+
ctrl: OAuthController,
|
|
149
|
+
_opts: DshLoginOptions = {},
|
|
150
|
+
): Promise<OAuthCredentials> {
|
|
151
|
+
if (ctrl.signal?.aborted) {
|
|
152
|
+
throw ctrl.signal.reason ?? new DOMException("DSH login aborted", "AbortError");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const detection = detectDshAccount();
|
|
156
|
+
if (!detection.detected || !detection.token) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
"DeepSeek Harness account not found in ~/.dsh/.credentials.yaml. " +
|
|
159
|
+
"Please sign in to DeepSeek Harness Desktop first, then import your account.",
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const validation = await validateDshAccountToken(detection.token, ctrl.signal);
|
|
164
|
+
if (!validation.valid || !validation.accountId) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`DeepSeek Harness account token in ~/.dsh/.credentials.yaml is invalid or expired (${validation.error ?? "unknown"}). ` +
|
|
167
|
+
"Please re-login in DeepSeek Harness Desktop, then try again.",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
access: detection.token,
|
|
173
|
+
refresh: detection.token,
|
|
174
|
+
expires: Number.MAX_SAFE_INTEGER,
|
|
175
|
+
accountId: validation.accountId,
|
|
176
|
+
email: validation.email,
|
|
177
|
+
source: "credential-file",
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Token refresh for DSH Account.
|
|
183
|
+
* DSH accounts use long-lived session grant tokens without an automatic rotation endpoint.
|
|
184
|
+
* In accordance with the explicit-import contract, background or automatic refresh MUST NOT
|
|
185
|
+
* automatically re-read or adopt new credentials from ~/.dsh/.credentials.yaml.
|
|
186
|
+
* If the grant is rejected or expires, this throws a terminal error so the account transitions
|
|
187
|
+
* to needsReauth, requiring explicit user re-import.
|
|
188
|
+
*/
|
|
189
|
+
export async function refreshDshAccountToken(
|
|
190
|
+
_refreshToken: string,
|
|
191
|
+
_signal?: AbortSignal,
|
|
192
|
+
_credential?: OAuthCredentials,
|
|
193
|
+
_accountId?: string,
|
|
194
|
+
): Promise<OAuthCredentials> {
|
|
195
|
+
throw new Error("DeepSeek Harness account session expired or revoked. Please sign in to DeepSeek Harness Desktop and re-import.");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Provider-owned auth hooks keep the shared OAuth registry within its line budget. */
|
|
199
|
+
export const DSH_ACCOUNT_AUTH = {
|
|
200
|
+
login: loginDshAccount,
|
|
201
|
+
refresh: refreshDshAccountToken,
|
|
202
|
+
defaultRefreshPolicy: "disabled" as const,
|
|
203
|
+
};
|
|
@@ -7,9 +7,9 @@ import { isBuiltinAnthropicInstanceRow } from "../providers/anthropic-instance";
|
|
|
7
7
|
* exists is Anthropic's — behind its own opt-in. So xAI, Cursor, Kimi, GitHub Copilot,
|
|
8
8
|
* Antigravity and Nous have no recovery path on a 429 even with several accounts logged in.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Conversation ownership lives in generic-session-affinity.ts; this module owns account
|
|
11
|
+
* eligibility, quota-ranked preference and bounded refusal recovery. Probe leases remain
|
|
12
|
+
* provider-specific.
|
|
13
13
|
*
|
|
14
14
|
* NOT a home for Codex (`codex/routing.ts` owns quota scopes and probe leases) or Anthropic
|
|
15
15
|
* (`oauth/anthropic-routing.ts` owns affinity and a fail-closed local-cli credential rule).
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
hasHeadroomEvidence,
|
|
26
26
|
isAccountQuotaExhausted,
|
|
27
27
|
rankAccountsByHeadroom,
|
|
28
|
+
rankAccountsByResetFirst,
|
|
28
29
|
classifyModelFamilyForQuota,
|
|
29
30
|
type QuotaModelFamily,
|
|
30
31
|
} from "./account-quota-rank";
|
|
@@ -351,7 +352,7 @@ export function rotateAntigravityAccountOnAuthRefusal(
|
|
|
351
352
|
}
|
|
352
353
|
|
|
353
354
|
/** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */
|
|
354
|
-
type ActiveGenericStrategy = "round-robin" | "fill-first" | "least-loaded";
|
|
355
|
+
type ActiveGenericStrategy = "round-robin" | "fill-first" | "reset-first" | "least-loaded";
|
|
355
356
|
|
|
356
357
|
/** Matches the Codex and Anthropic pools; the DTO still reports `null` for "not stored". */
|
|
357
358
|
const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80;
|
|
@@ -365,10 +366,13 @@ const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80;
|
|
|
365
366
|
* branch instead of a four-way one.
|
|
366
367
|
*/
|
|
367
368
|
function activeGenericStrategy(config: OcxConfig, providerName: string): ActiveGenericStrategy | null {
|
|
369
|
+
// All pool strategies run behind the kernel flag; `quota` is the pre-kernel path.
|
|
370
|
+
// Letting `reset-first` bypass it would change 429 selection for pools that never
|
|
371
|
+
// opted into kernel-gated strategies.
|
|
368
372
|
if (config.pool?.kernel !== true) return null;
|
|
369
373
|
const raw = config.providers?.[providerName]?.oauthAccountFailover?.strategy;
|
|
370
374
|
if (providerName === "kiro" && raw === "least-loaded") return raw;
|
|
371
|
-
return raw === "round-robin" || raw === "fill-first" ? raw : null;
|
|
375
|
+
return raw === "round-robin" || raw === "fill-first" || raw === "reset-first" ? raw : null;
|
|
372
376
|
}
|
|
373
377
|
|
|
374
378
|
function genericStickyLimit(config: OcxConfig, providerName: string): number {
|
|
@@ -416,6 +420,7 @@ function pickFillFirstGenericAccount(
|
|
|
416
420
|
now: number,
|
|
417
421
|
requestedModelId?: string | null,
|
|
418
422
|
accountRows?: ReadonlyMap<string, ProviderAccount>,
|
|
423
|
+
preferBelowThreshold = false,
|
|
419
424
|
): string | null {
|
|
420
425
|
const stableAll = stableGenericRoster(providerName);
|
|
421
426
|
if (stableAll.length < 2) return null;
|
|
@@ -431,6 +436,11 @@ function pickFillFirstGenericAccount(
|
|
|
431
436
|
}
|
|
432
437
|
const start = activeId ? stableAll.indexOf(activeId) : -1;
|
|
433
438
|
const ring = start >= 0 ? [...stableAll.slice(start + 1), ...stableAll.slice(0, start)] : stableAll;
|
|
439
|
+
if (preferBelowThreshold) {
|
|
440
|
+
const below = ring.find(id => id !== activeId && eligible.has(id)
|
|
441
|
+
&& !isOverAutoSwitchThreshold(providerName, id, threshold, requestedModelId, accountRows?.get(id)));
|
|
442
|
+
if (below) return below;
|
|
443
|
+
}
|
|
434
444
|
for (const id of ring) {
|
|
435
445
|
if (id !== activeId && eligible.has(id)) return id;
|
|
436
446
|
}
|
|
@@ -574,6 +584,10 @@ export function rotateGenericOAuthAccountOnRefusal(
|
|
|
574
584
|
}
|
|
575
585
|
return null;
|
|
576
586
|
}
|
|
587
|
+
if (strategy === "reset-first") {
|
|
588
|
+
return rankAccountsByResetFirst(providerName, candidates, requestedModelId, now,
|
|
589
|
+
new Map(set.accounts.map(account => [account.id, account])))[0] ?? null;
|
|
590
|
+
}
|
|
577
591
|
// With no quota evidence this returns the ring untouched, so providers without
|
|
578
592
|
// per-account quota keep exactly the traversal they have today.
|
|
579
593
|
return rankAccountsByHeadroom(providerName, candidates, requestedModelId,
|
|
@@ -634,6 +648,7 @@ export function preferredInitialAccount(
|
|
|
634
648
|
providerName: string,
|
|
635
649
|
now = Date.now(),
|
|
636
650
|
requestedModelId?: string | null,
|
|
651
|
+
activeAccountOverride?: string,
|
|
637
652
|
): string | null {
|
|
638
653
|
// The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
|
|
639
654
|
// refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
|
|
@@ -642,7 +657,7 @@ export function preferredInitialAccount(
|
|
|
642
657
|
// active id separately would delay manual selection and account removal.
|
|
643
658
|
const selected = getAccountSet(providerName);
|
|
644
659
|
if (!selected) return null;
|
|
645
|
-
const active = selected.activeAccountId;
|
|
660
|
+
const active = activeAccountOverride ?? selected.activeAccountId;
|
|
646
661
|
const accountRows = new Map(selected.accounts.map(account => [account.id, account]));
|
|
647
662
|
const order = selected.accounts
|
|
648
663
|
.filter(account => account.paused !== true && account.needsReauth !== true)
|
|
@@ -694,9 +709,20 @@ export function preferredInitialAccount(
|
|
|
694
709
|
return picked && picked !== active ? picked : null;
|
|
695
710
|
}
|
|
696
711
|
if (strategy === "fill-first") {
|
|
697
|
-
const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId, accountRows);
|
|
712
|
+
const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId, accountRows, activeAccountOverride !== undefined);
|
|
698
713
|
return picked && picked !== active ? picked : null;
|
|
699
714
|
}
|
|
715
|
+
if (strategy === "reset-first") {
|
|
716
|
+
// Same candidate set as round-robin: paused, reauth-needed and cooled accounts are out,
|
|
717
|
+
// and an account whose model listing omits the requested model loses to one that has it.
|
|
718
|
+
// Reset ordering needs quota evidence; without it the ordinary active-account path runs.
|
|
719
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
720
|
+
const eligibleNow = preferKiroModelSupport(providerName,
|
|
721
|
+
eligibleFailoverAccounts(providerName, now, family), requestedModelId, config);
|
|
722
|
+
if (eligibleNow.length === 0 || !hasHeadroomEvidence(providerName, eligibleNow, requestedModelId, accountRows)) return null;
|
|
723
|
+
const best = rankAccountsByResetFirst(providerName, eligibleNow, requestedModelId, now, accountRows)[0] ?? null;
|
|
724
|
+
return best && best !== active ? best : null;
|
|
725
|
+
}
|
|
700
726
|
|
|
701
727
|
const activeRow = selected.accounts.find(account => account.id === active);
|
|
702
728
|
if (activeRow && activeRow.paused !== true && activeRow.needsReauth !== true
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Request-local conversation ownership for opt-in generic OAuth pools. No credentials are retained. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { getConfigDir } from "../config/paths";
|
|
4
|
+
import { subscribeAccountSelections } from "../lib/account-selection-events";
|
|
5
|
+
import type { OcxConfig } from "../types";
|
|
6
|
+
import { classifyModelFamilyForQuota } from "./account-quota-rank";
|
|
7
|
+
import { eligibleFailoverAccounts, isGenericFailoverProvider, preferKiroModelSupport, preferredInitialAccount } from "./generic-account-failover";
|
|
8
|
+
import type { OAuthAccessSnapshot } from "./index";
|
|
9
|
+
import { captureOAuthAccountSelection, credentialGeneration, getAccountCredentialWithStatus } from "./store";
|
|
10
|
+
import type { OAuthAccountSelection } from "./types";
|
|
11
|
+
|
|
12
|
+
const IDLE_TTL_MS = 24 * 60 * 60_000;
|
|
13
|
+
const MAX_ENTRIES = 2_000;
|
|
14
|
+
const bindings = new Map<string, { accountId: string; lastUsedAt: number; selection: OAuthAccountSelection }>();
|
|
15
|
+
const prefix = (provider: string) => `${JSON.stringify([getConfigDir(), provider])}\u0000`;
|
|
16
|
+
const key = (provider: string, session: string) => prefix(provider) + "session:" + createHash("sha256").update(session).digest("hex");
|
|
17
|
+
const cursorKey = (provider: string) => prefix(provider) + "fill-first";
|
|
18
|
+
|
|
19
|
+
/** A persisted operator/roster/policy change revokes old session choices before its writer returns. */
|
|
20
|
+
export function clearGenericSessionAffinity(provider?: string): void {
|
|
21
|
+
if (!provider) { bindings.clear(); return; }
|
|
22
|
+
const scope = prefix(provider);
|
|
23
|
+
for (const id of bindings.keys()) if (id.startsWith(scope)) bindings.delete(id);
|
|
24
|
+
}
|
|
25
|
+
subscribeAccountSelections(event => { if (event.kind === "oauth") clearGenericSessionAffinity(event.provider); });
|
|
26
|
+
|
|
27
|
+
/** Unlike new-session strategy selection, keeping the surviving account needs no 2+ quorum. */
|
|
28
|
+
export function genericSessionAffinityEnabled(config: OcxConfig, provider: string): boolean {
|
|
29
|
+
const row = config.providers[provider];
|
|
30
|
+
const enabled = !!row && row.disabled !== true && provider !== "anthropic2" && isGenericFailoverProvider(provider, row)
|
|
31
|
+
&& row.oauthAccountFailover?.sessionAffinity === "conversation"
|
|
32
|
+
&& (row.oauthAccountFailover.enabled ?? config.oauthAccountFailover?.enabled) === true;
|
|
33
|
+
if (!enabled) clearGenericSessionAffinity(provider);
|
|
34
|
+
return enabled;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function genericSessionAccountEligible(
|
|
38
|
+
config: OcxConfig, provider: string, accountId: string, model?: string | null, now = Date.now(), allowActiveFallback = false,
|
|
39
|
+
): boolean {
|
|
40
|
+
const eligible = eligibleFailoverAccounts(provider, now, classifyModelFamilyForQuota(provider, model));
|
|
41
|
+
// Existing conversation ownership is independent of transient per-account load.
|
|
42
|
+
if (preferKiroModelSupport(provider, eligible, model).includes(accountId)) return true;
|
|
43
|
+
// Cached automatic exclusions remain preferences when no alternative exists. Preserve the
|
|
44
|
+
// ordinary active/singleton attempt and its upstream refusal instead of inventing a conflict.
|
|
45
|
+
if (!allowActiveFallback || eligible.length > 0 || captureOAuthAccountSelection(provider)?.accountId !== accountId) return false;
|
|
46
|
+
const row = getAccountCredentialWithStatus(provider, accountId);
|
|
47
|
+
return !!row && !row.paused && !row.needsReauth;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Peek only. The admitted snapshot, never a speculative token lookup, establishes ownership. */
|
|
51
|
+
export function genericSessionAccount(
|
|
52
|
+
config: OcxConfig, provider: string, session: string | null, model?: string | null, now = Date.now(),
|
|
53
|
+
): string | null {
|
|
54
|
+
if (!genericSessionAffinityEnabled(config, provider) || !session) return null;
|
|
55
|
+
return readBinding(config, provider, key(provider, session), model, now);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readBinding(config: OcxConfig, provider: string, id: string, model: string | null | undefined, now: number): string | null {
|
|
59
|
+
const bound = bindings.get(id);
|
|
60
|
+
if (!bound) return null;
|
|
61
|
+
const current = captureOAuthAccountSelection(provider);
|
|
62
|
+
if (!current || current.accountId !== bound.selection.accountId || current.revision !== bound.selection.revision
|
|
63
|
+
|| now - bound.lastUsedAt >= IDLE_TTL_MS || now < bound.lastUsedAt
|
|
64
|
+
|| !genericSessionAccountEligible(config, provider, bound.accountId, model, now)) {
|
|
65
|
+
bindings.delete(id);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return bound.accountId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** New sessions use the configured strategy; an ineligible active account cannot defeat release. */
|
|
72
|
+
export function preferredGenericSessionAccount(
|
|
73
|
+
config: OcxConfig, provider: string, session: string | null, model?: string | null, now = Date.now(),
|
|
74
|
+
): string | null {
|
|
75
|
+
if (!genericSessionAffinityEnabled(config, provider)) return null;
|
|
76
|
+
const bound = genericSessionAccount(config, provider, session, model, now);
|
|
77
|
+
if (bound) return bound;
|
|
78
|
+
const cursor = readBinding(config, provider, cursorKey(provider), model, now);
|
|
79
|
+
const active = cursor ?? captureOAuthAccountSelection(provider)?.accountId;
|
|
80
|
+
const preferred = preferredInitialAccount(config, provider, now, model, active);
|
|
81
|
+
if (preferred) return preferred;
|
|
82
|
+
if (active && genericSessionAccountEligible(config, provider, active, model, now)) return active;
|
|
83
|
+
return preferKiroModelSupport(provider,
|
|
84
|
+
eligibleFailoverAccounts(provider, now, classifyModelFamilyForQuota(provider, model)), model, config)[0]
|
|
85
|
+
?? captureOAuthAccountSelection(provider)?.accountId ?? null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Validate against the operator's persisted selection without moving it. ALL automatic choices
|
|
90
|
+
* in affinity mode use this receipt, including unkeyed requests and failovers, so concurrent
|
|
91
|
+
* conversations cannot invalidate each other's selection revision. Manual A→B→A still fences.
|
|
92
|
+
*/
|
|
93
|
+
export function admitGenericSessionAccount(
|
|
94
|
+
config: OcxConfig, provider: string, expected: OAuthAccountSelection,
|
|
95
|
+
snapshot: OAuthAccessSnapshot, model?: string | null, modeEnabled = genericSessionAffinityEnabled(config, provider),
|
|
96
|
+
): OAuthAccountSelection | null {
|
|
97
|
+
if (!modeEnabled || snapshot.provider !== provider) return null;
|
|
98
|
+
const current = captureOAuthAccountSelection(provider);
|
|
99
|
+
if (!current || current.accountId !== expected.accountId || current.revision !== expected.revision) return null;
|
|
100
|
+
const row = getAccountCredentialWithStatus(provider, snapshot.accountId);
|
|
101
|
+
if (!row || row.paused || row.needsReauth || credentialGeneration(row.credential) !== snapshot.generation
|
|
102
|
+
|| !genericSessionAccountEligible(config, provider, snapshot.accountId, model, Date.now(), true)) return null;
|
|
103
|
+
return current;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function bindGenericSessionAccount(
|
|
107
|
+
config: OcxConfig, provider: string, session: string | null, accountId: string, model?: string | null, now = Date.now(),
|
|
108
|
+
): void {
|
|
109
|
+
if (!genericSessionAffinityEnabled(config, provider)
|
|
110
|
+
|| !genericSessionAccountEligible(config, provider, accountId, model, now)) return;
|
|
111
|
+
for (const [id, entry] of bindings) {
|
|
112
|
+
if (now - entry.lastUsedAt >= IDLE_TTL_MS || now < entry.lastUsedAt) bindings.delete(id);
|
|
113
|
+
}
|
|
114
|
+
const selection = captureOAuthAccountSelection(provider);
|
|
115
|
+
if (!selection) return;
|
|
116
|
+
const keep = (id: string) => {
|
|
117
|
+
bindings.delete(id);
|
|
118
|
+
bindings.set(id, { accountId, lastUsedAt: now, selection });
|
|
119
|
+
};
|
|
120
|
+
const hit = session && genericSessionAccount(config, provider, session, model, now) === accountId;
|
|
121
|
+
if (!hit && config.pool?.kernel === true && config.providers[provider]?.oauthAccountFailover?.strategy === "fill-first")
|
|
122
|
+
keep(cursorKey(provider));
|
|
123
|
+
if (session) keep(key(provider, session));
|
|
124
|
+
while (bindings.size > MAX_ENTRIES) bindings.delete(bindings.keys().next().value!);
|
|
125
|
+
}
|