@bitkyc08/opencodex 2.67.0 → 2.68.0-preview.20260927
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/bin/ocx.mjs +3 -0
- package/gui/dist/assets/App-B5-cAEd2.js +51 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/Tray-DFnMiyD0.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-BcXblvem.js +86 -0
- package/gui/dist/assets/quota-summary-i84eOU3o.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* (`oauth/anthropic-routing.ts` owns affinity and a fail-closed local-cli credential rule).
|
|
15
15
|
* Both are excluded by `isGenericFailoverProvider`.
|
|
16
16
|
*/
|
|
17
|
-
import { getAccountSet } from "./store";
|
|
17
|
+
import { credentialGeneration, getAccountSet } from "./store";
|
|
18
|
+
import { accountInFlight } from "./kiro-account-load";
|
|
19
|
+
import type { ProviderAccount } from "./types";
|
|
18
20
|
import { getValidAccessSnapshotForAccount, type OAuthAccessSnapshot } from "./index";
|
|
19
21
|
import {
|
|
20
22
|
accountHeadroomPercent,
|
|
@@ -34,12 +36,25 @@ import {
|
|
|
34
36
|
seedPoolRotationAccount,
|
|
35
37
|
} from "./pool-kernel";
|
|
36
38
|
import { parseRetryAfterMs } from "../combos/failover";
|
|
39
|
+
import type { KiroRefusalKind } from "../adapters/kiro-refusal";
|
|
40
|
+
import { kiroAccountEvidence } from "../providers/kiro-usage";
|
|
41
|
+
import { kiroEvidenceIdentity } from "../providers/kiro-account-state-disk";
|
|
42
|
+
import { kiroAccountSupportsModel } from "../providers/kiro-model-catalog";
|
|
43
|
+
import { ACCOUNT_QUOTA_TTL_MS } from "../providers/quota-wire";
|
|
37
44
|
import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
38
45
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
39
46
|
|
|
40
47
|
/** Cap same-request rotations so a short Retry-After cannot spin. Mirrors the Anthropic bound. */
|
|
41
48
|
export const GENERIC_OAUTH_MAX_FAILOVERS_PER_REQUEST = 3;
|
|
42
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Most accounts one request may fund and rotate through, whatever the roster size. Each funded
|
|
52
|
+
* account gets the normal transient ladder, so this fixes the default ingress ceiling at
|
|
53
|
+
* 6 × TRANSIENT_RETRY_MAX_ATTEMPTS physical sends: enrolling more accounts cannot turn one request
|
|
54
|
+
* into a pool-wide 429 storm.
|
|
55
|
+
*/
|
|
56
|
+
export const GENERIC_OAUTH_MAX_ACCOUNTS_PER_REQUEST = 6;
|
|
57
|
+
|
|
43
58
|
const DEFAULT_COOLDOWN_MS = 60_000;
|
|
44
59
|
|
|
45
60
|
/**
|
|
@@ -68,7 +83,8 @@ const EXCLUDED_PROVIDERS = new Set(["openai", "anthropic"]);
|
|
|
68
83
|
|
|
69
84
|
interface AccountHealth {
|
|
70
85
|
cooldownUntil: number;
|
|
71
|
-
cooldownSource: "retry-after" | "default";
|
|
86
|
+
cooldownSource: "retry-after" | "default" | "kiro-suspension";
|
|
87
|
+
identity?: string;
|
|
72
88
|
}
|
|
73
89
|
|
|
74
90
|
interface PresenceEntry {
|
|
@@ -85,9 +101,26 @@ const presence = new Map<string, PresenceEntry>();
|
|
|
85
101
|
const healthKey = (provider: string, accountId: string, family?: QuotaModelFamily) =>
|
|
86
102
|
family ? `${provider}\u0000${accountId}\u0000${family}` : `${provider}\u0000${accountId}`;
|
|
87
103
|
|
|
104
|
+
export function quarantineKiroSuspendedAccount(accountId: string, generation?: string, now = Date.now()): void {
|
|
105
|
+
const live = getAccountSet("kiro")?.accounts.find(row => row.id === accountId);
|
|
106
|
+
if (!live || (generation && credentialGeneration(live.credential) !== generation)) return;
|
|
107
|
+
health.set(healthKey("kiro", accountId), {
|
|
108
|
+
cooldownUntil: now + 24 * 60 * 60_000, cooldownSource: "kiro-suspension",
|
|
109
|
+
identity: kiroEvidenceIdentity(live),
|
|
110
|
+
});
|
|
111
|
+
sweepExpiredOnWrite(now);
|
|
112
|
+
}
|
|
113
|
+
|
|
88
114
|
function isCooled(provider: string, accountId: string, now: number, family?: QuotaModelFamily): boolean {
|
|
89
115
|
const entry = health.get(healthKey(provider, accountId, family));
|
|
90
116
|
if (!entry) return false;
|
|
117
|
+
if (provider === "kiro" && entry.cooldownSource === "kiro-suspension") {
|
|
118
|
+
const live = getAccountSet("kiro")?.accounts.find(row => row.id === accountId);
|
|
119
|
+
if (!live || entry.identity !== kiroEvidenceIdentity(live)) {
|
|
120
|
+
health.delete(healthKey(provider, accountId, family));
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
91
124
|
if (entry.cooldownUntil <= now) {
|
|
92
125
|
health.delete(healthKey(provider, accountId, family));
|
|
93
126
|
return false;
|
|
@@ -95,6 +128,22 @@ function isCooled(provider: string, accountId: string, now: number, family?: Quo
|
|
|
95
128
|
return true;
|
|
96
129
|
}
|
|
97
130
|
|
|
131
|
+
export type KiroSkipReason = "needs_reauth" | "suspended" | "cooldown" | "quota_exhausted";
|
|
132
|
+
|
|
133
|
+
/** Eligibility for automatic alternatives; an active singleton can still send. */
|
|
134
|
+
export function kiroAutoSelection(
|
|
135
|
+
account: ProviderAccount, now = Date.now(),
|
|
136
|
+
): { autoSelectable: boolean; skipReason?: KiroSkipReason } {
|
|
137
|
+
if (account.needsReauth === true) return { autoSelectable: false, skipReason: "needs_reauth" };
|
|
138
|
+
const cooled = isCooled("kiro", account.id, now);
|
|
139
|
+
if (cooled && health.get(healthKey("kiro", account.id))?.cooldownSource === "kiro-suspension")
|
|
140
|
+
return { autoSelectable: false, skipReason: "suspended" };
|
|
141
|
+
if (kiroAccountEvidence(account, now).exhausted === true)
|
|
142
|
+
return { autoSelectable: false, skipReason: "quota_exhausted" };
|
|
143
|
+
if (cooled) return { autoSelectable: false, skipReason: "cooldown" };
|
|
144
|
+
return { autoSelectable: true };
|
|
145
|
+
}
|
|
146
|
+
|
|
98
147
|
/** True when this provider participates in generic rotation at all. */
|
|
99
148
|
export function isGenericFailoverProvider(providerName: string, provider: OcxProviderConfig): boolean {
|
|
100
149
|
return provider.authMode === "oauth" && !EXCLUDED_PROVIDERS.has(providerName);
|
|
@@ -112,7 +161,10 @@ function eligibleAccountCount(providerName: string, now: number): number {
|
|
|
112
161
|
const cached = presence.get(providerName);
|
|
113
162
|
if (cached && now >= cached.readAt && now - cached.readAt < PRESENCE_CACHE_TTL_MS) return cached.eligible;
|
|
114
163
|
const set = getAccountSet(providerName);
|
|
115
|
-
|
|
164
|
+
// Kiro terminal refresh marks the just-refused account needsReauth before the alternate
|
|
165
|
+
// selector runs. Both stored logins still express consent to recover through the survivor.
|
|
166
|
+
const eligible = set ? (providerName === "kiro" ? set.accounts.length
|
|
167
|
+
: set.accounts.filter(account => account.needsReauth !== true).length) : 0;
|
|
116
168
|
presence.set(providerName, { eligible, readAt: now });
|
|
117
169
|
return eligible;
|
|
118
170
|
}
|
|
@@ -163,7 +215,7 @@ export function isGenericOAuthFailoverEnabled(
|
|
|
163
215
|
* turns it off only when the provider has no override. A malformed value falls through rather
|
|
164
216
|
* than taking a provider out of service.
|
|
165
217
|
*/
|
|
166
|
-
function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, now: number): boolean {
|
|
218
|
+
export function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, now: number): boolean {
|
|
167
219
|
const provider = config.providers?.[providerName];
|
|
168
220
|
if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
|
|
169
221
|
const perProvider = provider.oauthAccountFailover?.enabled;
|
|
@@ -181,6 +233,25 @@ export function eligibleFailoverAccounts(providerName: string, now = Date.now(),
|
|
|
181
233
|
return eligibleIdsIn(getAccountSet(providerName), providerName, now, family);
|
|
182
234
|
}
|
|
183
235
|
|
|
236
|
+
/** Model evidence only ranks accounts already eligible and below any configured cap. */
|
|
237
|
+
export function preferKiroModelSupport(
|
|
238
|
+
providerName: string, ids: string[], model?: string | null, config?: OcxConfig,
|
|
239
|
+
): string[] {
|
|
240
|
+
if (providerName !== "kiro" || !model) return ids;
|
|
241
|
+
const cap = config?.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
|
|
242
|
+
const withRoom = typeof cap === "number" && Number.isInteger(cap) && cap >= 1 && cap <= 100
|
|
243
|
+
? ids.filter(id => accountInFlight("kiro", id) < cap) : ids;
|
|
244
|
+
const positive = withRoom.filter(id => kiroAccountSupportsModel(id, model) === true);
|
|
245
|
+
return positive.length ? positive : ids;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function kiroModelListingHasRoom(config: OcxConfig, accountId: string, model: string): boolean {
|
|
249
|
+
if (kiroAccountSupportsModel(accountId, model) !== true) return false;
|
|
250
|
+
const cap = config.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
|
|
251
|
+
return !(typeof cap === "number" && Number.isInteger(cap) && cap >= 1 && cap <= 100)
|
|
252
|
+
|| accountInFlight("kiro", accountId) < cap;
|
|
253
|
+
}
|
|
254
|
+
|
|
184
255
|
function eligibleIdsIn(
|
|
185
256
|
set: ReturnType<typeof getAccountSet>,
|
|
186
257
|
providerName: string,
|
|
@@ -189,7 +260,8 @@ function eligibleIdsIn(
|
|
|
189
260
|
): string[] {
|
|
190
261
|
if (!set) return [];
|
|
191
262
|
return set.accounts
|
|
192
|
-
.filter(account =>
|
|
263
|
+
.filter(account => providerName === "kiro" ? kiroAutoSelection(account, now).autoSelectable
|
|
264
|
+
: account.needsReauth !== true && !isCooled(providerName, account.id, now, family))
|
|
193
265
|
.map(account => account.id);
|
|
194
266
|
}
|
|
195
267
|
|
|
@@ -214,7 +286,7 @@ export function hasEligibleGenericOAuthFailoverTarget(
|
|
|
214
286
|
}
|
|
215
287
|
|
|
216
288
|
/** Generic pool strategies the kernel can actually run. `quota` IS the pre-kernel path. */
|
|
217
|
-
type ActiveGenericStrategy = "round-robin" | "fill-first";
|
|
289
|
+
type ActiveGenericStrategy = "round-robin" | "fill-first" | "least-loaded";
|
|
218
290
|
|
|
219
291
|
/** Matches the Codex and Anthropic pools; the DTO still reports `null` for "not stored". */
|
|
220
292
|
const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80;
|
|
@@ -230,6 +302,7 @@ const DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD = 80;
|
|
|
230
302
|
function activeGenericStrategy(config: OcxConfig, providerName: string): ActiveGenericStrategy | null {
|
|
231
303
|
if (config.pool?.kernel !== true) return null;
|
|
232
304
|
const raw = config.providers?.[providerName]?.oauthAccountFailover?.strategy;
|
|
305
|
+
if (providerName === "kiro" && raw === "least-loaded") return raw;
|
|
233
306
|
return raw === "round-robin" || raw === "fill-first" ? raw : null;
|
|
234
307
|
}
|
|
235
308
|
|
|
@@ -260,9 +333,9 @@ function stableGenericRoster(providerName: string): string[] {
|
|
|
260
333
|
* statement about observed usage, and treating "no observation" as "spent" would evacuate every
|
|
261
334
|
* quota-less provider off its active account on the very first request.
|
|
262
335
|
*/
|
|
263
|
-
function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null): boolean {
|
|
336
|
+
function isOverAutoSwitchThreshold(providerName: string, accountId: string, threshold: number, requestedModelId?: string | null, account?: ProviderAccount): boolean {
|
|
264
337
|
if (threshold <= 0) return false;
|
|
265
|
-
const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId);
|
|
338
|
+
const headroom = accountHeadroomPercent(providerName, accountId, requestedModelId, account);
|
|
266
339
|
if (headroom === null) return false;
|
|
267
340
|
return 100 - headroom >= threshold;
|
|
268
341
|
}
|
|
@@ -277,16 +350,18 @@ function pickFillFirstGenericAccount(
|
|
|
277
350
|
activeId: string | undefined,
|
|
278
351
|
now: number,
|
|
279
352
|
requestedModelId?: string | null,
|
|
353
|
+
accountRows?: ReadonlyMap<string, ProviderAccount>,
|
|
280
354
|
): string | null {
|
|
281
355
|
const stableAll = stableGenericRoster(providerName);
|
|
282
356
|
if (stableAll.length < 2) return null;
|
|
283
357
|
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
284
|
-
const eligible = new Set(
|
|
358
|
+
const eligible = new Set(preferKiroModelSupport(providerName,
|
|
359
|
+
eligibleFailoverAccounts(providerName, now, family), requestedModelId, config));
|
|
285
360
|
const stored = config.providers?.[providerName]?.oauthAccountFailover?.autoSwitchThreshold;
|
|
286
361
|
const threshold = typeof stored === "number" && Number.isInteger(stored) && stored >= 0 && stored <= 100
|
|
287
362
|
? stored
|
|
288
363
|
: DEFAULT_GENERIC_AUTO_SWITCH_THRESHOLD;
|
|
289
|
-
if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold, requestedModelId)) {
|
|
364
|
+
if (activeId && eligible.has(activeId) && !isOverAutoSwitchThreshold(providerName, activeId, threshold, requestedModelId, accountRows?.get(activeId))) {
|
|
290
365
|
return null;
|
|
291
366
|
}
|
|
292
367
|
const start = activeId ? stableAll.indexOf(activeId) : -1;
|
|
@@ -343,6 +418,22 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
343
418
|
now = Date.now(),
|
|
344
419
|
requestedModelId?: string | null,
|
|
345
420
|
): string | null {
|
|
421
|
+
return rotateGenericOAuthAccountOnRefusal(config, providerName, failedAccountId, "rate",
|
|
422
|
+
retryAfterHeader, now, requestedModelId);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Kiro adds account-scoped refusal classes; all other providers retain their 429 policy. */
|
|
426
|
+
export function rotateGenericOAuthAccountOnRefusal(
|
|
427
|
+
config: OcxConfig,
|
|
428
|
+
providerName: string,
|
|
429
|
+
failedAccountId: string,
|
|
430
|
+
kind: KiroRefusalKind,
|
|
431
|
+
retryAfterHeader: string | null | undefined,
|
|
432
|
+
now = Date.now(),
|
|
433
|
+
requestedModelId?: string | null,
|
|
434
|
+
monthlyCooldownMs?: number,
|
|
435
|
+
): string | null {
|
|
436
|
+
if (kind === "other" || (providerName !== "kiro" && kind !== "rate")) return null;
|
|
346
437
|
if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
|
|
347
438
|
const set = getAccountSet(providerName);
|
|
348
439
|
// A single stored account has nowhere to go; rotating to itself would just replay the 429.
|
|
@@ -351,14 +442,22 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
351
442
|
// `preserveServerDelay` keeps the delay the server actually stated, bounded by the parser's
|
|
352
443
|
// one-day ceiling, exactly as the combo path does. Truncating it locally only guarantees a
|
|
353
444
|
// second 429 on an account we were told to leave alone.
|
|
354
|
-
const parsed =
|
|
445
|
+
const parsed = kind === "rate"
|
|
446
|
+
? parseRetryAfterMs(retryAfterHeader, now, { preserveImmediate: true, preserveServerDelay: true })
|
|
447
|
+
: undefined;
|
|
355
448
|
// An account whose allowance is provably spent gets a reset-aligned cooldown instead of
|
|
356
449
|
// the default minute: retrying it every 60s until the window rolls over is pure waste.
|
|
357
450
|
// A Retry-After from upstream still wins — it is the server's own instruction.
|
|
358
|
-
const exhausted = parsed === undefined
|
|
359
|
-
|
|
451
|
+
const exhausted = parsed === undefined && providerName !== "kiro"
|
|
452
|
+
? exhaustedCooldownMs(providerName, failedAccountId, now, set.accounts.find(account => account.id === failedAccountId)) : null;
|
|
453
|
+
const cooldownMs = kind === "suspended" ? 24 * 60 * 60_000
|
|
454
|
+
: kind === "monthly_quota" ? (monthlyCooldownMs ?? ACCOUNT_QUOTA_TTL_MS)
|
|
455
|
+
: providerName === "kiro" ? (parsed ?? 10_000)
|
|
456
|
+
: (exhausted ?? parsed ?? DEFAULT_COOLDOWN_MS);
|
|
360
457
|
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
361
|
-
|
|
458
|
+
// Suspension is recorded by the caller with the sent generation before the quorum check.
|
|
459
|
+
// Rewriting it here would lose its login-identity fence.
|
|
460
|
+
if (kind !== "suspended") health.set(healthKey(providerName, failedAccountId, providerName === "kiro" ? undefined : family), {
|
|
362
461
|
cooldownUntil: now + cooldownMs,
|
|
363
462
|
cooldownSource: parsed ? "retry-after" : "default",
|
|
364
463
|
});
|
|
@@ -375,12 +474,21 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
375
474
|
const order = set.accounts.map(account => account.id);
|
|
376
475
|
const start = order.indexOf(failedAccountId);
|
|
377
476
|
const ring = start >= 0 ? [...order.slice(start + 1), ...order.slice(0, start)] : order;
|
|
378
|
-
|
|
477
|
+
let candidates = ring.filter(id => id !== failedAccountId && eligible.includes(id));
|
|
379
478
|
if (candidates.length === 0) return null;
|
|
479
|
+
const cap = providerName === "kiro" ? config.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount : undefined;
|
|
480
|
+
if (typeof cap === "number" && Number.isInteger(cap) && cap >= 1 && cap <= 100) {
|
|
481
|
+
const withRoom = candidates.filter(id => accountInFlight("kiro", id) < cap);
|
|
482
|
+
if (withRoom.length > 0) candidates = withRoom;
|
|
483
|
+
}
|
|
484
|
+
candidates = preferKiroModelSupport(providerName, candidates, requestedModelId, config);
|
|
380
485
|
// The 429 path branches too. Leaving it on the quota ranking would make a configured
|
|
381
486
|
// strategy inert in practice the moment anything actually failed, which is the case the
|
|
382
487
|
// operator chose the strategy for.
|
|
383
488
|
const strategy = activeGenericStrategy(config, providerName);
|
|
489
|
+
if (strategy === "least-loaded" && isProactivePreferenceEnabled(config, "kiro", now)) {
|
|
490
|
+
return candidates.sort((a, b) => accountInFlight("kiro", a) - accountInFlight("kiro", b))[0] ?? null;
|
|
491
|
+
}
|
|
384
492
|
if (strategy === "round-robin") {
|
|
385
493
|
// PICK here, not peek: the failure already happened and this answer is the one being used,
|
|
386
494
|
// so the ring genuinely advances.
|
|
@@ -403,7 +511,31 @@ export function rotateGenericOAuthAccountOn429(
|
|
|
403
511
|
}
|
|
404
512
|
// With no quota evidence this returns the ring untouched, so providers without
|
|
405
513
|
// per-account quota keep exactly the traversal they have today.
|
|
406
|
-
return rankAccountsByHeadroom(providerName, candidates, requestedModelId
|
|
514
|
+
return rankAccountsByHeadroom(providerName, candidates, requestedModelId,
|
|
515
|
+
new Map(set.accounts.map(account => [account.id, account])))[0] ?? null;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Known dead-account exclusion is a proactive choice with narrow-over-broad precedence. */
|
|
519
|
+
export function refusalAwareInitialKiroAccount(
|
|
520
|
+
config: OcxConfig, activeId: string, now = Date.now(), requestedModelId?: string | null,
|
|
521
|
+
): string | null {
|
|
522
|
+
if (!isProactivePreferenceEnabled(config, "kiro", now)) return null;
|
|
523
|
+
const set = getAccountSet("kiro");
|
|
524
|
+
if (!set || set.accounts.length < 2) return null;
|
|
525
|
+
const active = set.accounts.find(account => account.id === activeId);
|
|
526
|
+
if (!active) return null;
|
|
527
|
+
const eligibleIds = eligibleIdsIn(set, "kiro", now, classifyModelFamilyForQuota("kiro", requestedModelId));
|
|
528
|
+
const preferred = preferKiroModelSupport("kiro", eligibleIds, requestedModelId, config);
|
|
529
|
+
const activeLacks = requestedModelId && kiroAccountSupportsModel(activeId, requestedModelId) === false
|
|
530
|
+
&& preferred.some(id => id !== activeId && kiroModelListingHasRoom(config, id, requestedModelId));
|
|
531
|
+
if (!activeLacks && !isCooled("kiro", activeId, now)
|
|
532
|
+
&& kiroAccountEvidence(active, now).exhausted !== true) return null;
|
|
533
|
+
const eligible = new Set(preferred);
|
|
534
|
+
const start = set.accounts.findIndex(account => account.id === activeId);
|
|
535
|
+
for (const account of [...set.accounts.slice(start + 1), ...set.accounts.slice(0, start)]) {
|
|
536
|
+
if (eligible.has(account.id)) return account.id;
|
|
537
|
+
}
|
|
538
|
+
return null;
|
|
407
539
|
}
|
|
408
540
|
|
|
409
541
|
/**
|
|
@@ -446,18 +578,41 @@ export function preferredInitialAccount(
|
|
|
446
578
|
const selected = getAccountSet(providerName);
|
|
447
579
|
if (!selected) return null;
|
|
448
580
|
const active = selected.activeAccountId;
|
|
581
|
+
const accountRows = new Map(selected.accounts.map(account => [account.id, account]));
|
|
449
582
|
const order = selected.accounts.filter(account => account.needsReauth !== true).map(account => account.id);
|
|
450
583
|
if (order.length < 2) return null;
|
|
451
584
|
|
|
585
|
+
const modelEligible = preferKiroModelSupport(providerName,
|
|
586
|
+
eligibleFailoverAccounts(providerName, now, classifyModelFamilyForQuota(providerName, requestedModelId)),
|
|
587
|
+
requestedModelId, config);
|
|
588
|
+
const activeLacks = providerName === "kiro" && !!active && !!requestedModelId
|
|
589
|
+
&& kiroAccountSupportsModel(active, requestedModelId) === false;
|
|
590
|
+
const listingSibling = activeLacks
|
|
591
|
+
? modelEligible.find(id => id !== active && kiroModelListingHasRoom(config, id, requestedModelId!))
|
|
592
|
+
: undefined;
|
|
593
|
+
if (listingSibling) return listingSibling;
|
|
594
|
+
|
|
452
595
|
// A configured strategy answers this question itself. Both guards below exist to protect the
|
|
453
596
|
// QUOTA answer, and both are fatal to the other two: hasHeadroomEvidence refuses every
|
|
454
597
|
// provider with no quota data, which is exactly where round-robin is the point, and the
|
|
455
598
|
// healthy-active return fires before autoSwitchThreshold can ever be read, so fill-first
|
|
456
599
|
// would never reach its own test. Cooldowns and reauth are still honoured inside each pick.
|
|
457
600
|
const strategy = activeGenericStrategy(config, providerName);
|
|
601
|
+
if (strategy === "least-loaded" && providerName === "kiro") {
|
|
602
|
+
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
603
|
+
let candidates = eligibleFailoverAccounts(providerName, now, family);
|
|
604
|
+
const cap = config.providers?.kiro?.oauthAccountFailover?.maxConcurrentPerAccount;
|
|
605
|
+
if (typeof cap === "number" && Number.isInteger(cap) && cap >= 1 && cap <= 100) {
|
|
606
|
+
candidates = candidates.filter(id => accountInFlight("kiro", id) < cap);
|
|
607
|
+
}
|
|
608
|
+
candidates = preferKiroModelSupport(providerName, candidates, requestedModelId, config);
|
|
609
|
+
const picked = candidates.sort((a, b) => accountInFlight("kiro", a) - accountInFlight("kiro", b))[0];
|
|
610
|
+
return picked && picked !== active ? picked : null;
|
|
611
|
+
}
|
|
458
612
|
if (strategy === "round-robin") {
|
|
459
613
|
const family = classifyModelFamilyForQuota(providerName, requestedModelId);
|
|
460
|
-
const eligibleNow =
|
|
614
|
+
const eligibleNow = preferKiroModelSupport(providerName,
|
|
615
|
+
eligibleFailoverAccounts(providerName, now, family), requestedModelId, config);
|
|
461
616
|
if (eligibleNow.length === 0) return null;
|
|
462
617
|
// PEEK, not pick: this proposal is discardable, and advancing the ring for an account the
|
|
463
618
|
// resolver then rejects would skip a turn for nothing. noteGenericPoolSelection commits.
|
|
@@ -469,26 +624,28 @@ export function preferredInitialAccount(
|
|
|
469
624
|
return picked && picked !== active ? picked : null;
|
|
470
625
|
}
|
|
471
626
|
if (strategy === "fill-first") {
|
|
472
|
-
const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId);
|
|
627
|
+
const picked = pickFillFirstGenericAccount(config, providerName, active, now, requestedModelId, accountRows);
|
|
473
628
|
return picked && picked !== active ? picked : null;
|
|
474
629
|
}
|
|
475
630
|
|
|
476
631
|
const activeRow = selected.accounts.find(account => account.id === active);
|
|
477
632
|
if (activeRow && activeRow.needsReauth !== true
|
|
478
633
|
&& !isCooled(providerName, activeRow.id, now, classifyModelFamilyForQuota(providerName, requestedModelId))
|
|
479
|
-
&& !isAccountQuotaExhausted(providerName, activeRow.id, requestedModelId)) return null;
|
|
634
|
+
&& !isAccountQuotaExhausted(providerName, activeRow.id, requestedModelId, activeRow)) return null;
|
|
480
635
|
|
|
481
636
|
// Evidence is required BEFORE eligibility narrows the field. Without this, a provider
|
|
482
637
|
// with no quota data at all could still be redirected: cool the active account with a
|
|
483
638
|
// 429 and the eligible list collapses to one candidate, which any ranking returns
|
|
484
639
|
// unchanged — an answer that looks ranked but was never measured. The no-op guarantee
|
|
485
640
|
// for quota-less providers has to be checked on the full roster.
|
|
486
|
-
if (!hasHeadroomEvidence(providerName, order, requestedModelId)) return null;
|
|
641
|
+
if (!hasHeadroomEvidence(providerName, order, requestedModelId, accountRows)) return null;
|
|
487
642
|
|
|
488
643
|
// Cooldowns are respected here, unlike in the presence count: this picks the account to
|
|
489
644
|
// send to right now, and one inside its 429 window is the single candidate we hold
|
|
490
645
|
// positive evidence against.
|
|
491
|
-
const eligible =
|
|
646
|
+
const eligible = preferKiroModelSupport(providerName,
|
|
647
|
+
order.filter(id => !isCooled(providerName, id, now, classifyModelFamilyForQuota(providerName, requestedModelId))),
|
|
648
|
+
requestedModelId, config);
|
|
492
649
|
if (eligible.length === 0) return null;
|
|
493
650
|
|
|
494
651
|
// Start the ring at the active account so an unranked outcome reproduces today's choice.
|
|
@@ -497,7 +654,7 @@ export function preferredInitialAccount(
|
|
|
497
654
|
const candidates = ring.filter(id => eligible.includes(id));
|
|
498
655
|
if (candidates.length === 0) return null;
|
|
499
656
|
|
|
500
|
-
const best = rankAccountsByHeadroom(providerName, candidates, requestedModelId)[0] ?? null;
|
|
657
|
+
const best = rankAccountsByHeadroom(providerName, candidates, requestedModelId, accountRows)[0] ?? null;
|
|
501
658
|
// Nothing to do when the ranking agrees with the account we would have used anyway.
|
|
502
659
|
//
|
|
503
660
|
// A proposal still needs guarded selection commit after credential resolution: a
|
package/src/oauth/index.ts
CHANGED
|
@@ -53,7 +53,7 @@ import { resolveProviderTransport } from "../providers/xai-transport";
|
|
|
53
53
|
import { detectClaudeCodeToken, detectGrokCliToken, hasComparableGrokIdentity, isSameGrokIdentity, shouldAdoptGrokGeneration } from "./local-token-detect";
|
|
54
54
|
import { logOAuthEvent } from "./log";
|
|
55
55
|
import { captureConfigGeneration, sweepExpiredOnWrite } from "../lib/state-store-sweeper";
|
|
56
|
-
import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode } from "./login-flow-state";
|
|
56
|
+
import { clearManualCodeSlot, ensureManualCodeSlot, kiroLoginSettling, loginAbort, loginState, waitForManualLoginCode, type OAuthLoginHint } from "./login-flow-state";
|
|
57
57
|
export { reconcileOAuthFlowState, submitManualLoginCode } from "./login-flow-state";
|
|
58
58
|
import { randomUUID } from "node:crypto";
|
|
59
59
|
export {
|
|
@@ -1626,6 +1626,10 @@ export async function runLogin(
|
|
|
1626
1626
|
const loginProviderConfig = preflightConfig
|
|
1627
1627
|
? (def.resolveProviderConfig?.(preflightConfig) ?? preflightConfig.providers[provider] ?? def.providerConfig)
|
|
1628
1628
|
: def.providerConfig;
|
|
1629
|
+
if (provider === "kiro" && opts?.reauthAccountId
|
|
1630
|
+
&& getAccountSet("kiro")?.accounts.some(a => a.id === opts.reauthAccountId && a.loginOrigin === "kiro-device")) {
|
|
1631
|
+
throw new Error("Native Kiro device accounts cannot be reauthenticated with kiro-cli; remove and re-add the account.");
|
|
1632
|
+
}
|
|
1629
1633
|
const rawCred = await def.login(ctrl, opts, loginProviderConfig);
|
|
1630
1634
|
const cred: OAuthCredentials = rawCred.source ? rawCred : { ...rawCred, source: "oauth" };
|
|
1631
1635
|
const settleKiroTransaction = deps.settleKiroLoginTransaction ?? settleKiroLoginTransaction;
|
|
@@ -1653,6 +1657,7 @@ export async function runLogin(
|
|
|
1653
1657
|
}
|
|
1654
1658
|
await (deps.saveAccountCredential ?? saveAccountCredential)(provider, opts.reauthAccountId, cred, {
|
|
1655
1659
|
assertBeforePersist: deps.assertCurrentOwner,
|
|
1660
|
+
rotateLoginId: true,
|
|
1656
1661
|
});
|
|
1657
1662
|
} else {
|
|
1658
1663
|
const saveOptions = {
|
|
@@ -1764,7 +1769,7 @@ export interface OAuthAccountSummary {
|
|
|
1764
1769
|
* the config at its request boundary and resolves the policy there with `emailMaskingEnabled`.
|
|
1765
1770
|
* The default masks, so every existing caller keeps today's behaviour.
|
|
1766
1771
|
*/
|
|
1767
|
-
export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
|
|
1772
|
+
export function getLoginStatus(provider: string, maskEmails = true): { loggedIn: boolean; email?: string; source?: OAuthCredentials["source"]; error?: string; done: boolean; hint?: OAuthLoginHint; activeAccountId?: string; accounts?: OAuthAccountSummary[] } {
|
|
1768
1773
|
const cred = getCredential(provider);
|
|
1769
1774
|
const st = loginState.get(provider);
|
|
1770
1775
|
const set = getAccountSet(provider);
|
|
@@ -1795,6 +1800,7 @@ export function getLoginStatus(provider: string, maskEmails = true): { loggedIn:
|
|
|
1795
1800
|
source: cred?.source,
|
|
1796
1801
|
error: st?.error,
|
|
1797
1802
|
done: st?.done ?? false,
|
|
1803
|
+
...(st?.hint && !st.done ? { hint: { url: st.hint.url, instructions: st.hint.instructions, deviceCode: st.hint.deviceCode } } : {}),
|
|
1798
1804
|
...(set ? { activeAccountId: set.activeAccountId, accounts } : {}),
|
|
1799
1805
|
};
|
|
1800
1806
|
}
|
|
@@ -1849,8 +1855,15 @@ export async function startLoginFlow(
|
|
|
1849
1855
|
let urlResolved = false;
|
|
1850
1856
|
const ctrl: OAuthController = {
|
|
1851
1857
|
onAuth: ({ url, instructions, deviceCode }) => {
|
|
1852
|
-
|
|
1853
|
-
|
|
1858
|
+
if (abort.signal.aborted || loginAbort.get(provider)?.controller !== abort) return;
|
|
1859
|
+
// Device approval can fall back to manual input. Replace, never merge: the
|
|
1860
|
+
// previous device code must disappear when the provider changes the next step.
|
|
1861
|
+
const hint = { url, instructions, deviceCode };
|
|
1862
|
+
loginState.set(provider, { done: false, hint });
|
|
1863
|
+
if (!urlResolved) {
|
|
1864
|
+
urlResolved = true;
|
|
1865
|
+
resolve({ ...hint });
|
|
1866
|
+
}
|
|
1854
1867
|
},
|
|
1855
1868
|
onProgress: () => {},
|
|
1856
1869
|
// GUI fallback when the browser cannot hit the loopback callback server.
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/** Process-local serving capacity for Kiro OAuth accounts. */
|
|
2
|
+
export interface AccountLease {
|
|
3
|
+
readonly provider: string;
|
|
4
|
+
readonly accountId: string;
|
|
5
|
+
release(): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AccountLeaseOptions {
|
|
9
|
+
maxConcurrentPerAccount?: number;
|
|
10
|
+
waitMs?: number;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const KIRO_LEASE_MAX_MS = 15 * 60_000;
|
|
15
|
+
export const KIRO_ACCOUNT_WAIT_MS = 250;
|
|
16
|
+
|
|
17
|
+
interface LeaseRecord { acquiredAt: number; released: boolean }
|
|
18
|
+
interface Waiter { grant(now: number): "granted" | "blocked" | "expired" }
|
|
19
|
+
interface AccountState { records: Map<number, LeaseRecord>; waiters: Waiter[] }
|
|
20
|
+
|
|
21
|
+
const accounts = new Map<string, AccountState>();
|
|
22
|
+
let nextLeaseId = 0;
|
|
23
|
+
const keyOf = (provider: string, accountId: string) => `${provider}\u0000${accountId}`;
|
|
24
|
+
|
|
25
|
+
function handoff(state: AccountState, now: number): void {
|
|
26
|
+
while (state.waiters.length > 0) {
|
|
27
|
+
const waiter = state.waiters.shift()!;
|
|
28
|
+
const result = waiter.grant(now);
|
|
29
|
+
if (result === "granted") return;
|
|
30
|
+
if (result === "blocked") { state.waiters.unshift(waiter); return; }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function leaseFor(provider: string, accountId: string, key: string, state: AccountState, now: number): AccountLease {
|
|
35
|
+
const id = ++nextLeaseId;
|
|
36
|
+
const record: LeaseRecord = { acquiredAt: now, released: false };
|
|
37
|
+
state.records.set(id, record);
|
|
38
|
+
accounts.set(key, state);
|
|
39
|
+
return { provider, accountId, release() {
|
|
40
|
+
if (record.released) return;
|
|
41
|
+
record.released = true;
|
|
42
|
+
state.records.delete(id);
|
|
43
|
+
handoff(state, Date.now());
|
|
44
|
+
if (state.records.size === 0 && state.waiters.length === 0) accounts.delete(key);
|
|
45
|
+
} };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function reclaim(key: string, now: number): AccountState | undefined {
|
|
49
|
+
const state = accounts.get(key);
|
|
50
|
+
if (!state) return undefined;
|
|
51
|
+
for (const [id, record] of state.records) {
|
|
52
|
+
if (now - record.acquiredAt < KIRO_LEASE_MAX_MS) continue;
|
|
53
|
+
record.released = true;
|
|
54
|
+
state.records.delete(id);
|
|
55
|
+
handoff(state, now);
|
|
56
|
+
}
|
|
57
|
+
if (state.records.size === 0 && state.waiters.length === 0) accounts.delete(key);
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function accountInFlight(provider: string, accountId: string): number {
|
|
62
|
+
return reclaim(keyOf(provider, accountId), Date.now())?.records.size ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A null result means the deadline expired or the caller aborted. */
|
|
66
|
+
export async function acquireAccountLease(
|
|
67
|
+
provider: string, accountId: string, opts: AccountLeaseOptions = {},
|
|
68
|
+
): Promise<AccountLease | null> {
|
|
69
|
+
const key = keyOf(provider, accountId);
|
|
70
|
+
const deadline = Date.now() + Math.max(0, opts.waitMs ?? 0);
|
|
71
|
+
if (opts.signal?.aborted) return null;
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
const state: AccountState = reclaim(key, now) ?? { records: new Map(), waiters: [] };
|
|
74
|
+
if (state.waiters.length === 0
|
|
75
|
+
&& (opts.maxConcurrentPerAccount === undefined || state.records.size < opts.maxConcurrentPerAccount)) {
|
|
76
|
+
return leaseFor(provider, accountId, key, state, now);
|
|
77
|
+
}
|
|
78
|
+
const remaining = deadline - now;
|
|
79
|
+
if (remaining <= 0) return null;
|
|
80
|
+
return await new Promise<AccountLease | null>(resolve => {
|
|
81
|
+
let settled = false;
|
|
82
|
+
const finish = (lease: AccountLease | null) => {
|
|
83
|
+
if (settled) return;
|
|
84
|
+
settled = true;
|
|
85
|
+
clearTimeout(timer);
|
|
86
|
+
opts.signal?.removeEventListener("abort", abort);
|
|
87
|
+
const index = state.waiters.indexOf(waiter);
|
|
88
|
+
if (index >= 0) state.waiters.splice(index, 1);
|
|
89
|
+
if (state.records.size === 0 && state.waiters.length === 0) accounts.delete(key);
|
|
90
|
+
resolve(lease);
|
|
91
|
+
};
|
|
92
|
+
const abort = () => finish(null);
|
|
93
|
+
const waiter: Waiter = { grant(grantNow) {
|
|
94
|
+
if (settled) return "expired";
|
|
95
|
+
if (opts.signal?.aborted || grantNow >= deadline) { finish(null); return "expired"; }
|
|
96
|
+
if (opts.maxConcurrentPerAccount !== undefined && state.records.size >= opts.maxConcurrentPerAccount)
|
|
97
|
+
return "blocked";
|
|
98
|
+
finish(leaseFor(provider, accountId, key, state, grantNow));
|
|
99
|
+
return "granted";
|
|
100
|
+
} };
|
|
101
|
+
state.waiters.push(waiter);
|
|
102
|
+
accounts.set(key, state);
|
|
103
|
+
const timer = setTimeout(() => finish(null), remaining);
|
|
104
|
+
opts.signal?.addEventListener("abort", abort, { once: true });
|
|
105
|
+
if (opts.signal?.aborted) finish(null);
|
|
106
|
+
});
|
|
107
|
+
}
|