@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.
Files changed (55) hide show
  1. package/README.ko.md +37 -7
  2. package/README.md +52 -11
  3. package/README.zh-CN.md +36 -7
  4. package/bin/ocx.mjs +5 -3
  5. package/gui/dist/assets/index-BzhyTAco.js +40 -0
  6. package/gui/dist/assets/index-Dq3eZ1cU.css +1 -0
  7. package/gui/dist/index.html +2 -2
  8. package/gui/dist/provider-icons/opencode.svg +1 -1
  9. package/package.json +5 -2
  10. package/src/adapters/anthropic-image-normalize.ts +70 -29
  11. package/src/adapters/cursor/transport-retry.ts +20 -1
  12. package/src/adapters/run-turn-queue.ts +40 -0
  13. package/src/codex/auth-api.ts +10 -1
  14. package/src/codex/auth-context.ts +33 -7
  15. package/src/codex/catalog.ts +357 -23
  16. package/src/codex/routing.ts +10 -4
  17. package/src/combos/failover.ts +102 -0
  18. package/src/combos/index.ts +37 -0
  19. package/src/combos/request.ts +31 -0
  20. package/src/combos/resolve.ts +171 -0
  21. package/src/combos/types.ts +203 -0
  22. package/src/config.ts +280 -11
  23. package/src/lib/errors.ts +86 -24
  24. package/src/lib/upstream-retry.ts +8 -4
  25. package/src/oauth/index.ts +7 -1
  26. package/src/oauth/key-providers.ts +2 -32
  27. package/src/oauth/login-cli.ts +4 -3
  28. package/src/oauth/token-guardian.ts +38 -3
  29. package/src/providers/derive.ts +27 -2
  30. package/src/providers/kiro-models.ts +8 -3
  31. package/src/providers/label.ts +3 -1
  32. package/src/providers/openai-sidecar.ts +94 -0
  33. package/src/providers/openai-tier-startup.ts +27 -0
  34. package/src/providers/openai-tiers.ts +283 -0
  35. package/src/providers/openai-virtual-models.ts +82 -0
  36. package/src/providers/quota.ts +344 -24
  37. package/src/providers/registry.ts +112 -20
  38. package/src/reasoning-effort.ts +12 -11
  39. package/src/router.ts +80 -36
  40. package/src/server/auth-cors.ts +85 -9
  41. package/src/server/images.ts +31 -75
  42. package/src/server/index.ts +45 -86
  43. package/src/server/management-api.ts +273 -21
  44. package/src/server/request-log.ts +221 -20
  45. package/src/server/responses.ts +594 -75
  46. package/src/server/search.ts +22 -37
  47. package/src/types.ts +49 -1
  48. package/src/update/index.ts +50 -6
  49. package/src/update/job.ts +21 -4
  50. package/src/usage/log.ts +124 -1
  51. package/src/usage/summary.ts +147 -56
  52. package/src/vision/index.ts +20 -19
  53. package/src/web-search/index.ts +15 -17
  54. package/gui/dist/assets/index-Bk_GgFrh.css +0 -1
  55. package/gui/dist/assets/index-DQjt6Hly.js +0 -40
@@ -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, OcxProviderConfig } from "../types";
24
- import { isProxyAdmissionSecret } from "./auth-cors";
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, sidecarOutcomeRecorder } from "./responses";
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 upstream = findSearchUpstream(config);
66
- if (!upstream) {
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 authHeaders: Headers;
76
- let recordOutcome: ReturnType<typeof sidecarOutcomeRecorder>;
67
+ let upstream: Awaited<ReturnType<typeof resolveFirstUsableOpenAiSidecar>>;
77
68
  try {
78
- const authCtx = await resolveCodexAuthContext(req.headers, config);
79
- if (!isCodexAuthContextUsable(authCtx, config)) {
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
- recordOutcome = sidecarOutcomeRecorder(config, authCtx);
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(upstream.name, err.accountId, config);
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 authHeaders) headers[name] = value;
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;
@@ -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}@${tag}`]
73
- : ["install", "-g", `${PKG}@${tag}`];
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
- const cmd = updateExecutionCommand(check.installer, channel);
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") entries.push(parsed);
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
  }