@bitkyc08/opencodex 2.7.30 → 2.7.33-preview.20260722
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/README.ja.md +438 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +480 -0
- package/README.zh-CN.md +1 -1
- package/bin/ocx.mjs +18 -1
- package/gui/dist/assets/index-B79f-04T.js +52 -0
- package/gui/dist/assets/index-D6Fcl4yM.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +17 -2
- package/src/adapters/cursor/discovery.ts +2 -2
- package/src/adapters/google-tool-schema.ts +4 -0
- package/src/adapters/google.ts +17 -2
- package/src/adapters/openai-chat.ts +36 -1
- package/src/adapters/openai-responses.ts +2 -1
- package/src/bridge.ts +12 -4
- package/src/cli/account-api.ts +4 -2
- package/src/cli/account-extended.ts +34 -0
- package/src/cli/account.ts +3 -1
- package/src/cli/claude.ts +6 -1
- package/src/cli/help.ts +25 -4
- package/src/cli/init.ts +38 -3
- package/src/cli/models.ts +206 -7
- package/src/codex/auth-api.ts +45 -3
- package/src/codex/catalog.ts +105 -12
- package/src/codex/routing.ts +85 -4
- package/src/combos/index.ts +1 -1
- package/src/combos/request.ts +26 -4
- package/src/combos/types.ts +4 -4
- package/src/config.ts +60 -3
- package/src/lib/upstream-retry.ts +21 -0
- package/src/lib/winsw.ts +7 -1
- package/src/oauth/anthropic.ts +23 -1
- package/src/oauth/github-copilot.ts +1 -0
- package/src/oauth/index.ts +88 -8
- package/src/oauth/kiro.ts +12 -1
- package/src/oauth/local-token-detect.ts +3 -0
- package/src/oauth/store.ts +43 -0
- package/src/oauth/types.ts +3 -1
- package/src/providers/antigravity-models.ts +123 -14
- package/src/providers/api-keys.ts +12 -0
- package/src/providers/openrouter-routing.ts +102 -0
- package/src/providers/registry.ts +41 -17
- package/src/router.ts +2 -1
- package/src/server/auth-cors.ts +5 -0
- package/src/server/index.ts +3 -3
- package/src/server/management-api.ts +143 -6
- package/src/server/relay.ts +31 -5
- package/src/server/request-log.ts +12 -0
- package/src/server/responses.ts +127 -31
- package/src/service.ts +12 -3
- package/src/types.ts +44 -3
- package/src/update/index.ts +19 -2
- package/src/update/job.ts +13 -3
- package/src/usage/expected-prices.ts +22 -9
- package/src/usage/summary.ts +43 -0
- package/gui/dist/assets/index-B-UauL1p.css +0 -1
- package/gui/dist/assets/index-avcinRsG.js +0 -40
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { readFileSync } from "node:fs";
|
|
2
3
|
import type { CatalogModel } from "../codex/catalog";
|
|
3
4
|
import { invalidateCodexModelsCache, nativeModelRows } from "../codex/catalog";
|
|
@@ -46,7 +47,7 @@ import {
|
|
|
46
47
|
setDebugSettings,
|
|
47
48
|
type DebugFlag,
|
|
48
49
|
} from "../lib/debug-settings";
|
|
49
|
-
import type { OcxClaudeCodeConfig, OcxConfig, OcxProviderConfig } from "../types";
|
|
50
|
+
import type { OcxClaudeCodeConfig, OcxConfig, OcxCustomModel, OcxProviderConfig } from "../types";
|
|
50
51
|
import { drainAndShutdown } from "./lifecycle";
|
|
51
52
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "./request-log";
|
|
52
53
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../usage/cost";
|
|
@@ -798,9 +799,26 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
798
799
|
native: true,
|
|
799
800
|
...(row.contextWindow !== undefined ? { contextWindow: row.contextWindow } : {}),
|
|
800
801
|
}));
|
|
801
|
-
|
|
802
|
+
const customModels = (config.customModels ?? []).map(cm => {
|
|
803
|
+
const namespaced = routedSlug(cm.provider, cm.modelId);
|
|
804
|
+
return {
|
|
805
|
+
provider: cm.provider,
|
|
806
|
+
id: cm.modelId,
|
|
807
|
+
namespaced,
|
|
808
|
+
disabled: [...disabled].some(stored => slugEquals(stored, cm.provider, cm.modelId)),
|
|
809
|
+
custom: true,
|
|
810
|
+
customId: cm.id,
|
|
811
|
+
displayName: cm.displayName,
|
|
812
|
+
...(cm.contextWindow ? { contextWindow: cm.contextWindow } : {}),
|
|
813
|
+
...(cm.inputModalities ? { inputModalities: cm.inputModalities } : {}),
|
|
814
|
+
};
|
|
815
|
+
});
|
|
816
|
+
// Custom metadata wins when a live/static routed row resolves to the same Codex-facing slug.
|
|
817
|
+
const customNamespaced = new Set(customModels.map(c => c.namespaced));
|
|
818
|
+
const dedupedRouted = models.map(m => {
|
|
802
819
|
// Codex-facing slug (one "/", slug-codec); disabledModels compares tolerate both forms.
|
|
803
820
|
const namespaced = routedSlug(m.provider, m.id);
|
|
821
|
+
if (customNamespaced.has(namespaced)) return null;
|
|
804
822
|
const contextCap = providerContextCap(config, m.provider);
|
|
805
823
|
return {
|
|
806
824
|
...m,
|
|
@@ -808,7 +826,8 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
808
826
|
disabled: [...disabled].some(stored => slugEquals(stored, m.provider, m.id)),
|
|
809
827
|
...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
|
|
810
828
|
};
|
|
811
|
-
})
|
|
829
|
+
}).filter(Boolean);
|
|
830
|
+
return jsonResponse([...native, ...dedupedRouted, ...customModels]);
|
|
812
831
|
}
|
|
813
832
|
|
|
814
833
|
if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
|
|
@@ -880,6 +899,96 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
880
899
|
return jsonResponse({ ok: true, disabled });
|
|
881
900
|
}
|
|
882
901
|
|
|
902
|
+
if (url.pathname === "/api/custom-models" && req.method === "GET") {
|
|
903
|
+
return jsonResponse(config.customModels ?? []);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (url.pathname === "/api/custom-models" && req.method === "POST") {
|
|
907
|
+
let body: { provider?: unknown; modelId?: unknown; displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown };
|
|
908
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
909
|
+
const provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
910
|
+
const modelId = typeof body.modelId === "string" ? body.modelId.trim() : "";
|
|
911
|
+
if (!provider || !modelId) return jsonResponse({ error: "provider and modelId are required" }, 400);
|
|
912
|
+
if (modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
|
|
913
|
+
if (!isValidProviderName(provider)) return jsonResponse({ error: "invalid provider name" }, 400);
|
|
914
|
+
if (!hasOwnProvider(config.providers, provider)) return jsonResponse({ error: "provider not configured" }, 404);
|
|
915
|
+
const displayName = typeof body.displayName === "string" && body.displayName.trim() ? body.displayName.trim() : undefined;
|
|
916
|
+
if (displayName?.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
|
|
917
|
+
const contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
918
|
+
const inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
|
|
919
|
+
const existing = config.customModels ?? [];
|
|
920
|
+
const newSlug = routedSlug(provider, modelId);
|
|
921
|
+
if (existing.some(cm => routedSlug(cm.provider, cm.modelId) === newSlug)) {
|
|
922
|
+
return jsonResponse({ error: "duplicate model" }, 409);
|
|
923
|
+
}
|
|
924
|
+
const entry: OcxCustomModel = {
|
|
925
|
+
id: randomUUID(),
|
|
926
|
+
provider,
|
|
927
|
+
modelId,
|
|
928
|
+
...(displayName ? { displayName } : {}),
|
|
929
|
+
...(contextWindow ? { contextWindow } : {}),
|
|
930
|
+
...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}),
|
|
931
|
+
addedAt: new Date().toISOString(),
|
|
932
|
+
};
|
|
933
|
+
config.customModels = [...existing, entry];
|
|
934
|
+
const { saveConfig: save } = await import("../config");
|
|
935
|
+
save(config);
|
|
936
|
+
await refreshCodexCatalogBestEffort();
|
|
937
|
+
return jsonResponse(entry, 201);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
const customPutMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
|
|
941
|
+
if (customPutMatch && req.method === "PUT") {
|
|
942
|
+
let id: string;
|
|
943
|
+
try { id = decodeURIComponent(customPutMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
|
|
944
|
+
let body: { displayName?: unknown; contextWindow?: unknown; inputModalities?: unknown; modelId?: unknown };
|
|
945
|
+
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
946
|
+
const list = config.customModels ?? [];
|
|
947
|
+
const idx = list.findIndex(cm => cm.id === id);
|
|
948
|
+
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
949
|
+
const cm = { ...list[idx] };
|
|
950
|
+
if (typeof body.modelId === "string" && body.modelId.trim()) {
|
|
951
|
+
if (body.modelId.includes("/")) return jsonResponse({ error: "modelId must not contain /" }, 400);
|
|
952
|
+
cm.modelId = body.modelId.trim();
|
|
953
|
+
}
|
|
954
|
+
if (body.displayName !== undefined) {
|
|
955
|
+
const dn = typeof body.displayName === "string" ? body.displayName.trim() : "";
|
|
956
|
+
if (dn.includes("/")) return jsonResponse({ error: "displayName must not contain /" }, 400);
|
|
957
|
+
cm.displayName = dn || undefined;
|
|
958
|
+
}
|
|
959
|
+
if (body.contextWindow !== undefined) {
|
|
960
|
+
cm.contextWindow = typeof body.contextWindow === "number" && body.contextWindow > 0 ? Math.floor(body.contextWindow) : undefined;
|
|
961
|
+
}
|
|
962
|
+
if (body.inputModalities !== undefined) {
|
|
963
|
+
cm.inputModalities = Array.isArray(body.inputModalities) ? body.inputModalities.filter((m): m is string => typeof m === "string") : undefined;
|
|
964
|
+
}
|
|
965
|
+
const updatedSlug = routedSlug(cm.provider, cm.modelId);
|
|
966
|
+
if (list.some((other, i) => i !== idx && routedSlug(other.provider, other.modelId) === updatedSlug)) {
|
|
967
|
+
return jsonResponse({ error: "duplicate model" }, 409);
|
|
968
|
+
}
|
|
969
|
+
list[idx] = cm;
|
|
970
|
+
config.customModels = list;
|
|
971
|
+
const { saveConfig: save } = await import("../config");
|
|
972
|
+
save(config);
|
|
973
|
+
await refreshCodexCatalogBestEffort();
|
|
974
|
+
return jsonResponse(cm);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
const customDelMatch = url.pathname.match(/^\/api\/custom-models\/([^/]+)$/);
|
|
978
|
+
if (customDelMatch && req.method === "DELETE") {
|
|
979
|
+
let id: string;
|
|
980
|
+
try { id = decodeURIComponent(customDelMatch[1]); } catch { return jsonResponse({ error: "invalid id encoding" }, 400); }
|
|
981
|
+
const list = config.customModels ?? [];
|
|
982
|
+
const idx = list.findIndex(cm => cm.id === id);
|
|
983
|
+
if (idx === -1) return jsonResponse({ error: "not found" }, 404);
|
|
984
|
+
list.splice(idx, 1);
|
|
985
|
+
config.customModels = list.length > 0 ? list : undefined;
|
|
986
|
+
const { saveConfig: save } = await import("../config");
|
|
987
|
+
save(config);
|
|
988
|
+
await refreshCodexCatalogBestEffort();
|
|
989
|
+
return jsonResponse({ ok: true });
|
|
990
|
+
}
|
|
991
|
+
|
|
883
992
|
// multi_agent_v2 surface toggle. GET reports the flag + the agents.max_threads
|
|
884
993
|
// boot conflict; PUT flips it via the official `codex features` CLI and RESYNCS
|
|
885
994
|
// the catalog so multi-agent surface metadata stays fresh. The catalog build
|
|
@@ -1375,18 +1484,18 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1375
1484
|
}
|
|
1376
1485
|
}
|
|
1377
1486
|
// addAccount / reauth forces a fresh browser identity (skips local-CLI token import).
|
|
1378
|
-
const { url: authUrl, instructions } = await startLoginFlow(provider, {
|
|
1487
|
+
const { url: authUrl, instructions, deviceCode } = await startLoginFlow(provider, {
|
|
1379
1488
|
forceLogin: body.addAccount === true || reauth,
|
|
1380
1489
|
...(accountId ? { reauthAccountId: accountId } : {}),
|
|
1381
1490
|
});
|
|
1382
1491
|
upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
|
|
1383
|
-
if (authUrl) {
|
|
1492
|
+
if (authUrl && !deviceCode) {
|
|
1384
1493
|
// Open the browser server-side (the proxy runs on the user's machine) — the GUI's
|
|
1385
1494
|
// window.open is popup-blocked because it runs after an await, not a direct click.
|
|
1386
1495
|
const { openUrl } = await import("../lib/open-url");
|
|
1387
1496
|
openUrl(authUrl);
|
|
1388
1497
|
}
|
|
1389
|
-
return jsonResponse({ url: authUrl, instructions });
|
|
1498
|
+
return jsonResponse({ url: authUrl, instructions, deviceCode });
|
|
1390
1499
|
} catch (err) {
|
|
1391
1500
|
return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 409);
|
|
1392
1501
|
}
|
|
@@ -1454,6 +1563,20 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1454
1563
|
clearProviderQuotaCache();
|
|
1455
1564
|
return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
|
|
1456
1565
|
}
|
|
1566
|
+
if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") {
|
|
1567
|
+
const body = await req.json().catch(() => ({})) as { provider?: unknown; accountId?: unknown; alias?: unknown };
|
|
1568
|
+
const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : "";
|
|
1569
|
+
const accountId = typeof body.accountId === "string" ? body.accountId.trim() : "";
|
|
1570
|
+
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
1571
|
+
if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
1572
|
+
if (!accountId) return jsonResponse({ error: "missing accountId" }, 400);
|
|
1573
|
+
if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
|
|
1574
|
+
return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
|
|
1575
|
+
}
|
|
1576
|
+
const { setAccountAlias } = await import("../oauth/store");
|
|
1577
|
+
if (!(await setAccountAlias(provider, accountId, alias || undefined))) return jsonResponse({ error: "account not found" }, 404);
|
|
1578
|
+
return jsonResponse({ ok: true, provider, accountId, alias: alias || null });
|
|
1579
|
+
}
|
|
1457
1580
|
if (url.pathname === "/api/oauth/accounts" && req.method === "DELETE") {
|
|
1458
1581
|
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
1459
1582
|
const id = url.searchParams.get("id") ?? "";
|
|
@@ -1507,6 +1630,20 @@ export async function handleManagementAPI(req: Request, url: URL, config: OcxCon
|
|
|
1507
1630
|
clearKeyCooldowns(name); // manual key management resets 429 cooldown state
|
|
1508
1631
|
return jsonResponse({ ok: true, name, activeId: body.id });
|
|
1509
1632
|
}
|
|
1633
|
+
if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") {
|
|
1634
|
+
const body = await req.json().catch(() => ({})) as { name?: unknown; id?: unknown; alias?: unknown };
|
|
1635
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
1636
|
+
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
1637
|
+
const alias = typeof body.alias === "string" ? body.alias.trim() : "";
|
|
1638
|
+
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1639
|
+
if (!id) return jsonResponse({ error: "missing id" }, 400);
|
|
1640
|
+
if (typeof body.alias !== "string" || alias.length > 80 || /[\x00-\x1f\x7f]/.test(alias)) {
|
|
1641
|
+
return jsonResponse({ error: "alias must be at most 80 printable characters" }, 400);
|
|
1642
|
+
}
|
|
1643
|
+
const { setProviderApiKeyLabel } = await import("../providers/api-keys");
|
|
1644
|
+
if (!setProviderApiKeyLabel(config, name, id, alias || undefined)) return jsonResponse({ error: "key not found" }, 404);
|
|
1645
|
+
return jsonResponse({ ok: true, name, id, alias: alias || null });
|
|
1646
|
+
}
|
|
1510
1647
|
if (url.pathname === "/api/providers/keys" && req.method === "DELETE") {
|
|
1511
1648
|
const name = (url.searchParams.get("name") ?? "").trim();
|
|
1512
1649
|
const id = url.searchParams.get("id") ?? "";
|
package/src/server/relay.ts
CHANGED
|
@@ -406,7 +406,7 @@ export function relaySseWithHeartbeat(
|
|
|
406
406
|
*/
|
|
407
407
|
export function consumeForInspection(
|
|
408
408
|
body: ReadableStream<Uint8Array>,
|
|
409
|
-
onTerminal: (status: ResponsesTerminalStatus) => void,
|
|
409
|
+
onTerminal: (status: ResponsesTerminalStatus, httpStatusOverride?: number) => void,
|
|
410
410
|
signal?: AbortSignal,
|
|
411
411
|
onDone?: () => void,
|
|
412
412
|
logCtx?: RequestLogContext,
|
|
@@ -451,14 +451,24 @@ export function consumeForInspection(
|
|
|
451
451
|
reportFirstOutput(payload);
|
|
452
452
|
if (payload) {
|
|
453
453
|
const status = terminalStatusFromSsePayload(payload);
|
|
454
|
-
if (status) {
|
|
454
|
+
if (status) {
|
|
455
|
+
reported = true;
|
|
456
|
+
if (logCtx) {
|
|
457
|
+
logCtx.transportPhase = "terminal_sse";
|
|
458
|
+
logCtx.terminalSource = "upstream";
|
|
459
|
+
}
|
|
460
|
+
onTerminal(status);
|
|
461
|
+
}
|
|
455
462
|
if (onCompletedResponse) {
|
|
456
463
|
const response = completedResponseFromSsePayload(payload);
|
|
457
464
|
if (response) onCompletedResponse(response);
|
|
458
465
|
}
|
|
459
466
|
}
|
|
460
467
|
}
|
|
461
|
-
if (!reported && !cancelled)
|
|
468
|
+
if (!reported && !cancelled) {
|
|
469
|
+
if (logCtx) logCtx.terminalSource = "synthetic";
|
|
470
|
+
onTerminal("incomplete");
|
|
471
|
+
}
|
|
462
472
|
return;
|
|
463
473
|
}
|
|
464
474
|
buffer += decoder.decode(value, { stream: true });
|
|
@@ -472,7 +482,14 @@ export function consumeForInspection(
|
|
|
472
482
|
if (!payload) continue;
|
|
473
483
|
if (!reported) {
|
|
474
484
|
const status = terminalStatusFromSsePayload(payload);
|
|
475
|
-
if (status) {
|
|
485
|
+
if (status) {
|
|
486
|
+
reported = true;
|
|
487
|
+
if (logCtx) {
|
|
488
|
+
logCtx.transportPhase = "terminal_sse";
|
|
489
|
+
logCtx.terminalSource = "upstream";
|
|
490
|
+
}
|
|
491
|
+
onTerminal(status);
|
|
492
|
+
}
|
|
476
493
|
}
|
|
477
494
|
if (onCompletedResponse) {
|
|
478
495
|
const response = completedResponseFromSsePayload(payload);
|
|
@@ -481,7 +498,16 @@ export function consumeForInspection(
|
|
|
481
498
|
}
|
|
482
499
|
}
|
|
483
500
|
} catch {
|
|
484
|
-
|
|
501
|
+
// Upstream read failure after HTTP 200 (mid-stream socket reset) is not a
|
|
502
|
+
// protocol `response.incomplete` terminal. Report a synthetic 502 so account
|
|
503
|
+
// health treats it as transient; abort-driven client cancellation still wins.
|
|
504
|
+
if (!reported && !cancelled) {
|
|
505
|
+
if (logCtx) {
|
|
506
|
+
logCtx.transportPhase = "mid_stream";
|
|
507
|
+
logCtx.terminalSource = "synthetic";
|
|
508
|
+
}
|
|
509
|
+
onTerminal("failed", 502);
|
|
510
|
+
}
|
|
485
511
|
} finally {
|
|
486
512
|
onDone?.();
|
|
487
513
|
}
|
|
@@ -62,6 +62,9 @@ export interface RequestLogContext {
|
|
|
62
62
|
upstreamError?: string;
|
|
63
63
|
/** HTTP status derived from a terminal `response.failed` SSE payload (429/401/503/etc.). */
|
|
64
64
|
terminalHttpStatus?: number;
|
|
65
|
+
affinity?: "reused" | "new_bind" | "rebound" | "cleared";
|
|
66
|
+
transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
|
|
67
|
+
terminalSource?: "upstream" | "synthetic";
|
|
65
68
|
}
|
|
66
69
|
|
|
67
70
|
export interface RequestLogEntry {
|
|
@@ -92,6 +95,12 @@ export interface RequestLogEntry {
|
|
|
92
95
|
usage?: OcxUsage;
|
|
93
96
|
totalTokens?: number;
|
|
94
97
|
attempts?: PersistedUsageAttempt[];
|
|
98
|
+
/** Codex pool affinity decision for this request (diagnostics for #186). */
|
|
99
|
+
affinity?: "reused" | "new_bind" | "rebound" | "cleared";
|
|
100
|
+
/** Where the upstream terminal/failure was observed. */
|
|
101
|
+
transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse";
|
|
102
|
+
/** Whether the terminal came from a real upstream SSE event or a proxy synthetic tail. */
|
|
103
|
+
terminalSource?: "upstream" | "synthetic";
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
const requestLog: RequestLogEntry[] = [];
|
|
@@ -587,6 +596,9 @@ export function addFinalRequestLog(
|
|
|
587
596
|
...(loggedUsage ? { usage: loggedUsage } : {}),
|
|
588
597
|
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
589
598
|
...(attempts?.length ? { attempts } : {}),
|
|
599
|
+
...(logCtx.affinity ? { affinity: logCtx.affinity } : {}),
|
|
600
|
+
...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
|
|
601
|
+
...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
|
|
590
602
|
});
|
|
591
603
|
if (isUsageDebugEnabled()) {
|
|
592
604
|
appendUsageDebug({
|
package/src/server/responses.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from "../config";
|
|
7
7
|
import { parseRequest } from "../responses/parser";
|
|
8
8
|
import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../responses/compaction";
|
|
9
|
-
import { FORWARD_HEADERS } from "../adapters/openai-responses";
|
|
9
|
+
import { FORWARD_HEADERS, sanitizeReasoningInputContent } from "../adapters/openai-responses";
|
|
10
10
|
import { expandPreviousResponseInput, previousResponseConversationId, rememberResponseState } from "../responses/state";
|
|
11
11
|
import { routeModel } from "../router";
|
|
12
12
|
import {
|
|
@@ -55,7 +55,7 @@ import {
|
|
|
55
55
|
recordCodexUpstreamOutcome,
|
|
56
56
|
type CodexUpstreamOutcome,
|
|
57
57
|
} from "../codex/routing";
|
|
58
|
-
import { fetchWithResetRetry, fetchWithTransientRetry } from "../lib/upstream-retry";
|
|
58
|
+
import { fetchWithResetRetry, fetchWithTransientRetry, applyUpstreamRecoveryInit } from "../lib/upstream-retry";
|
|
59
59
|
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
60
60
|
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../providers/openai-sidecar";
|
|
61
61
|
import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../providers/openai-virtual-models";
|
|
@@ -69,6 +69,7 @@ import type { WsData } from "./ws-bridge";
|
|
|
69
69
|
import { registerTurn, trackStreamLifetime, unregisterTurn } from "./lifecycle";
|
|
70
70
|
import { redactSecretString } from "../lib/redact";
|
|
71
71
|
import { readBoundedResponseBody } from "../lib/bounded-body";
|
|
72
|
+
import { supportedLadderFor } from "./effort-policy";
|
|
72
73
|
import {
|
|
73
74
|
beginRequestAttempt,
|
|
74
75
|
catalogModelSupportsServiceTier,
|
|
@@ -395,9 +396,13 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number {
|
|
|
395
396
|
return rewritten;
|
|
396
397
|
}
|
|
397
398
|
|
|
398
|
-
export function sidecarOutcomeRecorder(
|
|
399
|
+
export function sidecarOutcomeRecorder(
|
|
400
|
+
config: OcxConfig,
|
|
401
|
+
authCtx: CodexAuthContext,
|
|
402
|
+
threadId?: string | null,
|
|
403
|
+
): ((outcome: CodexUpstreamOutcome) => void) | undefined {
|
|
399
404
|
return authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
400
|
-
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome)
|
|
405
|
+
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId })
|
|
401
406
|
: undefined;
|
|
402
407
|
}
|
|
403
408
|
|
|
@@ -418,9 +423,32 @@ export function codexForwardTerminalOutcomeRecorder(
|
|
|
418
423
|
config: OcxConfig,
|
|
419
424
|
authCtx: CodexAuthContext,
|
|
420
425
|
provider: OcxProviderConfig,
|
|
421
|
-
|
|
426
|
+
logCtx?: RequestLogContext,
|
|
427
|
+
threadId?: string | null,
|
|
428
|
+
): ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined {
|
|
422
429
|
if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
|
|
423
|
-
return status
|
|
430
|
+
return (status, httpStatusOverride) => {
|
|
431
|
+
if (status === "incomplete") {
|
|
432
|
+
// Normal limit/content-filter/stall terminal — the account served the
|
|
433
|
+
// request. Don't penalize account health; record success to clear any
|
|
434
|
+
// prior soft-avoid so a healthy account isn't stuck avoided.
|
|
435
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, 200, { threadId });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
// status === "completed" or "failed": use the semantic HTTP status derived
|
|
439
|
+
// from the terminal SSE error payload (httpStatusFromTerminalError in
|
|
440
|
+
// request-log inspection) instead of collapsing every non-completed terminal
|
|
441
|
+
// to 502. A 400 invalid_request_error must not soft-avoid the account or
|
|
442
|
+
// rebind threads — only genuine transport/5xx failures should trigger
|
|
443
|
+
// transient health recording.
|
|
444
|
+
// httpStatusOverride: the combo WS path inspects SSE payloads into the parent
|
|
445
|
+
// logCtx, but this recorder closes over the child logCtx. The caller passes
|
|
446
|
+
// the parent's terminalHttpStatus so the semantic status is not lost.
|
|
447
|
+
const outcome = status === "completed"
|
|
448
|
+
? 200
|
|
449
|
+
: (httpStatusOverride ?? logCtx?.terminalHttpStatus ?? 502);
|
|
450
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, { threadId });
|
|
451
|
+
};
|
|
424
452
|
}
|
|
425
453
|
|
|
426
454
|
/**
|
|
@@ -466,7 +494,7 @@ interface HandleResponsesOptions {
|
|
|
466
494
|
onFirstOutput?: () => void;
|
|
467
495
|
onCodexAuthContextResolved?: (context: CodexAuthContext | undefined) => void;
|
|
468
496
|
recordTerminalOutcomes?: boolean;
|
|
469
|
-
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
|
|
497
|
+
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined) => void;
|
|
470
498
|
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
471
499
|
onNativePassthroughCancel?: () => void;
|
|
472
500
|
/** Internal recursion guard; callers outside this module must not set it. */
|
|
@@ -562,6 +590,15 @@ function createChildPassthroughCallbackGate(options: HandleResponsesOptions) {
|
|
|
562
590
|
};
|
|
563
591
|
}
|
|
564
592
|
|
|
593
|
+
export function buildComboChildHeaders(parentHeaders: HeadersInit): Headers {
|
|
594
|
+
const childHeaders = new Headers(parentHeaders);
|
|
595
|
+
// Combo children re-serialize already-decoded JSON. Keeping transport metadata from
|
|
596
|
+
// the parent would make the child decoder treat plain JSON as compressed bytes.
|
|
597
|
+
childHeaders.delete("content-length");
|
|
598
|
+
childHeaders.delete("content-encoding");
|
|
599
|
+
return childHeaders;
|
|
600
|
+
}
|
|
601
|
+
|
|
565
602
|
async function handleComboResponses(
|
|
566
603
|
req: Request,
|
|
567
604
|
rawBody: unknown,
|
|
@@ -595,20 +632,21 @@ async function handleComboResponses(
|
|
|
595
632
|
model: pick.target.model,
|
|
596
633
|
provider: pick.target.provider,
|
|
597
634
|
};
|
|
635
|
+
const targetRoute = routeModel(config, `${pick.target.provider}/${pick.target.model}`);
|
|
598
636
|
const childBody = concreteComboRequestBody(
|
|
599
637
|
rawBody,
|
|
600
638
|
pick.target,
|
|
601
639
|
comboDefaultEffort(config, comboId),
|
|
640
|
+
supportedLadderFor({ provider: targetRoute.provider, modelId: targetRoute.modelId }),
|
|
602
641
|
);
|
|
603
|
-
const childHeaders =
|
|
604
|
-
childHeaders.delete("content-length");
|
|
642
|
+
const childHeaders = buildComboChildHeaders(req.headers);
|
|
605
643
|
const childRequest = new Request(req.url, {
|
|
606
644
|
method: req.method,
|
|
607
645
|
headers: childHeaders,
|
|
608
646
|
body: JSON.stringify(childBody),
|
|
609
647
|
});
|
|
610
648
|
let resolvedAuth: CodexAuthContext | undefined;
|
|
611
|
-
let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
|
|
649
|
+
let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined;
|
|
612
650
|
const started = Date.now();
|
|
613
651
|
const attempt = beginRequestAttempt(
|
|
614
652
|
(logCtx.attempts?.length ?? 0) + 1,
|
|
@@ -1092,11 +1130,11 @@ export async function handleResponses(
|
|
|
1092
1130
|
upstreamResponse = await fetchWithTransientRetry(
|
|
1093
1131
|
recovery => {
|
|
1094
1132
|
noteAttemptSend(logCtx.activeAttempt, passthroughEstimate, recovery);
|
|
1095
|
-
return fetchWithHeaderTimeout(request.url, {
|
|
1133
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
1096
1134
|
method: request.method,
|
|
1097
1135
|
headers: request.headers,
|
|
1098
1136
|
body: request.body,
|
|
1099
|
-
}, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
1137
|
+
}, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
1100
1138
|
},
|
|
1101
1139
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
1102
1140
|
);
|
|
@@ -1104,7 +1142,11 @@ export async function handleResponses(
|
|
|
1104
1142
|
upstream.abort();
|
|
1105
1143
|
if (options.abortSignal?.aborted) return clientCancelledResponse();
|
|
1106
1144
|
const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
1107
|
-
if (usesCodexForwardPoolAuth(authCtx, route.provider))
|
|
1145
|
+
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
1146
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1147
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1108
1150
|
const msg = outcome === "timeout"
|
|
1109
1151
|
? `Provider connect timeout after ${connectMs}ms`
|
|
1110
1152
|
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
@@ -1122,7 +1164,13 @@ export async function handleResponses(
|
|
|
1122
1164
|
const passthroughCt = headers.get("content-type")?.toLowerCase();
|
|
1123
1165
|
const isEventStream = passthroughCt?.includes("text/event-stream")
|
|
1124
1166
|
|| (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
|
|
1125
|
-
const terminalRecorder = codexForwardTerminalOutcomeRecorder(
|
|
1167
|
+
const terminalRecorder = codexForwardTerminalOutcomeRecorder(
|
|
1168
|
+
config,
|
|
1169
|
+
authCtx,
|
|
1170
|
+
route.provider,
|
|
1171
|
+
logCtx,
|
|
1172
|
+
req.headers.get("x-codex-parent-thread-id"),
|
|
1173
|
+
);
|
|
1126
1174
|
const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
|
|
1127
1175
|
// Capture quota from upstream response for multi-account tracking
|
|
1128
1176
|
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
@@ -1148,14 +1196,15 @@ export async function handleResponses(
|
|
|
1148
1196
|
);
|
|
1149
1197
|
}
|
|
1150
1198
|
if (terminalBodyWillRecord) {
|
|
1151
|
-
options.setTerminalOutcomeRecorder?.(status => {
|
|
1152
|
-
terminalRecorder(status);
|
|
1199
|
+
options.setTerminalOutcomeRecorder?.((status, httpStatusOverride) => {
|
|
1200
|
+
terminalRecorder(status, httpStatusOverride);
|
|
1153
1201
|
options.onNativePassthroughTerminal?.(status);
|
|
1154
1202
|
});
|
|
1155
1203
|
} else {
|
|
1156
1204
|
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
|
|
1157
1205
|
retryAfter: retryAfterRaw,
|
|
1158
1206
|
resetAt: [primaryResetRaw, secondaryResetRaw, monthlyResetRaw].filter(Boolean),
|
|
1207
|
+
threadId: req.headers.get("x-codex-parent-thread-id"),
|
|
1159
1208
|
});
|
|
1160
1209
|
}
|
|
1161
1210
|
}
|
|
@@ -1174,8 +1223,8 @@ export async function handleResponses(
|
|
|
1174
1223
|
// even if the client has already disconnected: the turn genuinely reached that terminal, so
|
|
1175
1224
|
// it must log as completed/failed, not be dropped or downgraded to a cancel (#44). A pure
|
|
1176
1225
|
// client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel.
|
|
1177
|
-
const reportNativeTerminal = (status: ResponsesTerminalStatus) => {
|
|
1178
|
-
terminalRecorder?.(status);
|
|
1226
|
+
const reportNativeTerminal = (status: ResponsesTerminalStatus, httpStatusOverride?: number) => {
|
|
1227
|
+
terminalRecorder?.(status, httpStatusOverride);
|
|
1179
1228
|
options.onNativePassthroughTerminal?.(status);
|
|
1180
1229
|
};
|
|
1181
1230
|
consumeForInspection(
|
|
@@ -1390,9 +1439,11 @@ export async function handleResponses(
|
|
|
1390
1439
|
upstreamResponse = await fetchWithResetRetry(
|
|
1391
1440
|
recovery => {
|
|
1392
1441
|
noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate, recovery);
|
|
1393
|
-
return fetchWithHeaderTimeout(request.url, {
|
|
1394
|
-
method: request.method,
|
|
1395
|
-
|
|
1442
|
+
return fetchWithHeaderTimeout(request.url, applyUpstreamRecoveryInit({
|
|
1443
|
+
method: request.method,
|
|
1444
|
+
headers: request.headers,
|
|
1445
|
+
body: request.body,
|
|
1446
|
+
}, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
|
|
1396
1447
|
},
|
|
1397
1448
|
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
1398
1449
|
);
|
|
@@ -1710,10 +1761,11 @@ export async function handleResponsesCompact(
|
|
|
1710
1761
|
// headers would run compaction on the wrong account (or 401) whenever a pool account is
|
|
1711
1762
|
// active for this thread while normal turns succeed.
|
|
1712
1763
|
let compactProvider = route.provider;
|
|
1764
|
+
let authCtx: CodexAuthContext = { kind: "main", accountId: null };
|
|
1713
1765
|
const headers = new Headers({ "content-type": "application/json" });
|
|
1714
1766
|
try {
|
|
1715
1767
|
if (route.codexAccountMode) {
|
|
1716
|
-
|
|
1768
|
+
authCtx = await resolveCodexAuthContext(req.headers, config, route.codexAccountMode);
|
|
1717
1769
|
const selected = headersForCodexAuthContext(req.headers, authCtx);
|
|
1718
1770
|
compactProvider = applyCodexAuthContextToProvider(route.provider, authCtx, route.codexAccountMode);
|
|
1719
1771
|
for (const name of FORWARD_HEADERS) {
|
|
@@ -1743,20 +1795,64 @@ export async function handleResponsesCompact(
|
|
|
1743
1795
|
}
|
|
1744
1796
|
const base = (compactProvider.baseUrl ?? "").replace(/\/$/, "");
|
|
1745
1797
|
if (compactProvider.apiKey) headers.set("authorization", `Bearer ${resolveEnvValue(compactProvider.apiKey)}`);
|
|
1746
|
-
const { reasoning: _reasoning, ...
|
|
1798
|
+
const { reasoning: _reasoning, ...compactBodyRaw } = raw as typeof raw & { reasoning?: unknown };
|
|
1799
|
+
// The regular /v1/responses path applies sanitizeReasoningInputContent via the adapter's
|
|
1800
|
+
// buildRequest, but the compact endpoint forwards directly. Apply the same sanitizer here
|
|
1801
|
+
// so routed-model reasoning items (reasoning_text content) don't 400 the ChatGPT backend.
|
|
1802
|
+
const compactBody = sanitizeReasoningInputContent(compactBodyRaw) as typeof compactBodyRaw;
|
|
1803
|
+
const compactUrl = `${base}/responses/compact`;
|
|
1804
|
+
const compactThreadId = req.headers.get("x-codex-parent-thread-id");
|
|
1805
|
+
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
1806
|
+
const recordCompactPoolOutcome = (outcome: CodexUpstreamOutcome, meta: { retryAfter?: string | null } = {}) => {
|
|
1807
|
+
if (!usesCodexForwardPoolAuth(authCtx, route.provider)) return;
|
|
1808
|
+
recordCodexUpstreamOutcome(config, authCtx.accountId, outcome, {
|
|
1809
|
+
...meta,
|
|
1810
|
+
threadId: compactThreadId,
|
|
1811
|
+
});
|
|
1812
|
+
};
|
|
1747
1813
|
let upstream: Response;
|
|
1748
1814
|
try {
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1815
|
+
// Same connect timeout + keep-alive reset + transient-5xx recovery as /v1/responses —
|
|
1816
|
+
// compact hits the same ChatGPT host and must soft-avoid / clear affinity (#186).
|
|
1817
|
+
upstream = await fetchWithTransientRetry(
|
|
1818
|
+
recovery => fetchWithHeaderTimeout(
|
|
1819
|
+
compactUrl,
|
|
1820
|
+
applyUpstreamRecoveryInit({
|
|
1821
|
+
method: "POST",
|
|
1822
|
+
headers,
|
|
1823
|
+
body: JSON.stringify({ ...compactBody, model: route.modelId }),
|
|
1824
|
+
}, recovery),
|
|
1825
|
+
req.signal,
|
|
1826
|
+
connectMs,
|
|
1827
|
+
false,
|
|
1828
|
+
providerFetch(compactProvider),
|
|
1829
|
+
),
|
|
1830
|
+
{ abortSignal: req.signal, label: safeHostLabel(compactUrl) },
|
|
1831
|
+
);
|
|
1832
|
+
} catch (err) {
|
|
1756
1833
|
if (req.signal.aborted) return formatErrorResponse(499, "client_cancelled", "Client cancelled compact request");
|
|
1834
|
+
const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
1835
|
+
recordCompactPoolOutcome(outcome);
|
|
1757
1836
|
return formatErrorResponse(502, "upstream_error", "Failed to connect to compact upstream");
|
|
1758
1837
|
}
|
|
1759
|
-
|
|
1838
|
+
const retryAfter = upstream.headers.get("retry-after");
|
|
1839
|
+
const buffered = await bufferCompactResponse(upstream, req.signal);
|
|
1840
|
+
// Record pool health only after the body is fully delivered (or definitively failed).
|
|
1841
|
+
// A premature 200 would clear soft-avoid while the client still sees a buffer 502.
|
|
1842
|
+
if (buffered.status === 499) {
|
|
1843
|
+
return buffered;
|
|
1844
|
+
}
|
|
1845
|
+
if (upstream.ok && buffered.status >= 500) {
|
|
1846
|
+
// The upstream account returned 200 — it is healthy. The buffering failure
|
|
1847
|
+
// (oversized body exceeding COMPACT_RESPONSE_MAX_BYTES, or a rare mid-read
|
|
1848
|
+
// reset on a small JSON payload) is a local proxy issue, not account flakiness.
|
|
1849
|
+
// Record the upstream status so a deterministic payload-size limit does not
|
|
1850
|
+
// soft-avoid a healthy account and rotate a thread for 30s.
|
|
1851
|
+
recordCompactPoolOutcome(upstream.status, { retryAfter });
|
|
1852
|
+
} else {
|
|
1853
|
+
recordCompactPoolOutcome(upstream.status, { retryAfter });
|
|
1854
|
+
}
|
|
1855
|
+
return buffered;
|
|
1760
1856
|
}
|
|
1761
1857
|
|
|
1762
1858
|
// ROUTED model: run the v2 synthetic-compaction turn internally (appends COMPACT_PROMPT, no
|
package/src/service.ts
CHANGED
|
@@ -503,7 +503,7 @@ function installWindows(): void {
|
|
|
503
503
|
throw new Error(`Cannot remove the native service before switching to Task Scheduler: ${err instanceof Error ? err.message : String(err)}. Remove it manually with 'sc delete ${WINSW_SERVICE_ID}' or retry.`);
|
|
504
504
|
}
|
|
505
505
|
if (statusWinswRaw() !== "nonexistent") {
|
|
506
|
-
throw new Error(
|
|
506
|
+
throw new Error(`Native service registration could not be re-verified after the removal attempt — aborting switch. Check 'sc.exe query ${WINSW_SERVICE_ID}' and remove it manually if present.`);
|
|
507
507
|
}
|
|
508
508
|
}
|
|
509
509
|
// End a running task BEFORE rewriting the assets it is executing — cmd.exe reading the
|
|
@@ -761,7 +761,9 @@ export function stopServiceIfInstalled(): boolean {
|
|
|
761
761
|
const q = schtasks(["/query", "/tn", TASK]);
|
|
762
762
|
if (q.includes(TASK)) { stopWindows(); stopped = true; }
|
|
763
763
|
} catch { /* task not found */ }
|
|
764
|
-
if (statusWinswRaw() !== "nonexistent") {
|
|
764
|
+
if (statusWinswRaw() !== "nonexistent") {
|
|
765
|
+
try { stopWinswService(); stopped = true; } catch { /* best-effort */ }
|
|
766
|
+
}
|
|
765
767
|
if (stopped) return true;
|
|
766
768
|
} else if (process.platform === "linux" && isSystemd() && existsSync(unitPath())) {
|
|
767
769
|
try { stopSystemd(); return true; } catch { return false; }
|
|
@@ -793,7 +795,14 @@ export function uninstallServiceIfInstalled(): boolean {
|
|
|
793
795
|
const q = schtasks(["/query", "/tn", TASK]);
|
|
794
796
|
if (q.includes(TASK)) { uninstallWindows(); removed = true; }
|
|
795
797
|
} catch { /* task not found */ }
|
|
796
|
-
if (statusWinswRaw() !== "nonexistent") {
|
|
798
|
+
if (statusWinswRaw() !== "nonexistent") {
|
|
799
|
+
try {
|
|
800
|
+
uninstallWinswService();
|
|
801
|
+
removed = true;
|
|
802
|
+
} catch (err) {
|
|
803
|
+
console.warn(`⚠️ Failed to remove native service: ${err instanceof Error ? err.message : String(err)}. Check 'sc.exe query ${WINSW_SERVICE_ID}'.`);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
797
806
|
if (removed) { removeServiceInstallState(); return true; }
|
|
798
807
|
} else if (process.platform === "linux" && existsSync(unitPath())) {
|
|
799
808
|
try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
|