@bitkyc08/opencodex 2.42.0 → 2.43.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/AGENTS_INSTALL.md +2 -2
- package/README.md +31 -0
- package/bin/ocx.mjs +10 -1
- package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
- package/gui/dist/assets/index-Djowl68T.js +112 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-codec.ts +304 -0
- package/src/adapters/anthropic-image-normalize.ts +8 -298
- package/src/adapters/anthropic.ts +30 -7
- package/src/adapters/command-code.ts +7 -5
- package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
- package/src/adapters/cursor/images.ts +36 -6
- package/src/adapters/cursor/live-transport.ts +7 -2
- package/src/adapters/cursor/native-exec-desktop.ts +2 -15
- package/src/adapters/cursor/protobuf-request.ts +13 -3
- package/src/adapters/cursor/tool-definitions.ts +5 -670
- package/src/adapters/cursor/tool-guidance.ts +236 -0
- package/src/adapters/cursor/tool-naming.ts +252 -0
- package/src/adapters/cursor/tool-schemas.ts +195 -0
- package/src/adapters/cursor/types.ts +6 -3
- package/src/adapters/exec-tool-result-normalize.ts +1 -1
- package/src/adapters/google-errors.ts +9 -1
- package/src/adapters/google.ts +1 -0
- package/src/adapters/kiro-calibration.ts +181 -0
- package/src/adapters/kiro.ts +135 -3
- package/src/adapters/openai-responses.ts +216 -26
- package/src/adapters/responses-code-mode.ts +59 -0
- package/src/adapters/tool-catalog-nudge.ts +1 -1
- package/src/adapters/xai-schema-analysis.ts +86 -0
- package/src/adapters/xai-tool-schema.ts +2 -87
- package/src/adapters/xai-web-search.ts +1 -1
- package/src/bridge.ts +22 -10
- package/src/chat/inbound.ts +11 -3
- package/src/claude/inbound-content-options.ts +60 -0
- package/src/claude/inbound-model-options.ts +142 -0
- package/src/claude/inbound-records.ts +7 -0
- package/src/claude/inbound.ts +10 -202
- package/src/claude/model-info.ts +45 -0
- package/src/cli/account-auth.ts +21 -6
- package/src/cli/capabilities.ts +13 -2
- package/src/cli/claude.ts +232 -39
- package/src/cli/config-command.ts +9 -1
- package/src/cli/dispatch.ts +5 -1
- package/src/cli/doctor.ts +10 -0
- package/src/cli/effort.ts +372 -0
- package/src/cli/export-command.ts +3 -9
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +13 -0
- package/src/cli/init.ts +4 -0
- package/src/cli/model-selection-guidance.ts +30 -0
- package/src/cli/models-runtime.ts +3 -2
- package/src/cli/models.ts +8 -3
- package/src/cli/opencode.ts +4 -1
- package/src/cli/provider-runtime.ts +65 -0
- package/src/cli/provider.ts +8 -0
- package/src/cli/registry.ts +16 -2
- package/src/cli/runtime-api.ts +3 -1
- package/src/cli/star-prompt.ts +22 -6
- package/src/cli/status-probes.ts +168 -0
- package/src/cli/status.ts +5 -168
- package/src/clients/config-export/constants.ts +69 -0
- package/src/clients/config-export/contracts.ts +154 -0
- package/src/clients/config-export/dsh.ts +132 -0
- package/src/clients/config-export/fast-models.ts +29 -0
- package/src/clients/config-export/mcode.ts +83 -0
- package/src/clients/config-export/model-metadata.ts +108 -0
- package/src/clients/config-export/omp.ts +104 -0
- package/src/clients/config-export/zcode.ts +92 -0
- package/src/clients/config-export.ts +18 -710
- package/src/codex/account-lifecycle.ts +20 -3
- package/src/codex/account-usability.ts +2 -0
- package/src/codex/auth-api.ts +170 -23
- package/src/codex/auth-context.ts +328 -24
- package/src/codex/catalog/effort.ts +30 -4
- package/src/codex/catalog/metadata.ts +39 -9
- package/src/codex/catalog/native-models.ts +53 -12
- package/src/codex/catalog/parsing.ts +119 -5
- package/src/codex/catalog/provider-fetch.ts +150 -20
- package/src/codex/catalog/reserve.ts +52 -0
- package/src/codex/catalog/sync.ts +89 -16
- package/src/codex/convergence-types.ts +1 -0
- package/src/codex/convergence.ts +2 -0
- package/src/codex/data/upstream-models.json +1 -1
- package/src/codex/forward-transport-headers.ts +25 -0
- package/src/codex/inject.ts +3 -28
- package/src/codex/legacy-config-keys.ts +68 -0
- package/src/codex/log-guard/inspect-schema.ts +137 -0
- package/src/codex/log-guard/inspect.ts +2 -134
- package/src/codex/loopback-target.ts +54 -0
- package/src/codex/main-account-cache.ts +63 -1
- package/src/codex/main-account-hard-lock.ts +52 -0
- package/src/codex/main-account.ts +3 -1
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/model-entitlements.ts +54 -4
- package/src/codex/project-config-warnings.ts +92 -2
- package/src/codex/prompt-layers/encoding.ts +80 -0
- package/src/codex/prompt-layers/paths.ts +54 -0
- package/src/codex/prompt-layers/revision.ts +55 -0
- package/src/codex/prompt-layers/toml-edit.ts +163 -0
- package/src/codex/prompt-layers/toml-read.ts +181 -0
- package/src/codex/prompt-layers.ts +14 -520
- package/src/codex/quota-auto-refresh-state.ts +16 -0
- package/src/codex/quota-auto-refresh.ts +219 -0
- package/src/codex/quota-types.ts +51 -0
- package/src/codex/quota.ts +252 -93
- package/src/codex/reserve-availability.ts +177 -0
- package/src/codex/routing.ts +28 -9
- package/src/codex/shim.ts +53 -11
- package/src/codex/subagent-model-fallback.ts +23 -3
- package/src/combos/failover.ts +125 -7
- package/src/combos/identifiers.ts +89 -0
- package/src/combos/index.ts +4 -0
- package/src/combos/resolve.ts +80 -9
- package/src/combos/types.ts +20 -93
- package/src/config/subagent-models.ts +24 -0
- package/src/config.ts +156 -13
- package/src/generated/compatibility-version.json +454 -166
- package/src/generated/model-metadata.ts +1 -1
- package/src/integrations/journal.ts +65 -4
- package/src/integrations/store.ts +5 -0
- package/src/lab/events/limits.ts +4 -0
- package/src/lib/destination-policy.ts +31 -2
- package/src/lib/errors.ts +39 -0
- package/src/lib/provider-outbound.ts +69 -3
- package/src/lib/proxy-env.ts +22 -0
- package/src/lib/redact-folding.ts +176 -0
- package/src/lib/redact.ts +2 -175
- package/src/lib/state-store-sweeper.ts +20 -6
- package/src/lib/token-estimate.ts +94 -27
- package/src/lib/windows-user-principal.ts +53 -5
- package/src/oauth/anthropic-routing.ts +99 -3
- package/src/oauth/generic-account-failover.ts +36 -13
- package/src/oauth/index.ts +127 -26
- package/src/oauth/login-cli.ts +5 -0
- package/src/oauth/meta-muse.ts +117 -15
- package/src/oauth/pool-settings-capability.ts +15 -4
- package/src/providers/api-keys.ts +8 -10
- package/src/providers/default-aliases.ts +39 -0
- package/src/providers/derive.ts +10 -2
- package/src/providers/fastwire.ts +36 -7
- package/src/providers/initial-model-selection-runtime.ts +90 -0
- package/src/providers/initial-model-selection.ts +120 -0
- package/src/providers/key-failover.ts +134 -54
- package/src/providers/key-store.ts +11 -1
- package/src/providers/label.ts +1 -1
- package/src/providers/model-discovery.ts +76 -0
- package/src/providers/model-rename-startup.ts +72 -8
- package/src/providers/openai-sidecar.ts +17 -5
- package/src/providers/openai-tiers-destination.ts +102 -0
- package/src/providers/openai-tiers.ts +2 -99
- package/src/providers/opencode-go-transport.ts +41 -0
- package/src/providers/quota-key-accounts.ts +141 -0
- package/src/providers/quota-types.ts +9 -0
- package/src/providers/quota.ts +535 -104
- package/src/providers/registry.ts +60 -17
- package/src/providers/xai-responses-opt-in.ts +31 -5
- package/src/quota/reset-activation.ts +81 -0
- package/src/quota/reset-detector.ts +305 -0
- package/src/quota/reset-notify-config.ts +162 -0
- package/src/quota/reset-observer.ts +125 -0
- package/src/quota/reset-poller.ts +160 -0
- package/src/quota/reset-seen-store.ts +385 -0
- package/src/quota/reset-sinks.ts +199 -0
- package/src/quota/window-mapping.ts +106 -0
- package/src/responses/apply-patch-envelope.ts +46 -0
- package/src/responses/code-mode-helper-compat.ts +39 -1
- package/src/responses/custom-tool-compat.ts +10 -4
- package/src/responses/hosted-tool-policy.ts +12 -4
- package/src/responses/parser-content.ts +133 -0
- package/src/responses/parser-text-format.ts +24 -0
- package/src/responses/parser-tools.ts +188 -0
- package/src/responses/parser.ts +3 -326
- package/src/responses/state.ts +124 -28
- package/src/router.ts +48 -13
- package/src/routing/analytics.ts +1 -0
- package/src/routing/capability.ts +17 -4
- package/src/server/auth-cors.ts +7 -1
- package/src/server/background-lifecycle.ts +23 -1
- package/src/server/chat-completions.ts +25 -3
- package/src/server/claude-messages.ts +62 -5
- package/src/server/effort-row.ts +1 -1
- package/src/server/fast-row.ts +295 -0
- package/src/server/gui-static.ts +30 -4
- package/src/server/index.ts +107 -21
- package/src/server/management/agent-settings-routes.ts +2 -2
- package/src/server/management/combo-routes.ts +37 -9
- package/src/server/management/config-routes.ts +93 -2
- package/src/server/management/integration-routes.ts +108 -0
- package/src/server/management/model-routes.ts +13 -3
- package/src/server/management/model-rows.ts +20 -1
- package/src/server/management/native-integration-routes.ts +4 -1
- package/src/server/management/oauth-account-routes.ts +38 -10
- package/src/server/management/provider-routes.ts +34 -3
- package/src/server/management/quota-reset-routes.ts +57 -0
- package/src/server/management/route-registry.ts +7 -4
- package/src/server/management/shared.ts +19 -5
- package/src/server/management/system-routes.ts +3 -2
- package/src/server/management-api.ts +14 -2
- package/src/server/ports.ts +12 -2
- package/src/server/relay-eager.ts +38 -23
- package/src/server/relay.ts +4 -0
- package/src/server/request-log.ts +6 -0
- package/src/server/responses/codex-ws-correlation.ts +65 -0
- package/src/server/responses/codex-ws-exchange.ts +261 -0
- package/src/server/responses/codex-ws-metadata.ts +134 -0
- package/src/server/responses/codex-ws-pool.ts +162 -0
- package/src/server/responses/codex-ws-request.ts +87 -0
- package/src/server/responses/codex-ws-session.ts +93 -0
- package/src/server/responses/codex-ws-wire.ts +144 -0
- package/src/server/responses/collaboration.ts +41 -1
- package/src/server/responses/compact.ts +105 -12
- package/src/server/responses/core.ts +476 -57
- package/src/server/responses/fetch-helpers.ts +10 -3
- package/src/server/responses/input-admission.ts +16 -9
- package/src/server/responses/responses-field-backfill.ts +1 -1
- package/src/server/responses/ws-upstream.ts +34 -318
- package/src/server/responses-custom-tool-repair.ts +20 -4
- package/src/server/responses-undeclared-tool-guard.ts +100 -8
- package/src/server/safe-response-headers.ts +23 -0
- package/src/server/search.ts +9 -0
- package/src/server/subagent-models-startup.ts +27 -0
- package/src/server/system-env-shell.ts +238 -0
- package/src/server/system-env.ts +7 -234
- package/src/server/ws-bridge.ts +3 -25
- package/src/server/xai-responses-startup.ts +21 -0
- package/src/service-manager-probe.ts +1 -1
- package/src/service.ts +55 -16
- package/src/types/config.ts +108 -12
- package/src/types/provider.ts +36 -7
- package/src/types/tools.ts +26 -1
- package/src/types.ts +2 -0
- package/src/update/notify.ts +8 -2
- package/src/usage/cost.ts +38 -28
- package/src/usage/expected-prices.ts +34 -15
- package/src/usage/log.ts +2 -0
- package/src/usage/model-identity.ts +26 -0
- package/src/usage/summary.ts +15 -1
- package/src/vision/describe.ts +6 -0
- package/src/vision/image-rewrite.ts +108 -0
- package/src/vision/index.ts +19 -306
- package/src/vision/plan.ts +205 -0
- package/src/web-search/executor.ts +6 -0
- package/src/web-search/index.ts +8 -1
- package/gui/dist/assets/index-BU1tE0sr.js +0 -112
- package/gui/dist/assets/index-DL9-iS6J.css +0 -1
|
@@ -30,7 +30,7 @@ import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/ke
|
|
|
30
30
|
import { deriveProviderPresets } from "../../providers/derive";
|
|
31
31
|
import { providerCodexAccountMode } from "../../providers/registry";
|
|
32
32
|
import { routedSlug, slugEquals } from "../../providers/slug-codec";
|
|
33
|
-
import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderQuotaReports,
|
|
33
|
+
import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderAccountQuotas, fetchProviderApiKeyQuotas, fetchProviderQuotaReports, providerOAuthAccountQuotaMode, providerApiKeyQuotaMode, readPassiveProviderAccountQuotas } from "../../providers/quota";
|
|
34
34
|
import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
|
|
35
35
|
import { clearThreadAccountMap } from "../../codex/routing";
|
|
36
36
|
import {
|
|
@@ -254,7 +254,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
254
254
|
if (url.pathname === "/api/oauth/accounts" && req.method === "GET") {
|
|
255
255
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
256
256
|
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
257
|
-
const
|
|
257
|
+
const quotaMode = providerOAuthAccountQuotaMode(provider);
|
|
258
|
+
const quotaProvider = config.providers[provider];
|
|
258
259
|
const { getAccountSet } = await import("../../oauth/store");
|
|
259
260
|
const {
|
|
260
261
|
oauthAccountHealthFields,
|
|
@@ -274,26 +275,26 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
274
275
|
needsReauth: summary.needsReauth === true,
|
|
275
276
|
reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
|
|
276
277
|
});
|
|
277
|
-
return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health) };
|
|
278
|
+
return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode };
|
|
278
279
|
}),
|
|
279
280
|
};
|
|
280
281
|
};
|
|
281
282
|
// Per-account rate limits: Anthropic reports usage per credential, so every logged-in
|
|
282
283
|
// account can show its own 5h/weekly bars (not just the active one). Opt-in via ?quota=1
|
|
283
284
|
// so the plain account list stays a cheap local read; ?refresh=1 bypasses the TTL.
|
|
284
|
-
const wantQuota = url.searchParams.get("quota") === "1" &&
|
|
285
|
+
const wantQuota = url.searchParams.get("quota") === "1" && quotaMode === "probe";
|
|
285
286
|
// Meta publishes no quota endpoint: its usage is observed in-band on streaming turns
|
|
286
287
|
// and read back from the cache here. `?refresh=1` is accepted and ignored on this
|
|
287
288
|
// path rather than rejected -- the GUI sends it for every provider on a manual
|
|
288
289
|
// refresh, and a 400 would report an error for what is simply a no-op.
|
|
289
|
-
const passiveQuota = url.searchParams.get("quota") === "1" &&
|
|
290
|
+
const passiveQuota = url.searchParams.get("quota") === "1" && quotaMode === "passive";
|
|
290
291
|
if (!wantQuota && !passiveQuota) return jsonResponse(projectAccounts());
|
|
291
292
|
const forceRefresh = url.searchParams.get("refresh") === "1";
|
|
292
293
|
// Probing may refresh the active credential and mark needsReauth — project health
|
|
293
294
|
// from the post-probe store so the response is not stale.
|
|
294
295
|
const rows = passiveQuota
|
|
295
296
|
? readPassiveProviderAccountQuotas(provider)
|
|
296
|
-
: await fetchProviderAccountQuotas(provider, forceRefresh);
|
|
297
|
+
: await fetchProviderAccountQuotas(provider, forceRefresh, quotaProvider);
|
|
297
298
|
const byId = new Map(rows.map(row => [row.accountId, row]));
|
|
298
299
|
const projected = projectAccounts();
|
|
299
300
|
return jsonResponse({
|
|
@@ -301,10 +302,13 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
301
302
|
accounts: projected.accounts.map(account => {
|
|
302
303
|
const row = byId.get(account.id);
|
|
303
304
|
if (!row) return account;
|
|
305
|
+
if (config.providers[provider] !== quotaProvider || row.isCurrent?.() === false) {
|
|
306
|
+
return { ...account, quota: null, quotaUnavailable: true };
|
|
307
|
+
}
|
|
304
308
|
return {
|
|
305
309
|
...account,
|
|
306
310
|
quota: row.quota,
|
|
307
|
-
...(
|
|
311
|
+
...(quotaMode === "probe" ? { quotaUnavailable: row.unavailable === true } : {}),
|
|
308
312
|
};
|
|
309
313
|
}),
|
|
310
314
|
});
|
|
@@ -333,8 +337,10 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
333
337
|
if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") {
|
|
334
338
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
335
339
|
if (provider !== "anthropic") {
|
|
336
|
-
// Generic OAuth pool-settings contract (#695 slice 1): persisted per provider
|
|
337
|
-
//
|
|
340
|
+
// Generic OAuth pool-settings contract (#695 slice 1): persisted per provider. `strategy`
|
|
341
|
+
// and `autoSwitchThreshold` stay inert until the selector consumes them; `enabled` already
|
|
342
|
+
// governs the pre-dispatch account preference. Codex keeps /api/codex-auth; api-key
|
|
343
|
+
// providers have no pool.
|
|
338
344
|
const { poolSettingsCapability, genericPoolSettingsDto } = await import("../../oauth/pool-settings-capability");
|
|
339
345
|
const prov = config.providers[provider];
|
|
340
346
|
if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
|
|
@@ -570,7 +576,29 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
570
576
|
const name = (url.searchParams.get("name") ?? "").trim();
|
|
571
577
|
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
572
578
|
const { listProviderApiKeys } = await import("../../providers/api-keys");
|
|
573
|
-
|
|
579
|
+
const projectKeys = () => {
|
|
580
|
+
const listed = listProviderApiKeys(config, name);
|
|
581
|
+
const provider = config.providers[name];
|
|
582
|
+
const quotaMode = provider ? providerApiKeyQuotaMode(name, provider) : "unsupported";
|
|
583
|
+
return { ...listed, keys: listed.keys.map(key => ({ ...key, quotaMode })) };
|
|
584
|
+
};
|
|
585
|
+
const initial = projectKeys();
|
|
586
|
+
if (url.searchParams.get("quota") !== "1" || !initial.keys.some(key => key.quotaMode === "probe")) {
|
|
587
|
+
return jsonResponse(initial);
|
|
588
|
+
}
|
|
589
|
+
const rows = await fetchProviderApiKeyQuotas(config, name, url.searchParams.get("refresh") === "1");
|
|
590
|
+
const byId = new Map(rows.map(row => [row.keyId, row]));
|
|
591
|
+
const current = projectKeys();
|
|
592
|
+
return jsonResponse({
|
|
593
|
+
activeId: current.activeId,
|
|
594
|
+
keys: current.keys.map(key => {
|
|
595
|
+
const row = byId.get(key.id);
|
|
596
|
+
if (!row || key.quotaMode !== "probe") return key;
|
|
597
|
+
if (!row.isCurrent()) return { ...key, quota: null, quotaUnavailable: true };
|
|
598
|
+
// Internal identity/epoch checks never enter the JSON DTO.
|
|
599
|
+
return { ...key, quota: row.quota, quotaUnavailable: row.unavailable === true };
|
|
600
|
+
}),
|
|
601
|
+
});
|
|
574
602
|
}
|
|
575
603
|
if (url.pathname === "/api/providers/keys" && req.method === "POST") {
|
|
576
604
|
const body = await readManagementJsonBodyOr(req, {}) as { name?: string; key?: string; label?: string };
|
|
@@ -41,10 +41,12 @@ import { fetchCursorUsableModels } from "../../adapters/cursor/live-models";
|
|
|
41
41
|
import { parseAntigravityAvailableModels } from "../../providers/antigravity-models";
|
|
42
42
|
import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
|
|
43
43
|
import { deriveProviderPresets, providerConfigSeed } from "../../providers/derive";
|
|
44
|
+
import { initializeProviderModelSelection } from "../../providers/initial-model-selection";
|
|
44
45
|
import { effectiveGoogleMode, providerCodexAccountMode, providerMatchesRegistryTransport } from "../../providers/registry";
|
|
45
46
|
import {
|
|
46
47
|
extractModelEnvelopeRows,
|
|
47
48
|
extractProviderModelItems,
|
|
49
|
+
isRegistryModelDiscoveryUrl,
|
|
48
50
|
readBoundedDiscoveryJson,
|
|
49
51
|
resolveProviderModelDiscovery,
|
|
50
52
|
} from "../../providers/model-discovery";
|
|
@@ -102,6 +104,7 @@ import { refreshUserCostOverlays } from "../../usage/user-cost-overlays";
|
|
|
102
104
|
import { redactSecretString } from "../../lib/redact";
|
|
103
105
|
import {
|
|
104
106
|
XAI_RESPONSES_OPT_IN_MODELS,
|
|
107
|
+
XAI_RESPONSES_DEFAULT_VERSION,
|
|
105
108
|
xaiResponsesOptInState,
|
|
106
109
|
} from "../../providers/xai-responses-opt-in";
|
|
107
110
|
import { dropProviderCustomModels } from "../../providers/provider-id-rewrite";
|
|
@@ -286,6 +289,10 @@ function adoptProviderEditorCandidate(live: OcxConfig, persisted: OcxConfig): vo
|
|
|
286
289
|
else live.customModels = structuredClone(persisted.customModels);
|
|
287
290
|
if (persisted.providerContextCaps === undefined) delete live.providerContextCaps;
|
|
288
291
|
else live.providerContextCaps = structuredClone(persisted.providerContextCaps);
|
|
292
|
+
if (persisted.disabledModels === undefined) delete live.disabledModels;
|
|
293
|
+
else live.disabledModels = [...persisted.disabledModels];
|
|
294
|
+
if (persisted.modelDiscovery === undefined) delete live.modelDiscovery;
|
|
295
|
+
else live.modelDiscovery = structuredClone(persisted.modelDiscovery);
|
|
289
296
|
}
|
|
290
297
|
|
|
291
298
|
/**
|
|
@@ -391,10 +398,11 @@ function applyProviderPatchFields(
|
|
|
391
398
|
const modelAdapters = { ...(next.modelAdapters ?? {}) };
|
|
392
399
|
for (const model of XAI_RESPONSES_OPT_IN_MODELS) {
|
|
393
400
|
if (rawBody.xaiResponsesOptIn) modelAdapters[model] = "openai-responses";
|
|
394
|
-
else
|
|
401
|
+
else modelAdapters[model] = "openai-chat";
|
|
395
402
|
}
|
|
396
403
|
if (Object.keys(modelAdapters).length > 0) next.modelAdapters = modelAdapters;
|
|
397
404
|
else delete next.modelAdapters;
|
|
405
|
+
next.xaiResponsesDefaultVersion = Math.max(next.xaiResponsesDefaultVersion ?? 0, XAI_RESPONSES_DEFAULT_VERSION);
|
|
398
406
|
touched = true;
|
|
399
407
|
}
|
|
400
408
|
if (Object.hasOwn(rawBody, "requestPacing")) {
|
|
@@ -830,8 +838,15 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
830
838
|
const changed = !isDeepStrictEqual(providerEditorConfigDTO(persisted), nextResult.value);
|
|
831
839
|
if (!changed) return { changed: false, value: candidate };
|
|
832
840
|
|
|
841
|
+
for (const [name, provider] of Object.entries(candidate.config.providers)) {
|
|
842
|
+
if (!Object.hasOwn(persisted.providers, name)) {
|
|
843
|
+
initializeProviderModelSelection(name, provider, undefined, candidate.config);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
833
846
|
persisted.defaultProvider = candidate.config.defaultProvider;
|
|
834
847
|
persisted.providers = structuredClone(candidate.config.providers);
|
|
848
|
+
persisted.disabledModels = candidate.config.disabledModels;
|
|
849
|
+
persisted.modelDiscovery = candidate.config.modelDiscovery;
|
|
835
850
|
for (const name of candidate.removedProviders) {
|
|
836
851
|
dropProviderCustomModels(persisted, name);
|
|
837
852
|
setProviderContextCap(persisted, name, false);
|
|
@@ -993,6 +1008,18 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
993
1008
|
// completed during that wait remains authoritative instead of being overwritten by the
|
|
994
1009
|
// older ownership snapshot used to admit this POST.
|
|
995
1010
|
restorePersistedAliasOverlays(prov, config.providers[name]);
|
|
1011
|
+
// The add/edit form omits wire choices. Read after DNS so a concurrent switch
|
|
1012
|
+
// remains authoritative, including the marker that protects it on the next boot.
|
|
1013
|
+
if (name === "xai") {
|
|
1014
|
+
const latest = config.providers[name];
|
|
1015
|
+
if (!Object.hasOwn(body.provider, "modelAdapters") && latest?.modelAdapters) {
|
|
1016
|
+
prov.modelAdapters = { ...latest.modelAdapters };
|
|
1017
|
+
}
|
|
1018
|
+
if (latest?.xaiResponsesDefaultVersion !== undefined) {
|
|
1019
|
+
prov.xaiResponsesDefaultVersion = latest.xaiResponsesDefaultVersion;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
initializeProviderModelSelection(name, prov, config.providers[name], config);
|
|
996
1023
|
config.providers[name] = stripRegistryOnlyStaticHeaders(name, prov);
|
|
997
1024
|
if (body.setDefault === true) config.defaultProvider = name;
|
|
998
1025
|
save(config);
|
|
@@ -1236,16 +1263,20 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1236
1263
|
const discovery = resolveProviderModelDiscovery(name, prov);
|
|
1237
1264
|
const started = Date.now();
|
|
1238
1265
|
try {
|
|
1266
|
+
// Same canonical-URL TUN transparency as catalog discovery: the registry's
|
|
1267
|
+
// own fixed discovery URL survives purely-benchmark (Clash/Surge/Mihomo
|
|
1268
|
+
// fake-IP) DNS without proxy env.
|
|
1269
|
+
const outboundDependencies = { isCanonicalUrl: isRegistryModelDiscoveryUrl };
|
|
1239
1270
|
const res = method === "POST"
|
|
1240
1271
|
? await providerOutboundPost(name, prov, modelsUrl, {
|
|
1241
1272
|
headers,
|
|
1242
1273
|
body: JSON.stringify({ project }),
|
|
1243
1274
|
signal: AbortSignal.timeout(8000),
|
|
1244
|
-
})
|
|
1275
|
+
}, outboundDependencies)
|
|
1245
1276
|
: await providerOutboundGet(name, prov, modelsUrl, {
|
|
1246
1277
|
headers,
|
|
1247
1278
|
signal: AbortSignal.timeout(8000),
|
|
1248
|
-
});
|
|
1279
|
+
}, outboundDependencies);
|
|
1249
1280
|
const latencyMs = Date.now() - started;
|
|
1250
1281
|
const redirectError = await providerRedirectError(res, modelsUrl);
|
|
1251
1282
|
if (redirectError) {
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only view of recently detected quota resets.
|
|
3
|
+
*
|
|
4
|
+
* Loaded on demand from src/server/management-api.ts, which is the FOURTH entry in the protected
|
|
5
|
+
* set of tests/core-lab-boundary.test.ts — added precisely because eagerly importing handlers
|
|
6
|
+
* there put ~70 modules on every dashboard request. A static import here would make this
|
|
7
|
+
* subsystem the next instance of that bug.
|
|
8
|
+
*
|
|
9
|
+
* Authentication is inherited: every /api route passes through requireManagementAuth before the
|
|
10
|
+
* chain runs, so a read-only GET adds no auth code of its own. It spends no user identity and
|
|
11
|
+
* mutates nothing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { jsonResponse } from "../auth-cors";
|
|
15
|
+
import type { ManagementContext } from "./context";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_LIMIT = 20;
|
|
18
|
+
const MAX_LIMIT = 100;
|
|
19
|
+
|
|
20
|
+
export async function handleQuotaResetRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
21
|
+
const { url, req, config } = ctx;
|
|
22
|
+
if (url.pathname !== "/api/quota-resets") return null;
|
|
23
|
+
if (req.method !== "GET") return null;
|
|
24
|
+
|
|
25
|
+
const rawLimit = url.searchParams.get("limit");
|
|
26
|
+
if (rawLimit !== null && !/^\d+$/.test(rawLimit)) {
|
|
27
|
+
return jsonResponse(
|
|
28
|
+
{ error: { code: "invalid_limit", message: "limit must be a non-negative integer" } },
|
|
29
|
+
400,
|
|
30
|
+
req,
|
|
31
|
+
config,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const limit = rawLimit === null
|
|
35
|
+
? DEFAULT_LIMIT
|
|
36
|
+
: Math.min(MAX_LIMIT, Number.parseInt(rawLimit, 10));
|
|
37
|
+
|
|
38
|
+
// Imported here rather than at module scope so a dashboard request that never touches this
|
|
39
|
+
// route does not load the store or its state file.
|
|
40
|
+
const [{ listRecentQuotaResetEvents }, { isQuotaResetNotificationEnabled }] = await Promise.all([
|
|
41
|
+
import("../../quota/reset-seen-store"),
|
|
42
|
+
import("../../quota/reset-notify-config"),
|
|
43
|
+
]);
|
|
44
|
+
|
|
45
|
+
// `enabled` is reported alongside the events because an empty list is ambiguous on its own:
|
|
46
|
+
// it means either "nothing has reset" or "detection was never turned on", and an operator
|
|
47
|
+
// debugging a missing notification needs to tell those apart.
|
|
48
|
+
return jsonResponse(
|
|
49
|
+
{
|
|
50
|
+
enabled: isQuotaResetNotificationEnabled(),
|
|
51
|
+
events: listRecentQuotaResetEvents(limit),
|
|
52
|
+
},
|
|
53
|
+
200,
|
|
54
|
+
req,
|
|
55
|
+
config,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Declared inventory of every reachable management route.
|
|
3
3
|
*
|
|
4
|
-
* DECLARED, not harvested. A grep cannot see this surface:
|
|
4
|
+
* DECLARED, not harvested. A grep cannot see this surface: 19 routes are registered
|
|
5
5
|
* through a regex, an `endsWith`, a `pathname.slice`, a prefix decode, a path constant, or a
|
|
6
6
|
* negated `pathname !== "…"` guard, and two of those are live routes whose only textual
|
|
7
7
|
* trace is the negated form. For `GET /api/storage` an equality scan finds solely the dead
|
|
8
8
|
* shadowed copy in `logs-usage-routes.ts` and never the live one.
|
|
9
9
|
*
|
|
10
10
|
* This module is pure DATA and must stay that way. It is imported by
|
|
11
|
-
* `src/server/management-api.ts`, which `tests/core-lab-boundary.test.ts` protects: a user
|
|
11
|
+
* `src/server/management-api.ts`, which `tests/lab/core-lab-boundary.test.ts` protects: a user
|
|
12
12
|
* with one provider and no Lab must execute no Lab code. Route paths are strings, so
|
|
13
13
|
* declaring `/api/lab/status` here creates no module edge. Never import a handler, and
|
|
14
14
|
* never import anything from `src/lab/`. The `module` field names the owning file as text
|
|
15
15
|
* for exactly this reason.
|
|
16
16
|
*
|
|
17
|
-
* Reconciliation lives in `tests/management-route-registry.test.ts`, which resolves
|
|
17
|
+
* Reconciliation lives in `tests/server/management-route-registry.test.ts`, which resolves
|
|
18
18
|
* `(method, path)` pairs from source and fails loudly on a route whose method it cannot
|
|
19
19
|
* determine. Adding a route without declaring it here fails that test.
|
|
20
20
|
*/
|
|
@@ -169,6 +169,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
169
169
|
// server/management/integration-routes
|
|
170
170
|
{ method: "GET", path: "/api/client-integrations", module: "server/management/integration-routes", mutates: false },
|
|
171
171
|
{ method: "GET", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: false },
|
|
172
|
+
{ method: "DELETE", path: "/api/client-integrations/journal", module: "server/management/integration-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Retiring one rollback row is a dashboard-local cleanup; the CLI verb that would drive it is owed by a later work-phase and is not implemented here.", owner: "260904_priority65_closeout WP7", ownerDoc: "devlog/_plan/260904_priority65_closeout/060_wp7_rollback_journal_crud.md" } },
|
|
172
173
|
{ method: "POST", path: "/api/client-integrations/restore", module: "server/management/integration-routes", mutates: true },
|
|
173
174
|
// server/management/lab-automation-routes
|
|
174
175
|
{ method: "GET", path: "/api/lab/automation", module: "server/management/lab-automation-routes", mutates: false, exempt: { reason: "local-transport", why: "ocx lab reads the same rows from the local SQLite projection; src/cli/lab.ts imports ../lab/query directly and never fetches /api/lab." } },
|
|
@@ -275,6 +276,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
275
276
|
{ method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true },
|
|
276
277
|
{ method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true, exempt: { reason: "deferred-verb", why: "Issue #3280 scopes this atomic batch endpoint to the GUI JSON editor; a matching CLI verb is outside wp5 and remains owed.", owner: "wp5-followup", ownerDoc: "devlog/_plan/260903_bug_drawdown_bcda/050_phase5.md" } },
|
|
277
278
|
{ method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true },
|
|
279
|
+
// server/management/quota-reset-routes
|
|
280
|
+
{ method: "GET", path: "/api/quota-resets", module: "server/management/quota-reset-routes", mutates: false, mechanism: "negated-guard" },
|
|
278
281
|
// server/management/request-history-routes
|
|
279
282
|
{ method: "GET", path: "/api/request-history", module: "server/management/request-history-routes", mutates: false },
|
|
280
283
|
// server/management/routing-analytics-routes
|
|
@@ -300,7 +303,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
300
303
|
{ method: "GET", path: "/api/system/memory", module: "server/management/system-routes", mutates: false },
|
|
301
304
|
{ method: "GET", path: "/api/system/windows-replace-retries", module: "server/management/system-routes", mutates: false },
|
|
302
305
|
{ method: "POST", path: "/api/system/restart", module: "server/management/system-routes", mutates: true },
|
|
303
|
-
// --- Routes an equality scan of their own file cannot see (
|
|
306
|
+
// --- Routes an equality scan of their own file cannot see (19). ---
|
|
304
307
|
// Each carries `mechanism`; the reconciliation test counts these separately.
|
|
305
308
|
{ method: "GET", path: "/api/storage", module: "server/management/storage-log-guard-routes", mutates: false, mechanism: "negated-guard" },
|
|
306
309
|
{ method: "GET", path: "/api/routing-analytics", module: "server/management/routing-analytics-routes", mutates: false, mechanism: "negated-guard" },
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { captureInitialSelectionBaseline, finalizeInitialModelSelection } from "../../providers/initial-model-selection-runtime";
|
|
3
|
+
import { initialModelSelectionPending } from "../../providers/initial-model-selection";
|
|
2
4
|
import { readFileSync } from "node:fs";
|
|
3
5
|
import type { CatalogModel } from "../../codex/catalog";
|
|
4
6
|
import { catalogModelSlug, invalidateCodexModelsCache, nativeModelRows, uniqueCatalogModelsForPublicList } from "../../codex/catalog";
|
|
@@ -53,6 +55,7 @@ import { drainAndShutdown } from "../lifecycle";
|
|
|
53
55
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
54
56
|
import { estimateComboCost, estimateRequestCost, serviceTierContext, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
55
57
|
import type { PersistedUsageAttempt } from "../../usage/log";
|
|
58
|
+
import { usageModelPriceOptions } from "../../usage/model-identity";
|
|
56
59
|
import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors";
|
|
57
60
|
import { applySystemEnvToggle } from "../system-env";
|
|
58
61
|
|
|
@@ -93,7 +96,7 @@ export type CostResult =
|
|
|
93
96
|
| { kind: "value"; estimate: NonNullable<ReturnType<typeof estimateRequestCost>>; estimateReasons: CostEstimateReason[] }
|
|
94
97
|
| { kind: "unavailable"; reason: MetricUnavailableReason };
|
|
95
98
|
|
|
96
|
-
export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome"> & {
|
|
99
|
+
export type MetricSource = Pick<RequestLogEntry, "provider" | "model" | "durationMs" | "usageStatus" | "usage" | "requestedServiceTier" | "configuredServiceTier" | "responseServiceTier" | "tierOutcome" | "routeDecision"> & {
|
|
97
100
|
attempts?: readonly PersistedUsageAttempt[];
|
|
98
101
|
};
|
|
99
102
|
|
|
@@ -132,8 +135,8 @@ export function unavailableCostReason(entry: MetricSource): MetricUnavailableRea
|
|
|
132
135
|
export function costResult(entry: MetricSource): CostResult {
|
|
133
136
|
const tier = serviceTierContext(entry);
|
|
134
137
|
const estimate = entry.attempts?.length
|
|
135
|
-
? estimateComboCost(entry.attempts, undefined, tier)
|
|
136
|
-
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier });
|
|
138
|
+
? estimateComboCost(entry.attempts.map(attempt => ({ ...attempt, ...usageModelPriceOptions(entry, attempt) })), undefined, tier)
|
|
139
|
+
: estimateRequestCost({ provider: entry.provider, model: entry.model, usage: entry.usage, usageStatus: entry.usageStatus, serviceTier: tier, ...usageModelPriceOptions(entry, entry) });
|
|
137
140
|
if (!estimate) return { kind: "unavailable", reason: unavailableCostReason(entry) };
|
|
138
141
|
const estimateReasons = [
|
|
139
142
|
entry.usageStatus === "estimated" || entry.usage?.estimated ? "usage_estimated" as const : undefined,
|
|
@@ -162,7 +165,7 @@ export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
|
|
|
162
165
|
...attempt,
|
|
163
166
|
displayMetrics: {
|
|
164
167
|
tokPerSecond: tokPerSecondResult(attempt),
|
|
165
|
-
cost: costResult({ ...attempt, attempts: undefined, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }),
|
|
168
|
+
cost: costResult({ ...attempt, attempts: undefined, routeDecision: entry.routeDecision, requestedServiceTier: entry.requestedServiceTier, configuredServiceTier: entry.configuredServiceTier, responseServiceTier: entry.responseServiceTier }),
|
|
166
169
|
},
|
|
167
170
|
})),
|
|
168
171
|
}
|
|
@@ -178,7 +181,13 @@ export function requestLogDto(entry: RequestLogEntry): Record<string, unknown> {
|
|
|
178
181
|
*/
|
|
179
182
|
export async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
|
|
180
183
|
const { gatherRoutedModels } = await import("../../codex/catalog");
|
|
181
|
-
|
|
184
|
+
const baseline = captureInitialSelectionBaseline(config);
|
|
185
|
+
if (!baseline) return gatherRoutedModels(config);
|
|
186
|
+
const outcomes: Array<{ provider: string; state: "authoritative" | "degraded" }> = [];
|
|
187
|
+
const models = await gatherRoutedModels(config, { providerModelOutcomes: outcomes });
|
|
188
|
+
finalizeInitialModelSelection(config, baseline, uniqueCatalogModelsForPublicList(models),
|
|
189
|
+
outcomes.filter(outcome => outcome.state === "authoritative").map(outcome => outcome.provider));
|
|
190
|
+
return models;
|
|
182
191
|
}
|
|
183
192
|
|
|
184
193
|
export interface GrokCandidateModel {
|
|
@@ -187,6 +196,11 @@ export interface GrokCandidateModel {
|
|
|
187
196
|
native: boolean;
|
|
188
197
|
}
|
|
189
198
|
|
|
199
|
+
/** Configuration pickers may retain disabled choices, but never offer provisional models. */
|
|
200
|
+
export async function fetchInitializedModels(config: OcxConfig): Promise<CatalogModel[]> {
|
|
201
|
+
return (await fetchAllModels(config)).filter(model => !initialModelSelectionPending(config.providers[model.provider]));
|
|
202
|
+
}
|
|
203
|
+
|
|
190
204
|
/**
|
|
191
205
|
* The model list `syncGrokConfig` would inject, BEFORE the user's exclusions. The Grok
|
|
192
206
|
* page needs this to show a switch for a model the user has already excluded — such a
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* further: it is the proxy's previous_response_id continuation store, so a
|
|
16
16
|
* growing responseState.totalBytes under rising observed memory points at
|
|
17
17
|
* conversation retention rather than the runtime allocator. Spill counts,
|
|
18
|
-
* payload-byte totals, tombstones,
|
|
19
|
-
*
|
|
18
|
+
* payload-byte totals, tombstones, failure counters, fixed health/error enums,
|
|
19
|
+
* and event timestamps remain finite scalars; response ids, raw errors,
|
|
20
|
+
* filenames, digests, paths, and payload content never leave the owner.
|
|
20
21
|
*
|
|
21
22
|
* `activeTurnCount` / `isDraining` are scalar lifecycle counters for the
|
|
22
23
|
* dashboard drain-and-restart confirm UX — never request bodies or IDs.
|
|
@@ -101,8 +101,8 @@ const managementConvergenceBindings = new WeakMap<object, Readonly<{
|
|
|
101
101
|
* Namespace match for management route prefixes: exact hit or a child path, never a
|
|
102
102
|
* prefix collision (`/api/labfoo` must not match `/api/lab`).
|
|
103
103
|
*/
|
|
104
|
-
function pathInManagementNamespace(pathname: string, prefix: string): boolean {
|
|
105
|
-
return pathname === prefix || pathname.startsWith(`${prefix}/`);
|
|
104
|
+
function pathInManagementNamespace(pathname: string, prefix: string, includeChildren = true): boolean {
|
|
105
|
+
return pathname === prefix || (includeChildren && pathname.startsWith(`${prefix}/`));
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
/**
|
|
@@ -132,6 +132,17 @@ async function handleLabRoutesOnDemand(ctx: ManagementContext): Promise<Response
|
|
|
132
132
|
return handleLabRoutes(ctx);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Lazy like the Lab and routing-profile handlers, and for the same recorded reason: this file is
|
|
137
|
+
* mounted for every dashboard request, so a static import would put the quota-reset store and
|
|
138
|
+
* its config resolution on all of them.
|
|
139
|
+
*/
|
|
140
|
+
async function handleQuotaResetRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
|
|
141
|
+
if (!pathInManagementNamespace(ctx.url.pathname, "/api/quota-resets", false)) return null;
|
|
142
|
+
const { handleQuotaResetRoutes } = await import("./management/quota-reset-routes");
|
|
143
|
+
return handleQuotaResetRoutes(ctx);
|
|
144
|
+
}
|
|
145
|
+
|
|
135
146
|
export async function handleManagementAPI(
|
|
136
147
|
req: Request,
|
|
137
148
|
url: URL,
|
|
@@ -229,6 +240,7 @@ export async function handleManagementAPI(
|
|
|
229
240
|
?? (await handleStorageLogGuardRoutes(ctx))
|
|
230
241
|
?? (await handleLogsUsageRoutes(ctx))
|
|
231
242
|
?? (await handleRequestHistoryRoutes(ctx))
|
|
243
|
+
?? (await handleQuotaResetRoutesOnDemand(ctx))
|
|
232
244
|
?? (await handleRoutingAnalyticsRoutes(ctx))
|
|
233
245
|
?? (await handleRoutingProfileRoutesOnDemand(ctx))
|
|
234
246
|
?? (await handleProviderRoutes(ctx))
|
package/src/server/ports.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import { createServer } from "node:net";
|
|
2
2
|
|
|
3
|
+
/** Temporary bind probes must not let accepted peers hold server.close() open. */
|
|
4
|
+
function createProbeServer(): ReturnType<typeof createServer> {
|
|
5
|
+
const server = createServer();
|
|
6
|
+
server.on("connection", socket => {
|
|
7
|
+
socket.on("error", () => socket.destroy());
|
|
8
|
+
socket.destroy();
|
|
9
|
+
});
|
|
10
|
+
return server;
|
|
11
|
+
}
|
|
12
|
+
|
|
3
13
|
/**
|
|
4
14
|
* True when an error means "this port/address is already bound" — the only bind failure
|
|
5
15
|
* that is safe to answer with a retry on another port. Bun/Node surface it as
|
|
@@ -15,7 +25,7 @@ export function isAddrInUse(err: unknown): boolean {
|
|
|
15
25
|
|
|
16
26
|
export async function isPortAvailable(port: number, hostname = "127.0.0.1"): Promise<boolean> {
|
|
17
27
|
return await new Promise(resolve => {
|
|
18
|
-
const server =
|
|
28
|
+
const server = createProbeServer();
|
|
19
29
|
// Fail closed: EACCES / EADDRNOTAVAIL / EPERM / unknown listen errors mean the
|
|
20
30
|
// requested bind is not available. Only the listening event reports free.
|
|
21
31
|
server.once("error", () => resolve(false));
|
|
@@ -133,7 +143,7 @@ export function setEphemeralPortAllocatorForTests(
|
|
|
133
143
|
async function allocateEphemeralPort(hostname: string): Promise<number> {
|
|
134
144
|
if (ephemeralAllocator) return ephemeralAllocator(hostname);
|
|
135
145
|
return await new Promise<number>((resolve, reject) => {
|
|
136
|
-
const server =
|
|
146
|
+
const server = createProbeServer();
|
|
137
147
|
server.once("error", reject);
|
|
138
148
|
server.once("listening", () => {
|
|
139
149
|
const address = server.address();
|