@bitkyc08/opencodex 2.67.0 → 2.68.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/bin/ocx.mjs +3 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/App-mpsibsFj.js +51 -0
- package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-kUE8tuAK.js +86 -0
- package/gui/dist/assets/quota-summary-DL-bNuNL.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
|
@@ -261,11 +261,14 @@ async function stopProxyGracefullyDetailed(
|
|
|
261
261
|
if (!res.ok) return done(false);
|
|
262
262
|
const body: unknown = await res.json().catch(() => null);
|
|
263
263
|
const expectedTeardown = io.deferSharedTeardownNonce ? "deferred" : "performed";
|
|
264
|
+
// A sibling instance answers `not-owned`: it has no shared teardown to perform, so there is
|
|
265
|
+
// nothing left over for this caller either. A deferral it was sent is still not confirmed.
|
|
264
266
|
sharedTeardownConfirmed = body !== null
|
|
265
267
|
&& typeof body === "object"
|
|
266
268
|
&& !Array.isArray(body)
|
|
267
269
|
&& "success" in body && body.success === true
|
|
268
|
-
&& "sharedTeardown" in body && body.sharedTeardown === expectedTeardown
|
|
270
|
+
&& "sharedTeardown" in body && (body.sharedTeardown === expectedTeardown
|
|
271
|
+
|| (!io.deferSharedTeardownNonce && body.sharedTeardown === "not-owned"));
|
|
269
272
|
} catch {
|
|
270
273
|
return done(false);
|
|
271
274
|
}
|
|
@@ -12,6 +12,112 @@ export const SYSTEM_RESTART_CAPABILITY_HEADER = "x-opencodex-restart-capability"
|
|
|
12
12
|
export const MEMORY_DRAIN_RESTART_MS = 60_000;
|
|
13
13
|
export const REPLACEMENT_READY_TIMEOUT_MS = 70_000;
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The env var a restarting process hands its replacement `ocx start`: the restarting (parent) pid.
|
|
17
|
+
*
|
|
18
|
+
* The replacement can probe while its parent still answers on the port it is about to take over
|
|
19
|
+
* (a deadline or listener-stop-fallback handoff spawns before the old listener is gone). Without
|
|
20
|
+
* the marker that answer reads as "a proxy is already running" and the replacement refuses, which
|
|
21
|
+
* leaves no proxy at all once the parent exits. `handleStart` consumes the marker before any probe
|
|
22
|
+
* (`src/cli/restart-handoff.ts`), so no later child of the replacement inherits it.
|
|
23
|
+
*/
|
|
24
|
+
export const RESTART_PARENT_PID_ENV = "OCX_RESTART_PARENT_PID";
|
|
25
|
+
|
|
26
|
+
type RestartEnv = Record<string, string | undefined>;
|
|
27
|
+
|
|
28
|
+
/** A copy of `env` for this process's own replacement, naming `parentPid` as its restart parent. */
|
|
29
|
+
export function withRestartParentMarker<T extends RestartEnv>(env: T, parentPid: number): T {
|
|
30
|
+
const next: RestartEnv = { ...env };
|
|
31
|
+
next[RESTART_PARENT_PID_ENV] = String(parentPid);
|
|
32
|
+
return next as T;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Consume an inherited restart-parent marker. The marker is removed from `env` either way, and the
|
|
37
|
+
* pid is honored only when it is this process's actual parent: a stale or hand-set value names a
|
|
38
|
+
* process that did not spawn this start and never earns the wait.
|
|
39
|
+
*/
|
|
40
|
+
export function takeRestartParentMarker(
|
|
41
|
+
env: RestartEnv,
|
|
42
|
+
actualParentPid: number = process.ppid,
|
|
43
|
+
ownPid: number = process.pid,
|
|
44
|
+
): number | null {
|
|
45
|
+
const raw = env[RESTART_PARENT_PID_ENV]?.trim() ?? "";
|
|
46
|
+
delete env[RESTART_PARENT_PID_ENV];
|
|
47
|
+
if (!/^[1-9]\d{0,9}$/.test(raw)) return null;
|
|
48
|
+
const pid = Number(raw);
|
|
49
|
+
if (!Number.isSafeInteger(pid) || pid === ownPid || pid !== actualParentPid) return null;
|
|
50
|
+
return pid;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The env var the desktop app sets on the `ocx start` it spawns and waits on
|
|
55
|
+
* (`desktop/src-tauri/src/sidecar.rs`). Under it a restart spawns no detached replacement: it marks
|
|
56
|
+
* recycling and exits {@link DESKTOP_RESTART_EXIT_CODE}, and the app starts the replacement itself.
|
|
57
|
+
* A detached grandchild is a process the app can neither see nor stop, and one that failed to start
|
|
58
|
+
* left no proxy until somebody relaunched the app. `handleStart` consumes the marker before its
|
|
59
|
+
* first probe, so no child of the runtime inherits it.
|
|
60
|
+
*/
|
|
61
|
+
export const DESKTOP_SUPERVISED_ENV = "OCX_DESKTOP_SUPERVISED";
|
|
62
|
+
/** EX_TEMPFAIL, "run me again": the desktop's supervisor restarts promptly on exactly this code. */
|
|
63
|
+
export const DESKTOP_RESTART_EXIT_CODE = 75;
|
|
64
|
+
/**
|
|
65
|
+
* The link-mode port-reclaim budget under the desktop app. With the pinned port's prefer-retry after
|
|
66
|
+
* it (`PINNED_PREFER_RETRY_MS` in `src/client/runtime.ts`) a start that cannot bind gives up within
|
|
67
|
+
* 25 seconds, inside the app's 30-second startup deadline, so the app sees it exit instead of giving
|
|
68
|
+
* up on it while it still waits.
|
|
69
|
+
*/
|
|
70
|
+
export const DESKTOP_SUPERVISED_PORT_WAIT_MS = 20_000;
|
|
71
|
+
|
|
72
|
+
/** The desktop app's pid, recorded when the marker was taken; null when nothing supervises this process. */
|
|
73
|
+
let desktopParentPid: number | null = null;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Consume an inherited desktop-supervision marker, removing it from `env` either way. It is recorded
|
|
77
|
+
* only against a real parent process, so {@link isDesktopSupervised} can later check that the same
|
|
78
|
+
* app is still there.
|
|
79
|
+
*/
|
|
80
|
+
export function takeDesktopSupervisedMarker(env: RestartEnv, actualParentPid: number = process.ppid): boolean {
|
|
81
|
+
const raw = env[DESKTOP_SUPERVISED_ENV]?.trim();
|
|
82
|
+
delete env[DESKTOP_SUPERVISED_ENV];
|
|
83
|
+
desktopParentPid = raw === "1" && Number.isSafeInteger(actualParentPid) && actualParentPid > 1
|
|
84
|
+
? actualParentPid
|
|
85
|
+
: null;
|
|
86
|
+
return desktopParentPid !== null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface DesktopSupervisionIo {
|
|
90
|
+
parentPid?: () => number;
|
|
91
|
+
isAlive?: (pid: number) => boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function processExists(pid: number): boolean {
|
|
95
|
+
try {
|
|
96
|
+
process.kill(pid, 0);
|
|
97
|
+
return true;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return (error as NodeJS.ErrnoException | null)?.code === "EPERM";
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Whether the desktop app that started this process is still there to start its replacement: the
|
|
105
|
+
* marker was taken, the parent recorded then is still this process's parent, and it is alive. An app
|
|
106
|
+
* that crashed leaves the runtime re-parented (POSIX) or its parent dead (Windows), and a restart then
|
|
107
|
+
* falls back to the detached replacement instead of exiting into nothing. Read only when a restart
|
|
108
|
+
* or a link-mode start needs it, never on the request path.
|
|
109
|
+
*/
|
|
110
|
+
export function isDesktopSupervised(io: DesktopSupervisionIo = {}): boolean {
|
|
111
|
+
if (desktopParentPid === null) return false;
|
|
112
|
+
if ((io.parentPid ?? (() => process.ppid))() !== desktopParentPid) return false;
|
|
113
|
+
return (io.isAlive ?? processExists)(desktopParentPid);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Test seam: forget a marker a test took. */
|
|
117
|
+
export function resetDesktopSupervisionForTests(): void {
|
|
118
|
+
desktopParentPid = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
15
121
|
const BASE64URL_256 = /^[A-Za-z0-9_-]{43}$/;
|
|
16
122
|
|
|
17
123
|
export type ExpectedSystemRestartPid =
|
|
@@ -847,6 +847,62 @@ export async function refetchAfterProtocolSafeReset(
|
|
|
847
847
|
console.warn("[upstream-retry] protocol-safe refetch rejected" + label + "; preserving original stream error");
|
|
848
848
|
return null;
|
|
849
849
|
}
|
|
850
|
-
console.warn("[upstream-retry] pre-output
|
|
850
|
+
console.warn("[upstream-retry] pre-output stream reset" + label + "; using one replacement stream");
|
|
851
851
|
return replacement;
|
|
852
852
|
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Wrap a streamed body so a reset that arrives before the downstream reader has consumed a single
|
|
856
|
+
* byte swaps in ONE replacement body.
|
|
857
|
+
*
|
|
858
|
+
* The zero-byte gate is the whole reason this wrapper exists: the caller observed nothing, which is
|
|
859
|
+
* the stage where a replacement may even be considered. Every other question -- whether the operator
|
|
860
|
+
* granted one, whether the request is replayable, whether the replacement is a fresh unlocked body
|
|
861
|
+
* that matches the contract already promised to the client -- belongs to
|
|
862
|
+
* {@link refetchAfterProtocolSafeReset}. Delegating rather than re-deciding is what keeps the chat
|
|
863
|
+
* lane from drifting away from the one the Responses stream already uses.
|
|
864
|
+
*
|
|
865
|
+
* Partial output is never masked: once a byte has reached the caller, the original failure stands.
|
|
866
|
+
*/
|
|
867
|
+
export function wrapWithZeroOutputRefetch(
|
|
868
|
+
body: ReadableStream<Uint8Array>,
|
|
869
|
+
doFetch: ProtocolSafeRefetch,
|
|
870
|
+
// `authorize` is optional on the shared options but required here: a zero-output replacement
|
|
871
|
+
// is always a post-header resend, so every caller must name the gate that weighs it.
|
|
872
|
+
opts: ProtocolSafeRefetchOptions & { authorize: () => boolean },
|
|
873
|
+
): ReadableStream<Uint8Array> {
|
|
874
|
+
let reader = body.getReader();
|
|
875
|
+
let bytesRead = 0;
|
|
876
|
+
let retried = false;
|
|
877
|
+
return new ReadableStream<Uint8Array>({
|
|
878
|
+
async pull(controller) {
|
|
879
|
+
for (;;) {
|
|
880
|
+
try {
|
|
881
|
+
const { done, value } = await reader.read();
|
|
882
|
+
if (done) {
|
|
883
|
+
controller.close();
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
bytesRead += value.byteLength;
|
|
887
|
+
controller.enqueue(value);
|
|
888
|
+
return;
|
|
889
|
+
} catch (err) {
|
|
890
|
+
if (!retried && bytesRead === 0 && !opts.abortSignal?.aborted) {
|
|
891
|
+
retried = true;
|
|
892
|
+
const replacement = await refetchAfterProtocolSafeReset(doFetch, err, { ...opts, authorize: opts.authorize });
|
|
893
|
+
if (replacement?.body) {
|
|
894
|
+
try { void reader.cancel().catch(() => {}); } catch { /* broken reader; the replacement won */ }
|
|
895
|
+
reader = replacement.body.getReader();
|
|
896
|
+
continue;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
try { controller.error(err); } catch { /* already torn down */ }
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
cancel(reason) {
|
|
905
|
+
try { void reader.cancel(reason).catch(() => {}); } catch { /* already torn down */ }
|
|
906
|
+
},
|
|
907
|
+
});
|
|
908
|
+
}
|
package/src/link/ports.ts
CHANGED
|
@@ -7,6 +7,15 @@
|
|
|
7
7
|
export const MIN_LINK_PORT = 1024;
|
|
8
8
|
export const MAX_LINK_PORT = 65535;
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* A Child joining from its dashboard picks its tunnel port from this range. It sits below the
|
|
12
|
+
* common OS ephemeral ranges (macOS and Windows 49152-65535, Linux 32768-60999), so an outgoing
|
|
13
|
+
* connection that happens to hold the port rarely blocks the tunnel when it comes back after a
|
|
14
|
+
* reboot. The persisted port of an existing link is never rewritten.
|
|
15
|
+
*/
|
|
16
|
+
export const JOIN_TUNNEL_PORT_MIN = 20_000;
|
|
17
|
+
export const JOIN_TUNNEL_PORT_MAX = 29_999;
|
|
18
|
+
|
|
10
19
|
export function isLinkPort(value: unknown): value is number {
|
|
11
20
|
return typeof value === "number" && Number.isInteger(value) && value >= MIN_LINK_PORT && value <= MAX_LINK_PORT;
|
|
12
21
|
}
|
package/src/link/ssh-argv.ts
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* - Tunnel and exec commands use StrictHostKeyChecking=yes. Only the probe uses accept-new,
|
|
13
13
|
* against an empty temporary file, so the key it records can be shown to the user first.
|
|
14
14
|
* - Forwards always bind 127.0.0.1 on both ends.
|
|
15
|
+
* - Remote ocx runs through `remoteOcxArgv`: a non-interactive ssh session reads no interactive
|
|
16
|
+
* profile, so ~/.bun/bin and Homebrew are usually missing from the remote PATH.
|
|
15
17
|
*/
|
|
16
18
|
|
|
17
19
|
import { isAbsolute } from "node:path";
|
|
@@ -129,6 +131,20 @@ export function buildExecArgv(options: ExecArgvOptions): string[] {
|
|
|
129
131
|
];
|
|
130
132
|
}
|
|
131
133
|
|
|
134
|
+
/**
|
|
135
|
+
* The remote sh script for every ocx call. `quoteRemote` single-quotes it, so the login shell
|
|
136
|
+
* passes it through untouched and `$HOME`/`$PATH` expand in the remote `sh`; the arguments reach
|
|
137
|
+
* ocx as `"$@"` without another round of parsing. The fallback directories are appended, so an
|
|
138
|
+
* ocx the remote PATH already resolves keeps winning. A missing ocx exits 127.
|
|
139
|
+
*/
|
|
140
|
+
export const REMOTE_OCX_SCRIPT = 'PATH="$PATH:$HOME/.bun/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin"; exec ocx "$@"';
|
|
141
|
+
/** The POSIX shell's exit status for a command it could not find. */
|
|
142
|
+
export const REMOTE_COMMAND_NOT_FOUND = 127;
|
|
143
|
+
|
|
144
|
+
export function remoteOcxArgv(args: readonly string[]): string[] {
|
|
145
|
+
return ["sh", "-c", REMOTE_OCX_SCRIPT, "ocx", ...args];
|
|
146
|
+
}
|
|
147
|
+
|
|
132
148
|
export interface ProbeArgvOptions {
|
|
133
149
|
alias: string;
|
|
134
150
|
/** An empty, private, temporary file. The probe records the offered host key here. */
|
package/src/link/ssh-runner.ts
CHANGED
|
@@ -1,4 +1,66 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
|
|
1
3
|
const DEFAULT_OUTPUT_BYTES = 64 * 1024;
|
|
4
|
+
const HINT_MAX_CHARS = 160;
|
|
5
|
+
// Written as escapes on purpose: invisible and bidi controls must never sit literally in source.
|
|
6
|
+
const ANSI_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-?]*[ -/]*[@-~]|\u001b[@-_]/g;
|
|
7
|
+
const HINT_CONTROL = /[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2060-\u206f\ufeff]/g;
|
|
8
|
+
const HINT_SECRET = /\bocx_(data|admin|session|pair)_[A-Za-z0-9_-]+/g;
|
|
9
|
+
const HINT_URL_QUERY = /\b(https?:\/\/[^\s?#]*)[?#]\S*/g;
|
|
10
|
+
|
|
11
|
+
type SpawnEnv = Record<string, string | undefined>;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* PATH for ssh and ssh-keygen children on POSIX. A desktop sidecar inherits launchd's minimal
|
|
15
|
+
* PATH, so a ProxyCommand helper installed by Homebrew or into ~/.bun/bin would not resolve.
|
|
16
|
+
* Inherited entries keep their order and precedence; the common helper directories are appended
|
|
17
|
+
* once. Windows keeps its inherited environment untouched and gets undefined.
|
|
18
|
+
*/
|
|
19
|
+
export function linkSshPath(env: SpawnEnv = process.env, platform: NodeJS.Platform = process.platform): string | undefined {
|
|
20
|
+
if (platform === "win32") return undefined;
|
|
21
|
+
const home = env.HOME || homedir();
|
|
22
|
+
const extra = ["/opt/homebrew/bin", "/usr/local/bin", ...(home ? [`${home}/.bun/bin`, `${home}/.local/bin`] : [])];
|
|
23
|
+
return [...new Set([...(env.PATH ?? "").split(":").filter(Boolean), ...extra])].join(":");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The environment ssh runs with: the inherited one with `linkSshPath`, or undefined on Windows. */
|
|
27
|
+
export function linkSshSpawnEnv(env: SpawnEnv = process.env, platform: NodeJS.Platform = process.platform): SpawnEnv | undefined {
|
|
28
|
+
const path = linkSshPath(env, platform);
|
|
29
|
+
return path === undefined ? undefined : { ...env, PATH: path };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* One hint line as the dashboard may see it: control and bidi characters removed, OpenCodex
|
|
34
|
+
* secrets and URL queries redacted, whitespace collapsed, capped at 160 code points. The cut is
|
|
35
|
+
* made between code points, so an astral character is never split into a lone surrogate.
|
|
36
|
+
*/
|
|
37
|
+
export function boundHint(line: string): string | undefined {
|
|
38
|
+
const clean = line.replace(HINT_CONTROL, " ").replace(HINT_SECRET, "ocx_$1_[redacted]")
|
|
39
|
+
.replace(HINT_URL_QUERY, "$1").replace(/\s+/g, " ").trim();
|
|
40
|
+
if (!clean) return undefined;
|
|
41
|
+
const points = Array.from(clean);
|
|
42
|
+
return points.length > HINT_MAX_CHARS ? `${points.slice(0, HINT_MAX_CHARS - 1).join("")}\u2026` : clean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A short reason for a failed ssh command: the last non-empty stderr line, with terminal escapes,
|
|
47
|
+
* control and bidi characters removed, OpenCodex secrets and URL queries redacted, capped at 160
|
|
48
|
+
* code points. It reads stderr only; stdout and stdin can carry keys. Callers return it to the
|
|
49
|
+
* dashboard and never log it.
|
|
50
|
+
*/
|
|
51
|
+
export function sshFailureHint(stderr: string): string | undefined {
|
|
52
|
+
const lines = stderr.replace(ANSI_SEQUENCE, "").split(/\r\n|\r|\n/);
|
|
53
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
54
|
+
const hint = boundHint(lines[index]!);
|
|
55
|
+
if (hint) return hint;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The runner's own failure (spawn, timeout, output limit) as a hint; other errors give none. */
|
|
61
|
+
export function sshRunnerErrorHint(error: unknown): string | undefined {
|
|
62
|
+
return error instanceof SshRunnerError ? boundHint(error.message) : undefined;
|
|
63
|
+
}
|
|
2
64
|
|
|
3
65
|
export interface SshRunResult {
|
|
4
66
|
code: number;
|
|
@@ -71,14 +133,19 @@ function defaultKill(process: Bun.Subprocess, signal?: NodeJS.Signals): void {
|
|
|
71
133
|
process.kill(signal);
|
|
72
134
|
}
|
|
73
135
|
|
|
74
|
-
export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: number } = {}): SshRunner {
|
|
136
|
+
export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: number; env?: () => SpawnEnv | undefined } = {}): SshRunner {
|
|
75
137
|
const spawn = deps.spawn ?? ((argv, options) => Bun.spawn(argv, options));
|
|
76
138
|
const defaultTimeoutMs = deps.timeoutMs ?? 30_000;
|
|
139
|
+
// Read per spawn so a PATH change reaches the next command; Windows passes no env key at all.
|
|
140
|
+
const spawnEnv = (): { env?: SpawnEnv } => {
|
|
141
|
+
const env = (deps.env ?? (() => linkSshSpawnEnv()))();
|
|
142
|
+
return env ? { env } : {};
|
|
143
|
+
};
|
|
77
144
|
|
|
78
145
|
const spawnChild = (argv: readonly string[]): SshChild => {
|
|
79
146
|
let child: Bun.Subprocess;
|
|
80
147
|
try {
|
|
81
|
-
child = spawn([...argv], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
148
|
+
child = spawn([...argv], { stdin: "ignore", stdout: "pipe", stderr: "pipe", ...spawnEnv() });
|
|
82
149
|
} catch (error) {
|
|
83
150
|
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
|
84
151
|
}
|
|
@@ -108,6 +175,7 @@ export function createSshRunner(deps: { spawn?: typeof Bun.spawn; timeoutMs?: nu
|
|
|
108
175
|
stdin: options.stdin === undefined ? "ignore" : "pipe",
|
|
109
176
|
stdout: "pipe",
|
|
110
177
|
stderr: "pipe",
|
|
178
|
+
...spawnEnv(),
|
|
111
179
|
});
|
|
112
180
|
} catch (error) {
|
|
113
181
|
throw new SshRunnerError("spawn", `could not spawn ${argv[0] ?? "ssh"}`, { cause: error });
|
package/src/link/tunnel-state.ts
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
* - connected: the forward is up.
|
|
6
6
|
* - reconnecting: a transient failure; requests through the link fail with 503 meanwhile, and a
|
|
7
7
|
* new attempt is due at `retryAt`.
|
|
8
|
-
* - failed:
|
|
9
|
-
*
|
|
8
|
+
* - failed: auth, host key and forward failures, and a link that stayed down for FAILED_AFTER_MS.
|
|
9
|
+
* Without a retry policy (the Home's `-R` supervisor) it needs the user. With one (the Child's
|
|
10
|
+
* own `-L` tunnel) a reason that has a delay is tried again at `retryAt`, and the retry attempt
|
|
11
|
+
* runs with `inFlight` while the state still reads failed; a reason without a delay stays
|
|
12
|
+
* terminal.
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
export type TunnelFailure = "auth" | "hostkey" | "forward" | "timeout";
|
|
@@ -17,7 +20,7 @@ export type TunnelState =
|
|
|
17
20
|
| { kind: "connecting"; since: number }
|
|
18
21
|
| { kind: "connected"; since: number }
|
|
19
22
|
| { kind: "reconnecting"; since: number; attempt: number; retryAt: number; inFlight: boolean }
|
|
20
|
-
| { kind: "failed"; since: number; reason: TunnelFailure };
|
|
23
|
+
| { kind: "failed"; since: number; reason: TunnelFailure; retryAt?: number; inFlight?: boolean };
|
|
21
24
|
|
|
22
25
|
export type TunnelEvent =
|
|
23
26
|
| { type: "spawn"; now: number }
|
|
@@ -26,10 +29,25 @@ export type TunnelEvent =
|
|
|
26
29
|
| { type: "tick"; now: number }
|
|
27
30
|
| { type: "stop" };
|
|
28
31
|
|
|
32
|
+
/** Opt-in retry of a failed tunnel: the delay before each failure reason is tried again. */
|
|
33
|
+
export interface TunnelRetryPolicy {
|
|
34
|
+
retryFailedAfterMs: Partial<Record<TunnelFailure, number>>;
|
|
35
|
+
}
|
|
36
|
+
|
|
29
37
|
export const FAILED_AFTER_MS = 5 * 60_000;
|
|
30
38
|
export const BASE_DELAY_MS = 1_000;
|
|
31
39
|
export const MAX_DELAY_MS = 30_000;
|
|
32
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The client-owned tunnel's policy. Timeout and forward failures retry about once a minute. Auth
|
|
43
|
+
* retries every five minutes, and because each auth failure schedules the next attempt five
|
|
44
|
+
* minutes out, no more than 12 attempts reach the Home's sshd in an hour. A changed host key is a
|
|
45
|
+
* security signal and is never retried.
|
|
46
|
+
*/
|
|
47
|
+
export const CLIENT_TUNNEL_RETRY_POLICY: TunnelRetryPolicy = {
|
|
48
|
+
retryFailedAfterMs: { timeout: 60_000, forward: 60_000, auth: 5 * 60_000 },
|
|
49
|
+
};
|
|
50
|
+
|
|
33
51
|
export const IDLE: TunnelState = { kind: "idle" };
|
|
34
52
|
|
|
35
53
|
/** Capped exponential backoff with ±20% jitter. `random` is injectable for tests. */
|
|
@@ -40,22 +58,52 @@ export function nextDelayMs(attempt: number, random: () => number = Math.random)
|
|
|
40
58
|
return Math.round(Math.min(MAX_DELAY_MS, base * jitter));
|
|
41
59
|
}
|
|
42
60
|
|
|
43
|
-
|
|
61
|
+
/** A failed state; under a policy that retries `reason` it carries the next attempt's time. */
|
|
62
|
+
export function failedTunnel(
|
|
63
|
+
reason: TunnelFailure,
|
|
64
|
+
now: number,
|
|
65
|
+
policy?: TunnelRetryPolicy,
|
|
66
|
+
since: number = now,
|
|
67
|
+
): TunnelState {
|
|
68
|
+
const delay = policy?.retryFailedAfterMs[reason];
|
|
69
|
+
return delay === undefined
|
|
70
|
+
? { kind: "failed", since, reason }
|
|
71
|
+
: { kind: "failed", since, reason, retryAt: now + delay, inFlight: false };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function failureOf(cls: StderrClass): TunnelFailure | null {
|
|
75
|
+
return cls === "auth" || cls === "hostkey" || cls === "forward" ? cls : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function reduceTunnel(
|
|
79
|
+
state: TunnelState,
|
|
80
|
+
event: TunnelEvent,
|
|
81
|
+
random?: () => number,
|
|
82
|
+
policy?: TunnelRetryPolicy,
|
|
83
|
+
): TunnelState {
|
|
44
84
|
if (event.type === "stop") return IDLE;
|
|
45
85
|
switch (event.type) {
|
|
46
86
|
case "spawn":
|
|
87
|
+
if (state.kind === "failed" && state.retryAt !== undefined) return { ...state, inFlight: true };
|
|
47
88
|
if (state.kind === "idle" || state.kind === "failed") return { kind: "connecting", since: event.now };
|
|
48
89
|
if (state.kind === "reconnecting") return { ...state, inFlight: true };
|
|
49
90
|
return state;
|
|
50
91
|
case "ready":
|
|
51
92
|
if (state.kind === "connecting" || state.kind === "reconnecting") return { kind: "connected", since: event.now };
|
|
93
|
+
if (state.kind === "failed" && state.inFlight) return { kind: "connected", since: event.now };
|
|
52
94
|
return state;
|
|
53
95
|
case "exit": {
|
|
54
|
-
if (state.kind === "idle"
|
|
55
|
-
|
|
56
|
-
|
|
96
|
+
if (state.kind === "idle") return state;
|
|
97
|
+
if (state.kind === "failed") {
|
|
98
|
+
if (!state.inFlight) return state;
|
|
99
|
+
// A retry of a failed link failed again. It stays failed from the original moment; a
|
|
100
|
+
// transient exit keeps the slow cadence (timeout) instead of restarting fast backoff.
|
|
101
|
+
return failedTunnel(failureOf(event.stderrClass) ?? "timeout", event.now, policy, state.since);
|
|
102
|
+
}
|
|
103
|
+
const failure = failureOf(event.stderrClass);
|
|
104
|
+
if (failure) return failedTunnel(failure, event.now, policy);
|
|
57
105
|
const since = state.kind === "reconnecting" || state.kind === "connecting" ? state.since : event.now;
|
|
58
|
-
if (event.now - since >= FAILED_AFTER_MS) return
|
|
106
|
+
if (event.now - since >= FAILED_AFTER_MS) return failedTunnel("timeout", event.now, policy);
|
|
59
107
|
const attempt = state.kind === "reconnecting" ? state.attempt + 1 : 1;
|
|
60
108
|
return { kind: "reconnecting", since, attempt, retryAt: event.now + nextDelayMs(attempt, random), inFlight: false };
|
|
61
109
|
}
|
|
@@ -63,7 +111,7 @@ export function reduceTunnel(state: TunnelState, event: TunnelEvent, random?: ()
|
|
|
63
111
|
// An attempt in flight does not pause the clock: a first attempt or a retry that hangs past
|
|
64
112
|
// the limit still fails the link, and the supervisor kills the child on seeing `failed`.
|
|
65
113
|
if ((state.kind === "reconnecting" || state.kind === "connecting") && event.now - state.since >= FAILED_AFTER_MS) {
|
|
66
|
-
return
|
|
114
|
+
return failedTunnel("timeout", event.now, policy);
|
|
67
115
|
}
|
|
68
116
|
return state;
|
|
69
117
|
}
|
|
@@ -71,6 +119,7 @@ export function reduceTunnel(state: TunnelState, event: TunnelEvent, random?: ()
|
|
|
71
119
|
|
|
72
120
|
/** Whether the supervisor should start a new ssh attempt now. */
|
|
73
121
|
export function dueForSpawn(state: TunnelState, now: number): boolean {
|
|
122
|
+
if (state.kind === "failed") return state.retryAt !== undefined && !state.inFlight && now >= state.retryAt;
|
|
74
123
|
return state.kind === "reconnecting" && !state.inFlight && now >= state.retryAt;
|
|
75
124
|
}
|
|
76
125
|
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
* serve the retry" — and within the healthy group a simple headroom sort is enough.
|
|
12
12
|
*/
|
|
13
13
|
import { getCachedProviderAccountQuota, hasPassiveAccountQuota } from "../providers/quota";
|
|
14
|
-
import {
|
|
14
|
+
import { kiroAccountEvidence } from "../providers/kiro-usage";
|
|
15
|
+
import type { ProviderAccount } from "./types";
|
|
15
16
|
|
|
16
17
|
/** Antigravity hosts Gemini and Claude windows on one account; ranking must not mix them. */
|
|
17
18
|
export type QuotaModelFamily = "gem" | "cla";
|
|
@@ -83,8 +84,11 @@ const PASSIVE_HEADROOM_MAX_AGE_MS = 60 * 60_000;
|
|
|
83
84
|
* The minimum wins: an account at 5% of its five-hour window is unusable right now even if
|
|
84
85
|
* its monthly allowance is barely touched.
|
|
85
86
|
*/
|
|
86
|
-
function headroomOf(provider: string, accountId: string, requestedModelId?: string | null): number | null {
|
|
87
|
-
const
|
|
87
|
+
function headroomOf(provider: string, accountId: string, requestedModelId?: string | null, account?: ProviderAccount): number | null {
|
|
88
|
+
const evidence = provider === "kiro" && account ? kiroAccountEvidence(account) : null;
|
|
89
|
+
const quota = provider === "kiro"
|
|
90
|
+
? evidence?.quotaPercent === undefined ? null : { monthlyPercent: evidence.quotaPercent, updatedAt: Date.now() }
|
|
91
|
+
: getCachedProviderAccountQuota(provider, accountId);
|
|
88
92
|
if (!quota) return null;
|
|
89
93
|
// Null, not a low rank: this must reproduce "no evidence" so a stale roster degrades to
|
|
90
94
|
// the unranked ring rather than to a differently wrong answer.
|
|
@@ -119,8 +123,9 @@ export function accountHeadroomPercent(
|
|
|
119
123
|
provider: string,
|
|
120
124
|
accountId: string,
|
|
121
125
|
requestedModelId?: string | null,
|
|
126
|
+
account?: ProviderAccount,
|
|
122
127
|
): number | null {
|
|
123
|
-
return headroomOf(provider, accountId, requestedModelId);
|
|
128
|
+
return headroomOf(provider, accountId, requestedModelId, account);
|
|
124
129
|
}
|
|
125
130
|
|
|
126
131
|
/** Unknown usage is not exhaustion; Kiro's explicit overage verdict is authoritative. */
|
|
@@ -128,10 +133,10 @@ export function isAccountQuotaExhausted(
|
|
|
128
133
|
provider: string,
|
|
129
134
|
accountId: string,
|
|
130
135
|
requestedModelId?: string | null,
|
|
136
|
+
account?: ProviderAccount,
|
|
131
137
|
): boolean {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const headroom = headroomOf(provider, accountId, requestedModelId);
|
|
138
|
+
if (provider === "kiro") return account ? kiroAccountEvidence(account).exhausted === true : false;
|
|
139
|
+
const headroom = headroomOf(provider, accountId, requestedModelId, account);
|
|
135
140
|
return headroom !== null && headroom <= 0;
|
|
136
141
|
}
|
|
137
142
|
|
|
@@ -145,6 +150,7 @@ export function rankAccountsByHeadroom(
|
|
|
145
150
|
provider: string,
|
|
146
151
|
ring: readonly string[],
|
|
147
152
|
requestedModelId?: string | null,
|
|
153
|
+
accounts?: ReadonlyMap<string, ProviderAccount>,
|
|
148
154
|
): string[] {
|
|
149
155
|
if (ring.length < 2) return [...ring];
|
|
150
156
|
|
|
@@ -158,11 +164,14 @@ export function rankAccountsByHeadroom(
|
|
|
158
164
|
const ranked: Ranked[] = ring.map((id, index) => {
|
|
159
165
|
// A provider-declared exhaustion verdict outranks the percentage: an account may sit at
|
|
160
166
|
// 100% and still be servable when overage is enabled, and the verdict knows that.
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
const account = accounts?.get(id);
|
|
168
|
+
const exhaustion = provider === "kiro" && account ? kiroAccountEvidence(account).exhausted : undefined;
|
|
169
|
+
const headroom = headroomOf(provider, id, requestedModelId, account);
|
|
170
|
+
if (exhaustion !== undefined || headroom !== null) sawEvidence = true;
|
|
171
|
+
|
|
172
|
+
if (isAccountQuotaExhausted(provider, id, requestedModelId, account)
|
|
173
|
+
|| (provider === "kiro" && exhaustion === undefined && headroom !== null && headroom <= 0))
|
|
174
|
+
return { id, bucket: RANK_EXHAUSTED, headroom: 0, index };
|
|
166
175
|
if (headroom === null) return { id, bucket: RANK_UNKNOWN, headroom: 0, index };
|
|
167
176
|
return { id, bucket: RANK_HEALTHY, headroom, index };
|
|
168
177
|
});
|
|
@@ -186,6 +195,7 @@ export function hasHeadroomEvidence(
|
|
|
186
195
|
provider: string,
|
|
187
196
|
ids: readonly string[],
|
|
188
197
|
requestedModelId?: string | null,
|
|
198
|
+
accounts?: ReadonlyMap<string, ProviderAccount>,
|
|
189
199
|
): boolean {
|
|
190
200
|
// A PASSIVE provider needs evidence for EVERY candidate, not any one of them.
|
|
191
201
|
//
|
|
@@ -200,8 +210,9 @@ export function hasHeadroomEvidence(
|
|
|
200
210
|
return ids.length > 0 && ids.every(id => headroomOf(provider, id, requestedModelId) !== null);
|
|
201
211
|
}
|
|
202
212
|
return ids.some(id =>
|
|
203
|
-
headroomOf(provider, id, requestedModelId) !== null
|
|
204
|
-
|| (provider === "kiro" &&
|
|
213
|
+
headroomOf(provider, id, requestedModelId, accounts?.get(id)) !== null
|
|
214
|
+
|| (provider === "kiro" && accounts?.get(id) !== undefined
|
|
215
|
+
&& kiroAccountEvidence(accounts.get(id)!).exhausted !== undefined));
|
|
205
216
|
}
|
|
206
217
|
/**
|
|
207
218
|
* How long to cool an account that just 429'd, when we know its allowance is spent.
|
|
@@ -213,10 +224,10 @@ export function hasHeadroomEvidence(
|
|
|
213
224
|
const MIN_EXHAUSTED_COOLDOWN_MS = 5 * 60_000;
|
|
214
225
|
const MAX_EXHAUSTED_COOLDOWN_MS = 24 * 60 * 60_000;
|
|
215
226
|
|
|
216
|
-
export function exhaustedCooldownMs(provider: string, accountId: string, now = Date.now()): number | null {
|
|
227
|
+
export function exhaustedCooldownMs(provider: string, accountId: string, now = Date.now(), account?: ProviderAccount): number | null {
|
|
217
228
|
if (provider !== "kiro") return null;
|
|
218
|
-
const
|
|
219
|
-
if (!
|
|
220
|
-
const untilReset =
|
|
229
|
+
const evidence = account ? kiroAccountEvidence(account, now) : {};
|
|
230
|
+
if (!evidence.exhausted) return null;
|
|
231
|
+
const untilReset = evidence.resetAt === undefined ? MIN_EXHAUSTED_COOLDOWN_MS : evidence.resetAt - now;
|
|
221
232
|
return Math.min(Math.max(untilReset, MIN_EXHAUSTED_COOLDOWN_MS), MAX_EXHAUSTED_COOLDOWN_MS);
|
|
222
233
|
}
|