@bitkyc08/opencodex 2.7.23 → 2.7.24
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.ko.md +37 -7
- package/README.md +52 -11
- package/README.zh-CN.md +36 -7
- package/bin/ocx.mjs +5 -3
- package/gui/dist/assets/index-BzhyTAco.js +40 -0
- package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opencode.svg +1 -1
- package/package.json +5 -2
- package/src/adapters/anthropic-image-normalize.ts +70 -29
- package/src/adapters/cursor/transport-retry.ts +20 -1
- package/src/adapters/run-turn-queue.ts +40 -0
- package/src/codex/auth-api.ts +10 -1
- package/src/codex/auth-context.ts +33 -7
- package/src/codex/catalog.ts +357 -23
- package/src/codex/routing.ts +10 -4
- package/src/combos/failover.ts +102 -0
- package/src/combos/index.ts +37 -0
- package/src/combos/request.ts +31 -0
- package/src/combos/resolve.ts +171 -0
- package/src/combos/types.ts +203 -0
- package/src/config.ts +280 -11
- package/src/lib/errors.ts +86 -24
- package/src/lib/upstream-retry.ts +8 -4
- package/src/oauth/index.ts +7 -1
- package/src/oauth/key-providers.ts +2 -32
- package/src/oauth/login-cli.ts +4 -3
- package/src/oauth/token-guardian.ts +38 -3
- package/src/providers/derive.ts +27 -2
- package/src/providers/kiro-models.ts +8 -3
- package/src/providers/label.ts +3 -1
- package/src/providers/openai-sidecar.ts +94 -0
- package/src/providers/openai-tier-startup.ts +27 -0
- package/src/providers/openai-tiers.ts +283 -0
- package/src/providers/openai-virtual-models.ts +82 -0
- package/src/providers/quota.ts +344 -24
- package/src/providers/registry.ts +112 -20
- package/src/reasoning-effort.ts +12 -11
- package/src/router.ts +80 -36
- package/src/server/auth-cors.ts +85 -9
- package/src/server/images.ts +31 -75
- package/src/server/index.ts +45 -86
- package/src/server/management-api.ts +273 -21
- package/src/server/request-log.ts +221 -20
- package/src/server/responses.ts +594 -75
- package/src/server/search.ts +22 -37
- package/src/types.ts +49 -1
- package/src/update/index.ts +50 -6
- package/src/update/job.ts +21 -4
- package/src/usage/log.ts +124 -1
- package/src/usage/summary.ts +147 -56
- package/src/vision/index.ts +20 -19
- package/src/web-search/index.ts +15 -17
- package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
- package/gui/dist/assets/index-DQjt6Hly.js +0 -40
package/src/server/search.ts
CHANGED
|
@@ -12,19 +12,18 @@ import { formatErrorResponse } from "../bridge";
|
|
|
12
12
|
import {
|
|
13
13
|
CodexAccountCooldownError,
|
|
14
14
|
CodexAuthContextError,
|
|
15
|
+
CodexPoolAuthenticationError,
|
|
15
16
|
CodexThreadAffinityExpiredError,
|
|
16
|
-
headersForCodexAuthContext,
|
|
17
|
-
isCodexAuthContextUsable,
|
|
18
|
-
resolveCodexAuthContext,
|
|
19
17
|
} from "../codex/auth-context";
|
|
20
18
|
import { formatCodexProviderForLog } from "../codex/routing";
|
|
21
19
|
import { signalWithTimeout } from "../lib/abort";
|
|
22
20
|
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
23
|
-
import type { OcxConfig
|
|
24
|
-
import {
|
|
21
|
+
import type { OcxConfig } from "../types";
|
|
22
|
+
import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar } from "../providers/openai-sidecar";
|
|
25
23
|
import { readJsonRequestBody } from "./request-decompress";
|
|
24
|
+
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
26
25
|
import type { RequestLogContext } from "./request-log";
|
|
27
|
-
import { codexLogAccountId, decodeRequestErrorResponse
|
|
26
|
+
import { codexLogAccountId, decodeRequestErrorResponse } from "./responses";
|
|
28
27
|
|
|
29
28
|
/**
|
|
30
29
|
* Default TOTAL deadline for one search relay. alpha/search is non-streaming JSON — response
|
|
@@ -36,23 +35,16 @@ import { codexLogAccountId, decodeRequestErrorResponse, sidecarOutcomeRecorder }
|
|
|
36
35
|
const SEARCH_UPSTREAM_TIMEOUT_MS = 200_000;
|
|
37
36
|
const SEARCH_RESPONSE_MAX_BYTES = 16 * 1024 * 1024;
|
|
38
37
|
|
|
39
|
-
interface NamedProvider {
|
|
40
|
-
name: string;
|
|
41
|
-
provider: OcxProviderConfig;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function findSearchUpstream(config: OcxConfig): NamedProvider | undefined {
|
|
45
|
-
for (const [name, provider] of Object.entries(config.providers)) {
|
|
46
|
-
if (provider.disabled !== true && provider.authMode === "forward") return { name, provider };
|
|
47
|
-
}
|
|
48
|
-
return undefined;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
38
|
export async function handleSearch(
|
|
52
39
|
req: Request,
|
|
53
40
|
config: OcxConfig,
|
|
54
41
|
logCtx: RequestLogContext,
|
|
55
42
|
): Promise<Response> {
|
|
43
|
+
try { validateForwardAdmissionCredential(req.headers, config); }
|
|
44
|
+
catch (err) {
|
|
45
|
+
if (err instanceof ForwardAdmissionCredentialError) return formatErrorResponse(401, "authentication_error", err.message);
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
56
48
|
let body: unknown;
|
|
57
49
|
try {
|
|
58
50
|
body = await readJsonRequestBody(req);
|
|
@@ -62,8 +54,8 @@ export async function handleSearch(
|
|
|
62
54
|
const model = (body as { model?: unknown } | null)?.model;
|
|
63
55
|
if (typeof model === "string" && model) logCtx.model = model;
|
|
64
56
|
|
|
65
|
-
const
|
|
66
|
-
if (
|
|
57
|
+
const candidates = listOpenAiForwardSidecarCandidates(config);
|
|
58
|
+
if (candidates.length === 0) {
|
|
67
59
|
return formatErrorResponse(
|
|
68
60
|
400,
|
|
69
61
|
"invalid_request_error",
|
|
@@ -72,25 +64,17 @@ export async function handleSearch(
|
|
|
72
64
|
);
|
|
73
65
|
}
|
|
74
66
|
|
|
75
|
-
let
|
|
76
|
-
let recordOutcome: ReturnType<typeof sidecarOutcomeRecorder>;
|
|
67
|
+
let upstream: Awaited<ReturnType<typeof resolveFirstUsableOpenAiSidecar>>;
|
|
77
68
|
try {
|
|
78
|
-
|
|
79
|
-
if (!
|
|
80
|
-
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
81
|
-
}
|
|
82
|
-
authHeaders = headersForCodexAuthContext(req.headers, authCtx);
|
|
83
|
-
const bearer = authHeaders.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
|
|
84
|
-
if (bearer && isProxyAdmissionSecret(bearer, config)) authHeaders.delete("authorization");
|
|
85
|
-
if (!authHeaders.get("authorization")) {
|
|
69
|
+
upstream = await resolveFirstUsableOpenAiSidecar(candidates, req.headers, config);
|
|
70
|
+
if (!upstream) {
|
|
86
71
|
return formatErrorResponse(
|
|
87
72
|
401,
|
|
88
73
|
"authentication_error",
|
|
89
74
|
"web search relay needs ChatGPT auth (Authorization header)",
|
|
90
75
|
);
|
|
91
76
|
}
|
|
92
|
-
|
|
93
|
-
logCtx.provider = formatCodexProviderForLog(upstream.name, codexLogAccountId(authCtx), config);
|
|
77
|
+
logCtx.provider = formatCodexProviderForLog(upstream.providerName, codexLogAccountId(upstream.authContext), config);
|
|
94
78
|
} catch (err) {
|
|
95
79
|
if (err instanceof CodexAccountCooldownError) {
|
|
96
80
|
return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
|
|
@@ -99,16 +83,17 @@ export async function handleSearch(
|
|
|
99
83
|
return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
|
100
84
|
}
|
|
101
85
|
if (err instanceof CodexAuthContextError) {
|
|
102
|
-
const safeAccountLabel = formatCodexProviderForLog(
|
|
86
|
+
const safeAccountLabel = formatCodexProviderForLog("openai", err.accountId, config);
|
|
103
87
|
console.error(`[search] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
104
88
|
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
105
89
|
}
|
|
90
|
+
if (err instanceof CodexPoolAuthenticationError) return formatErrorResponse(401, "authentication_error", err.message);
|
|
106
91
|
throw err;
|
|
107
92
|
}
|
|
108
93
|
|
|
109
94
|
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
110
95
|
if (upstream.provider.headers) Object.assign(headers, upstream.provider.headers);
|
|
111
|
-
for (const [name, value] of
|
|
96
|
+
for (const [name, value] of upstream.headers) headers[name] = value;
|
|
112
97
|
const url = `${upstream.provider.baseUrl}/alpha/search`;
|
|
113
98
|
const timeoutMs = config.search?.timeoutMs ?? SEARCH_UPSTREAM_TIMEOUT_MS;
|
|
114
99
|
const linkedSignal = signalWithTimeout(timeoutMs, req.signal);
|
|
@@ -124,7 +109,7 @@ export async function handleSearch(
|
|
|
124
109
|
if (payload.byteLength > SEARCH_RESPONSE_MAX_BYTES) {
|
|
125
110
|
return formatErrorResponse(502, "upstream_error", `search response too large (${payload.byteLength} bytes)`);
|
|
126
111
|
}
|
|
127
|
-
recordOutcome?.(upstreamResponse.status);
|
|
112
|
+
upstream.recordOutcome?.(upstreamResponse.status);
|
|
128
113
|
const relayHeaders: Record<string, string> = {};
|
|
129
114
|
const contentType = upstreamResponse.headers.get("content-type");
|
|
130
115
|
if (contentType) relayHeaders["content-type"] = contentType;
|
|
@@ -134,10 +119,10 @@ export async function handleSearch(
|
|
|
134
119
|
return formatErrorResponse(499, "client_closed_request", "search request canceled by client");
|
|
135
120
|
}
|
|
136
121
|
if (err instanceof Error && err.name === "TimeoutError") {
|
|
137
|
-
recordOutcome?.("timeout");
|
|
122
|
+
upstream.recordOutcome?.("timeout");
|
|
138
123
|
return formatErrorResponse(504, "upstream_error", "search upstream timed out");
|
|
139
124
|
}
|
|
140
|
-
recordOutcome?.("connect_error");
|
|
125
|
+
upstream.recordOutcome?.("connect_error");
|
|
141
126
|
return formatErrorResponse(
|
|
142
127
|
502,
|
|
143
128
|
"upstream_error",
|
package/src/types.ts
CHANGED
|
@@ -349,6 +349,8 @@ export interface OcxConfig {
|
|
|
349
349
|
port: number;
|
|
350
350
|
providers: Record<string, OcxProviderConfig>;
|
|
351
351
|
defaultProvider: string;
|
|
352
|
+
/** OpenAI provider-contract migration marker (v2 = single `openai` provider with account mode). */
|
|
353
|
+
openaiProviderTierVersion?: 1 | 2;
|
|
352
354
|
/** Claude Code inbound + launcher settings. */
|
|
353
355
|
claudeCode?: OcxClaudeCodeConfig;
|
|
354
356
|
/**
|
|
@@ -467,12 +469,34 @@ export interface OcxConfig {
|
|
|
467
469
|
autoSwitchThreshold?: number;
|
|
468
470
|
/** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */
|
|
469
471
|
upstreamFailoverThreshold?: number;
|
|
472
|
+
/** Virtual `combo/<id>` models spanning concrete provider/model targets (issue #133). */
|
|
473
|
+
combos?: Record<string, OcxComboConfig>;
|
|
470
474
|
/** Background proactive token refresh ("Token Guardian"). Off by default; see OcxTokenGuardianConfig. */
|
|
471
475
|
tokenGuardian?: OcxTokenGuardianConfig;
|
|
472
476
|
/** Additional origins allowed for CORS (e.g. ["https://clisu-oracle.tail19a2d7.ts.net"]). Loopback origins are always allowed. */
|
|
473
477
|
corsAllowOrigins?: string[];
|
|
474
478
|
}
|
|
475
479
|
|
|
480
|
+
export type OcxComboStrategy = "failover" | "round-robin";
|
|
481
|
+
export type OcxComboDefaultEffort = "low" | "medium" | "high" | "xhigh" | "max" | "ultra";
|
|
482
|
+
|
|
483
|
+
export interface OcxComboTarget {
|
|
484
|
+
provider: string;
|
|
485
|
+
model: string;
|
|
486
|
+
/** Relative SWRR batch weight. Default 1; valid range 1..10000. */
|
|
487
|
+
weight?: number;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export interface OcxComboConfig {
|
|
491
|
+
targets: OcxComboTarget[];
|
|
492
|
+
/** Ordered failover (default) or deterministic smooth weighted round-robin. */
|
|
493
|
+
strategy?: OcxComboStrategy;
|
|
494
|
+
/** Successful requests retained on one RR selection batch. Default 1; range 1..100. */
|
|
495
|
+
stickyLimit?: number;
|
|
496
|
+
/** Used when the client omits reasoning.effort. Default medium. */
|
|
497
|
+
defaultEffort?: OcxComboDefaultEffort;
|
|
498
|
+
}
|
|
499
|
+
|
|
476
500
|
/**
|
|
477
501
|
* Per-provider proactive-refresh policy. The guardian only ever touches a provider whose EFFECTIVE
|
|
478
502
|
* policy is "proactive"; "lazy-only" keeps today's on-demand refresh, "disabled" forbids the
|
|
@@ -566,6 +590,12 @@ export interface OcxProviderConfig {
|
|
|
566
590
|
allowPrivateNetwork?: boolean;
|
|
567
591
|
/** Keep provider settings on disk but exclude it from routing and model/catalog listings. */
|
|
568
592
|
disabled?: boolean;
|
|
593
|
+
/**
|
|
594
|
+
* Codex account-selection mode. Valid ONLY on the canonical built-in `openai` forward provider.
|
|
595
|
+
* "pool" (default) rotates main + added Codex accounts through the affinity/quota/cooldown/
|
|
596
|
+
* failover engine; "direct" pins the caller's main Codex login and never touches pool state.
|
|
597
|
+
*/
|
|
598
|
+
codexAccountMode?: CodexAccountMode;
|
|
569
599
|
apiKey?: string;
|
|
570
600
|
/**
|
|
571
601
|
* Multi-key pool (API-key twin of OAuth multiauth). `apiKey` always mirrors the ACTIVE
|
|
@@ -595,16 +625,27 @@ export interface OcxProviderConfig {
|
|
|
595
625
|
modelContextWindows?: Record<string, number>;
|
|
596
626
|
/** Model-specific Codex catalog input modalities, e.g. ["text"] or ["text", "image"]. */
|
|
597
627
|
modelInputModalities?: Record<string, string[]>;
|
|
628
|
+
/** Model-specific max input token limits. Values cap auto_compact_token_limit. */
|
|
629
|
+
modelMaxInputTokens?: Record<string, number>;
|
|
598
630
|
headers?: Record<string, string>;
|
|
599
631
|
/**
|
|
600
632
|
* "key" (default): authenticate upstream with `apiKey`.
|
|
601
633
|
* "forward": relay the caller's incoming auth headers verbatim (OAuth passthrough; gpt only).
|
|
602
634
|
* "oauth": resolve a stored OAuth access token (auto-refreshed) and use it as the Bearer key.
|
|
603
635
|
* Only the openai-responses adapter implements "forward"; openai-chat uses its own key/token.
|
|
636
|
+
* "local": local runtime (Ollama etc.) — no remote key required. Valid only for
|
|
637
|
+
* providers whose registry entry declares authKind "local" (management API enforces).
|
|
604
638
|
*/
|
|
605
|
-
authMode?: "key" | "forward" | "oauth";
|
|
639
|
+
authMode?: "key" | "forward" | "oauth" | "local";
|
|
606
640
|
/** Allow an explicitly key/oauth provider to run without a credential (for keyless local proxies). */
|
|
607
641
|
keyOptional?: boolean;
|
|
642
|
+
/**
|
|
643
|
+
* Free-tier pricing flag for UI/catalog (Free badge, Free filter). Not the same as
|
|
644
|
+
* `keyOptional` — free tiers may still require an API key (e.g. NVIDIA NIM free credits).
|
|
645
|
+
*/
|
|
646
|
+
freeTier?: boolean;
|
|
647
|
+
/** Optional human note shown in the providers UI (not used for routing). */
|
|
648
|
+
note?: string;
|
|
608
649
|
/** Strip one trailing bracketed suffix from model ids before sending them upstream. */
|
|
609
650
|
modelSuffixBracketStrip?: boolean;
|
|
610
651
|
/**
|
|
@@ -621,6 +662,8 @@ export interface OcxProviderConfig {
|
|
|
621
662
|
reasoningEfforts?: string[];
|
|
622
663
|
/** Model-specific Codex-visible reasoning tiers. An empty array means “do not expose effort”. */
|
|
623
664
|
modelReasoningEfforts?: Record<string, string[]>;
|
|
665
|
+
/** Model-specific default Codex reasoning tier; must also be present in the visible tier list. */
|
|
666
|
+
modelDefaultReasoningEfforts?: Record<string, string>;
|
|
624
667
|
/** Provider-wide mapping from Codex effort labels to upstream `reasoning_effort` values. */
|
|
625
668
|
reasoningEffortMap?: Record<string, string>;
|
|
626
669
|
/** Model-specific mapping from Codex effort labels to upstream `reasoning_effort` values. */
|
|
@@ -712,6 +755,11 @@ export interface OcxProviderConfig {
|
|
|
712
755
|
nativeLocalExec?: "off" | "codex-sandbox" | "on";
|
|
713
756
|
}
|
|
714
757
|
|
|
758
|
+
/** Trusted runtime ownership for Codex-account credentials. Never persisted per provider. */
|
|
759
|
+
export type CodexAccountMode = "direct" | "pool";
|
|
760
|
+
|
|
761
|
+
export const OPENAI_PROVIDER_TIER_VERSION = 2 as const;
|
|
762
|
+
|
|
715
763
|
export interface CodexAccount {
|
|
716
764
|
id: string;
|
|
717
765
|
email: string;
|
package/src/update/index.ts
CHANGED
|
@@ -66,20 +66,51 @@ export function latestVersion(tag: string): string | null {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/** The global-install command opencodex would run to update on this channel. */
|
|
69
|
-
export function updateCommand(installer: Installer, tag: Channel): { bin: string; args: string[] } {
|
|
69
|
+
export function updateCommand(installer: Installer, tag: Channel, resolvedVersion?: string | null): { bin: string; args: string[] } {
|
|
70
70
|
const bin = installer === "bun" ? "bun" : "npm";
|
|
71
|
+
// Immutable target: when the registry resolved a concrete version, install exactly
|
|
72
|
+
// that version — the dist-tag can move between resolution and install (TOCTOU).
|
|
73
|
+
const target = resolvedVersion || tag;
|
|
71
74
|
const args = installer === "bun"
|
|
72
|
-
? ["add", "-g", `${PKG}@${
|
|
73
|
-
: ["install", "-g", `${PKG}@${
|
|
75
|
+
? ["add", "-g", `${PKG}@${target}`]
|
|
76
|
+
: ["install", "-g", `${PKG}@${target}`];
|
|
74
77
|
return { bin, args };
|
|
75
78
|
}
|
|
76
79
|
|
|
77
80
|
/** Human-readable form of {@link updateCommand}, used in the update prompt label. */
|
|
78
|
-
export function updateCommandStr(installer: Installer, tag: Channel): string {
|
|
79
|
-
const { bin, args } = updateCommand(installer, tag);
|
|
81
|
+
export function updateCommandStr(installer: Installer, tag: Channel, resolvedVersion?: string | null): string {
|
|
82
|
+
const { bin, args } = updateCommand(installer, tag, resolvedVersion);
|
|
80
83
|
return `${bin} ${args.join(" ")}`;
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Pre-flight integrity metadata check (NOT independent tamper-proofing — the installer
|
|
88
|
+
* verifies tarballs against the same registry metadata). Two failure lanes:
|
|
89
|
+
* - transient registry failure (spawn error/timeout/nonzero exit) → `{ ok: "skipped" }`
|
|
90
|
+
* so registry absence never turns into an unconditional update failure;
|
|
91
|
+
* - successful query with missing/malformed SRI → `{ ok: false }` (anomalous
|
|
92
|
+
* metadata — fail closed BEFORE the running proxy is stopped).
|
|
93
|
+
* `dist.integrity` may be a quoted, space-separated multi-hash list; any sha512 token passes.
|
|
94
|
+
*/
|
|
95
|
+
export function checkUpdatePackageIntegrity(
|
|
96
|
+
version: string | null,
|
|
97
|
+
spawn: typeof spawnSync = spawnSync,
|
|
98
|
+
): { ok: true; integrity: string } | { ok: false; reason: string } | { ok: "skipped"; reason: string } {
|
|
99
|
+
if (!version) return { ok: "skipped", reason: "no resolved version (registry unavailable)" };
|
|
100
|
+
const npm = npmSpawnTarget("npm");
|
|
101
|
+
const r = spawn(
|
|
102
|
+
npm.bin,
|
|
103
|
+
["view", `${PKG}@${version}`, "dist.integrity"],
|
|
104
|
+
{ encoding: "utf8", timeout: 12000, windowsHide: true, shell: npm.shell },
|
|
105
|
+
);
|
|
106
|
+
// status !== 0 covers nonzero exits AND timeouts (status === null).
|
|
107
|
+
if (r.status !== 0) return { ok: "skipped", reason: `registry integrity query failed (status ${r.status ?? "timeout"})` };
|
|
108
|
+
const tokens = (r.stdout ?? "").replace(/["']/g, "").trim().split(/\s+/).filter(Boolean);
|
|
109
|
+
const match = tokens.find(token => /^sha512-[A-Za-z0-9+/=]+$/.test(token));
|
|
110
|
+
if (!match) return { ok: false, reason: `registry returned no sha512 integrity for ${PKG}@${version}` };
|
|
111
|
+
return { ok: true, integrity: match };
|
|
112
|
+
}
|
|
113
|
+
|
|
83
114
|
/**
|
|
84
115
|
* `ocx update` fallback for source checkouts and Bun global installs. npm global installs are updated
|
|
85
116
|
* in the Node bin launcher before Bun starts, so Windows does not replace the running Bun binary.
|
|
@@ -101,6 +132,19 @@ export async function runUpdate(): Promise<void> {
|
|
|
101
132
|
return;
|
|
102
133
|
}
|
|
103
134
|
|
|
135
|
+
// Pre-flight integrity metadata check — runs BEFORE the proxy is stopped so an
|
|
136
|
+
// anomalous registry entry aborts without unloading the running service.
|
|
137
|
+
const integrity = checkUpdatePackageIntegrity(latest);
|
|
138
|
+
if (integrity.ok === false) {
|
|
139
|
+
console.error(`⚠️ ${integrity.reason} — aborting the update before stopping the proxy.`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
if (integrity.ok === "skipped") {
|
|
143
|
+
console.warn(`⚠️ Integrity pre-flight skipped: ${integrity.reason}. Proceeding best-effort.`);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(`Verified ${PKG}@${latest} integrity metadata ${integrity.integrity.slice(0, 24)}…`);
|
|
146
|
+
}
|
|
147
|
+
|
|
104
148
|
// Remember whether a background service manages the proxy BEFORE stopping — `ocx stop`
|
|
105
149
|
// unloads it permanently, so a successful update must reinstall/restart it afterwards.
|
|
106
150
|
let serviceWasInstalled = false;
|
|
@@ -130,7 +174,7 @@ export async function runUpdate(): Promise<void> {
|
|
|
130
174
|
}
|
|
131
175
|
}
|
|
132
176
|
|
|
133
|
-
const { bin, args: cmdArgs } = updateCommand(installer, tag);
|
|
177
|
+
const { bin, args: cmdArgs } = updateCommand(installer, tag, latest);
|
|
134
178
|
console.log(`Updating${latest ? ` to v${latest}` : ""}…\n$ ${bin} ${cmdArgs.join(" ")}`);
|
|
135
179
|
|
|
136
180
|
const target = npmSpawnTarget(bin);
|
package/src/update/job.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { isServiceInstalled } from "../service";
|
|
|
8
8
|
import {
|
|
9
9
|
type Channel,
|
|
10
10
|
type Installer,
|
|
11
|
+
PKG,
|
|
12
|
+
checkUpdatePackageIntegrity,
|
|
11
13
|
currentVersion,
|
|
12
14
|
defaultUpdateTag,
|
|
13
15
|
detectInstall,
|
|
@@ -136,15 +138,18 @@ export function updateExecutionCommand(
|
|
|
136
138
|
installer: Installer,
|
|
137
139
|
channel: Channel,
|
|
138
140
|
launcher = packageLauncherPath(),
|
|
141
|
+
resolvedVersion?: string | null,
|
|
139
142
|
): { bin: string; args: string[]; display: string } {
|
|
140
143
|
if (installer === "npm") {
|
|
141
144
|
const bin = nodeBin();
|
|
142
145
|
const args = [launcher, "update", "--tag", channel];
|
|
146
|
+
// The Node launcher self-update re-resolves the tag at its own time — a residual
|
|
147
|
+
// divergence window this path cannot close (documented, not claimed immutable).
|
|
143
148
|
return { bin, args, display: formatCommand(bin, args) };
|
|
144
149
|
}
|
|
145
150
|
if (installer === "bun") {
|
|
146
|
-
const { bin, args } = updateCommand(installer, channel);
|
|
147
|
-
return { bin, args, display: updateCommandStr(installer, channel) };
|
|
151
|
+
const { bin, args } = updateCommand(installer, channel, resolvedVersion);
|
|
152
|
+
return { bin, args, display: updateCommandStr(installer, channel, resolvedVersion) };
|
|
148
153
|
}
|
|
149
154
|
return { bin: "sh", args: ["-lc", manualSourceCommand()], display: manualSourceCommand() };
|
|
150
155
|
}
|
|
@@ -319,13 +324,25 @@ export function runGuiUpdateWorker(jobId: string, channel: Channel, restart: boo
|
|
|
319
324
|
throw new Error(check.reason ?? "No update is available");
|
|
320
325
|
}
|
|
321
326
|
|
|
322
|
-
|
|
327
|
+
// Pre-flight integrity metadata check (same lanes as the CLI): anomalous registry
|
|
328
|
+
// metadata for a resolved version fails the job BEFORE anything is spawned or the
|
|
329
|
+
// proxy is stopped; transient registry failure degrades to a logged skip.
|
|
330
|
+
const integrity = checkUpdatePackageIntegrity(check.latestVersion);
|
|
331
|
+
if (integrity.ok === false) {
|
|
332
|
+
updateJob(job, { status: "failed", error: integrity.reason });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const integrityLine = integrity.ok === "skipped"
|
|
336
|
+
? `Integrity pre-flight skipped: ${integrity.reason}. Proceeding best-effort.`
|
|
337
|
+
: `Verified ${PKG}@${check.latestVersion} integrity metadata ${integrity.integrity.slice(0, 24)}…`;
|
|
338
|
+
|
|
339
|
+
const cmd = updateExecutionCommand(check.installer, channel, undefined, check.latestVersion);
|
|
323
340
|
job = updateJob(job, {
|
|
324
341
|
currentVersion: check.currentVersion,
|
|
325
342
|
latestVersion: check.latestVersion,
|
|
326
343
|
installer: check.installer,
|
|
327
344
|
command: cmd.display,
|
|
328
|
-
});
|
|
345
|
+
}, integrityLine);
|
|
329
346
|
|
|
330
347
|
/* [Decision Log]
|
|
331
348
|
- 목적: GUI 요청 처리 프로세스가 자신이 실행 중인 패키지를 직접 덮어쓰지 않도록 업데이트를 별도 worker에서 수행한다.
|
package/src/usage/log.ts
CHANGED
|
@@ -6,6 +6,29 @@ import type { OcxUsage } from "../types";
|
|
|
6
6
|
|
|
7
7
|
export type UsageStatus = "reported" | "unreported" | "unsupported" | "estimated";
|
|
8
8
|
|
|
9
|
+
export type AttemptRecoveryKind =
|
|
10
|
+
| "transient-5xx"
|
|
11
|
+
| "connection-reset"
|
|
12
|
+
| "oauth-401"
|
|
13
|
+
| "key-429"
|
|
14
|
+
| "image-413";
|
|
15
|
+
|
|
16
|
+
export interface PersistedUsageAttempt {
|
|
17
|
+
ordinal: number;
|
|
18
|
+
provider: string;
|
|
19
|
+
model: string;
|
|
20
|
+
adapter: string;
|
|
21
|
+
status: number;
|
|
22
|
+
durationMs: number;
|
|
23
|
+
sendCount: number;
|
|
24
|
+
recoveryKinds: AttemptRecoveryKind[];
|
|
25
|
+
usageStatus: UsageStatus;
|
|
26
|
+
inputTokenEstimate?: number;
|
|
27
|
+
usage?: OcxUsage;
|
|
28
|
+
totalTokens?: number;
|
|
29
|
+
errorCode?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
9
32
|
export interface PersistedUsageEntry {
|
|
10
33
|
requestId: string;
|
|
11
34
|
timestamp: number;
|
|
@@ -13,11 +36,13 @@ export interface PersistedUsageEntry {
|
|
|
13
36
|
model: string;
|
|
14
37
|
surface?: "claude";
|
|
15
38
|
resolvedModel?: string;
|
|
39
|
+
requestedModel?: string;
|
|
16
40
|
status: number;
|
|
17
41
|
durationMs: number;
|
|
18
42
|
usageStatus: UsageStatus;
|
|
19
43
|
usage?: OcxUsage;
|
|
20
44
|
totalTokens?: number;
|
|
45
|
+
attempts?: PersistedUsageAttempt[];
|
|
21
46
|
// Failure diagnostics (devlog/_plan/260716_claudecode_hardening/030): persisted for
|
|
22
47
|
// status>=400 or non-completed terminals so incidents survive the in-memory ring buffer.
|
|
23
48
|
errorCode?: string;
|
|
@@ -70,7 +95,101 @@ function normalizeUsageValue(usage: OcxUsage | undefined): OcxUsage | undefined
|
|
|
70
95
|
};
|
|
71
96
|
}
|
|
72
97
|
|
|
98
|
+
const ATTEMPT_RECOVERY_KINDS = new Set<AttemptRecoveryKind>([
|
|
99
|
+
"transient-5xx",
|
|
100
|
+
"connection-reset",
|
|
101
|
+
"oauth-401",
|
|
102
|
+
"key-429",
|
|
103
|
+
"image-413",
|
|
104
|
+
]);
|
|
105
|
+
const USAGE_STATUSES = new Set<UsageStatus>([
|
|
106
|
+
"reported",
|
|
107
|
+
"unreported",
|
|
108
|
+
"unsupported",
|
|
109
|
+
"estimated",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
function isNonNegativeFiniteNumber(value: unknown): value is number {
|
|
113
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeAttemptUsage(raw: unknown): OcxUsage | null {
|
|
117
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
118
|
+
const usage = raw as Record<string, unknown>;
|
|
119
|
+
if (!isNonNegativeFiniteNumber(usage.inputTokens)
|
|
120
|
+
|| !isNonNegativeFiniteNumber(usage.outputTokens)) return null;
|
|
121
|
+
for (const key of [
|
|
122
|
+
"totalTokens",
|
|
123
|
+
"cachedInputTokens",
|
|
124
|
+
"cacheReadInputTokens",
|
|
125
|
+
"cacheCreationInputTokens",
|
|
126
|
+
"reasoningOutputTokens",
|
|
127
|
+
] as const) {
|
|
128
|
+
if (key in usage && !isNonNegativeFiniteNumber(usage[key])) return null;
|
|
129
|
+
}
|
|
130
|
+
if ("estimated" in usage && typeof usage.estimated !== "boolean") return null;
|
|
131
|
+
return normalizeUsageValue(usage as unknown as OcxUsage) ?? null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeUsageAttempt(raw: unknown): PersistedUsageAttempt | null {
|
|
135
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
136
|
+
const attempt = raw as Record<string, unknown>;
|
|
137
|
+
if (typeof attempt.ordinal !== "number" || !Number.isInteger(attempt.ordinal)
|
|
138
|
+
|| attempt.ordinal < 1
|
|
139
|
+
|| typeof attempt.provider !== "string" || !attempt.provider
|
|
140
|
+
|| typeof attempt.model !== "string" || !attempt.model
|
|
141
|
+
|| typeof attempt.adapter !== "string" || !attempt.adapter
|
|
142
|
+
|| typeof attempt.status !== "number" || !Number.isInteger(attempt.status)
|
|
143
|
+
|| attempt.status < 100 || attempt.status > 599
|
|
144
|
+
|| typeof attempt.durationMs !== "number" || !Number.isFinite(attempt.durationMs)
|
|
145
|
+
|| attempt.durationMs < 0
|
|
146
|
+
|| typeof attempt.sendCount !== "number" || !Number.isInteger(attempt.sendCount)
|
|
147
|
+
|| attempt.sendCount < 0
|
|
148
|
+
|| typeof attempt.usageStatus !== "string"
|
|
149
|
+
|| !USAGE_STATUSES.has(attempt.usageStatus as UsageStatus)) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if ("inputTokenEstimate" in attempt
|
|
153
|
+
&& !isNonNegativeFiniteNumber(attempt.inputTokenEstimate)) return null;
|
|
154
|
+
if ("totalTokens" in attempt
|
|
155
|
+
&& !isNonNegativeFiniteNumber(attempt.totalTokens)) return null;
|
|
156
|
+
const usage = "usage" in attempt ? normalizeAttemptUsage(attempt.usage) : undefined;
|
|
157
|
+
if ("usage" in attempt && usage === null) return null;
|
|
158
|
+
const recoveryKinds = Array.isArray(attempt.recoveryKinds)
|
|
159
|
+
? [...new Set(attempt.recoveryKinds.filter(
|
|
160
|
+
(value): value is AttemptRecoveryKind => typeof value === "string"
|
|
161
|
+
&& ATTEMPT_RECOVERY_KINDS.has(value as AttemptRecoveryKind),
|
|
162
|
+
))]
|
|
163
|
+
: [];
|
|
164
|
+
return {
|
|
165
|
+
ordinal: attempt.ordinal as number,
|
|
166
|
+
provider: attempt.provider,
|
|
167
|
+
model: attempt.model,
|
|
168
|
+
adapter: attempt.adapter,
|
|
169
|
+
status: attempt.status,
|
|
170
|
+
durationMs: attempt.durationMs,
|
|
171
|
+
sendCount: attempt.sendCount as number,
|
|
172
|
+
recoveryKinds,
|
|
173
|
+
usageStatus: attempt.usageStatus as UsageStatus,
|
|
174
|
+
...(isNonNegativeFiniteNumber(attempt.inputTokenEstimate)
|
|
175
|
+
? { inputTokenEstimate: attempt.inputTokenEstimate }
|
|
176
|
+
: {}),
|
|
177
|
+
...(usage ? { usage } : {}),
|
|
178
|
+
...(isNonNegativeFiniteNumber(attempt.totalTokens)
|
|
179
|
+
? { totalTokens: attempt.totalTokens }
|
|
180
|
+
: {}),
|
|
181
|
+
...(typeof attempt.errorCode === "string" ? { errorCode: attempt.errorCode } : {}),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function normalizedAttempts(raw: unknown): PersistedUsageAttempt[] {
|
|
186
|
+
if (!Array.isArray(raw)) return [];
|
|
187
|
+
return raw.map(normalizeUsageAttempt)
|
|
188
|
+
.filter((attempt): attempt is PersistedUsageAttempt => attempt !== null);
|
|
189
|
+
}
|
|
190
|
+
|
|
73
191
|
function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
|
|
192
|
+
const attempts = normalizedAttempts(entry.attempts);
|
|
74
193
|
return {
|
|
75
194
|
requestId: entry.requestId,
|
|
76
195
|
timestamp: entry.timestamp,
|
|
@@ -78,11 +197,13 @@ function normalizeUsageEntry(entry: PersistedUsageEntry): PersistedUsageEntry {
|
|
|
78
197
|
model: entry.model,
|
|
79
198
|
...(entry.surface === "claude" ? { surface: entry.surface } : {}),
|
|
80
199
|
...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
|
|
200
|
+
...(entry.requestedModel ? { requestedModel: entry.requestedModel } : {}),
|
|
81
201
|
status: entry.status,
|
|
82
202
|
durationMs: entry.durationMs,
|
|
83
203
|
usageStatus: entry.usageStatus,
|
|
84
204
|
...(entry.usage ? { usage: normalizeUsageValue(entry.usage) } : {}),
|
|
85
205
|
...(typeof entry.totalTokens === "number" ? { totalTokens: entry.totalTokens } : {}),
|
|
206
|
+
...(attempts.length > 0 ? { attempts } : {}),
|
|
86
207
|
...(entry.errorCode ? { errorCode: entry.errorCode } : {}),
|
|
87
208
|
...(entry.terminalStatus ? { terminalStatus: entry.terminalStatus } : {}),
|
|
88
209
|
...(entry.closeReason ? { closeReason: entry.closeReason } : {}),
|
|
@@ -112,7 +233,9 @@ export function readUsageEntries(): PersistedUsageEntry[] {
|
|
|
112
233
|
if (!line.trim()) continue;
|
|
113
234
|
try {
|
|
114
235
|
const parsed = JSON.parse(line) as PersistedUsageEntry;
|
|
115
|
-
if (parsed && typeof parsed === "object" && typeof parsed.requestId === "string")
|
|
236
|
+
if (parsed && typeof parsed === "object" && typeof parsed.requestId === "string") {
|
|
237
|
+
entries.push(normalizeUsageEntry(parsed));
|
|
238
|
+
}
|
|
116
239
|
} catch {
|
|
117
240
|
/* keep reading after a partially written or hand-edited line */
|
|
118
241
|
}
|