@yansigit/opencodex 2.36.1-dev.20260829.48 → 2.36.1

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 (94) hide show
  1. package/gui/dist/assets/{ApiKeys-LOOFiZfv.js → ApiKeys-CsmNyf4I.js} +1 -1
  2. package/gui/dist/assets/{Claude-BwKzpXe3.js → Claude-B4sHsGTD.js} +1 -1
  3. package/gui/dist/assets/{CodexSet-B5qi9KeE.js → CodexSet-BuAQ4YQn.js} +1 -1
  4. package/gui/dist/assets/{FileIntegrationPage-B0sUef6W.js → FileIntegrationPage-BC65sEaS.js} +1 -1
  5. package/gui/dist/assets/{Grok-bsb4n-f4.js → Grok-DNJWlISf.js} +1 -1
  6. package/gui/dist/assets/{Integrations-CSMwFDvM.js → Integrations-vGJshFZK.js} +2 -2
  7. package/gui/dist/assets/{IntegrationsOverview-CTORdKJA.js → IntegrationsOverview-DYMkDFHE.js} +1 -1
  8. package/gui/dist/assets/{Logs-BWYbfELf.js → Logs-Coc7_RPP.js} +1 -1
  9. package/gui/dist/assets/{Models-CexEtdT1.js → Models-CKVT8Meb.js} +1 -1
  10. package/gui/dist/assets/{NumberStepper-BapSFQnW.js → NumberStepper-Dug4wR8n.js} +1 -1
  11. package/gui/dist/assets/{Providers-BwUFeAgA.js → Providers-DyaCHRWL.js} +1 -1
  12. package/gui/dist/assets/{RestoreDialog-CD8piq90.js → RestoreDialog-BpuvVU2w.js} +1 -1
  13. package/gui/dist/assets/{Startup-BgX731C2.js → Startup-DAUQqNEN.js} +1 -1
  14. package/gui/dist/assets/{Storage-Bgq7HphP.js → Storage-DfP95SlJ.js} +1 -1
  15. package/gui/dist/assets/{Subagents-ChxItbeF.js → Subagents-CMNsZLfk.js} +1 -1
  16. package/gui/dist/assets/{Usage-BkylzP50.js → Usage-ESFRfBMO.js} +1 -1
  17. package/gui/dist/assets/{codex-stale-banner-D4gvSWnO.js → codex-stale-banner-W4FhikN7.js} +1 -1
  18. package/gui/dist/assets/{data-surface-BtO3lwam.js → data-surface-B8PX8rjm.js} +1 -1
  19. package/gui/dist/assets/{data-surface-pCXChiBf.js → data-surface-CETFXqiA.js} +1 -1
  20. package/gui/dist/assets/{index-CNopOid3.js → index-DHQHBRhb.js} +3 -3
  21. package/gui/dist/assets/{model-display-CiUpg8T9.js → model-display-OBTJhQwT.js} +1 -1
  22. package/gui/dist/assets/{provider-payload-b2jlS-On.js → provider-payload-CJRJUn7w.js} +1 -1
  23. package/gui/dist/assets/{section-tabs-BP7gEPK6.js → section-tabs-B8sLMiz6.js} +1 -1
  24. package/gui/dist/assets/shared-DI18-uF7.js +69 -0
  25. package/gui/dist/index.html +2 -2
  26. package/package.json +3 -1
  27. package/src/adapters/base.ts +26 -0
  28. package/src/adapters/cursor/catalog.ts +541 -0
  29. package/src/adapters/cursor/cursor-errors.ts +15 -0
  30. package/src/adapters/cursor/discovery.ts +34 -41
  31. package/src/adapters/cursor/envelope-echo.ts +128 -0
  32. package/src/adapters/cursor/request-builder.ts +19 -12
  33. package/src/adapters/cursor/tool-definitions.ts +2 -1
  34. package/src/adapters/cursor/tool-result-normalize.ts +23 -31
  35. package/src/adapters/cursor.ts +21 -2
  36. package/src/adapters/exec-tool-result-normalize.ts +99 -0
  37. package/src/adapters/google-antigravity-replay.ts +71 -2
  38. package/src/adapters/google.ts +19 -4
  39. package/src/adapters/kiro-constants.ts +12 -0
  40. package/src/adapters/kiro.ts +128 -11
  41. package/src/adapters/openai-chat.ts +16 -2
  42. package/src/adapters/openai-responses.ts +15 -2
  43. package/src/adapters/tool-catalog-nudge.ts +2 -1
  44. package/src/adapters/xai-web-search.ts +10 -14
  45. package/src/claude/outbound.ts +14 -3
  46. package/src/cli/access.ts +46 -3
  47. package/src/cli/account-api.ts +93 -16
  48. package/src/cli/account-extended.ts +262 -36
  49. package/src/cli/account-main.ts +12 -12
  50. package/src/cli/account.ts +40 -10
  51. package/src/cli/agent.ts +8 -1
  52. package/src/cli/capabilities-command.ts +94 -0
  53. package/src/cli/capabilities.ts +535 -0
  54. package/src/cli/claude-desktop.ts +31 -11
  55. package/src/cli/dispatch.ts +195 -27
  56. package/src/cli/doctor.ts +100 -1
  57. package/src/cli/help.ts +11 -2
  58. package/src/cli/index.ts +19 -3
  59. package/src/cli/inspect.ts +230 -0
  60. package/src/cli/observe.ts +11 -3
  61. package/src/cli/registry.ts +34 -2
  62. package/src/cli/runtime-api.ts +51 -7
  63. package/src/cli/status.ts +16 -0
  64. package/src/cli/storage.ts +234 -0
  65. package/src/cli/system-command.ts +16 -0
  66. package/src/cli/usage-report.ts +52 -2
  67. package/src/cli/version-skew.ts +46 -0
  68. package/src/codex/account-label.ts +21 -0
  69. package/src/codex/catalog/provider-fetch.ts +4 -0
  70. package/src/codex/transition-state.ts +12 -3
  71. package/src/compatibility/openai-responses.ts +9 -1
  72. package/src/generated/compatibility-version.json +94 -58
  73. package/src/integrations/ownership-policy.ts +24 -5
  74. package/src/integrations/ownership.ts +36 -2
  75. package/src/integrations/state.ts +40 -7
  76. package/src/integrations/writer.ts +21 -3
  77. package/src/lib/admin-secrets.ts +24 -0
  78. package/src/lib/errors.ts +25 -1
  79. package/src/lib/service-secrets.ts +15 -0
  80. package/src/oauth/store.ts +16 -6
  81. package/src/providers/label.ts +34 -1
  82. package/src/responses/turn-termination.ts +107 -0
  83. package/src/server/management/logs-usage-routes.ts +5 -27
  84. package/src/server/management/route-registry.ts +317 -0
  85. package/src/server/proxy-liveness.ts +27 -4
  86. package/src/server/request-log.ts +37 -17
  87. package/src/server/responses/core.ts +80 -4
  88. package/src/server/responses/policy-fallback.ts +1 -1
  89. package/src/service.ts +34 -0
  90. package/src/storage/policy-job.ts +14 -4
  91. package/src/storage/policy.ts +79 -33
  92. package/src/usage/log.ts +32 -6
  93. package/src/usage/summary.ts +22 -34
  94. package/gui/dist/assets/shared-BdYGV-yJ.js +0 -69
@@ -12,10 +12,11 @@ import { ClientPathError, EXPORT_CLIENTS, opencodeProxyBaseUrl, type ExportModel
12
12
  import type { OcxConfig } from "../types";
13
13
  import { PARSE_FAILED, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
14
14
  import { SNAPSHOT_RETENTION } from "./journal";
15
- import { canonicalContribution, fingerprint, type OwnershipRecord } from "./ownership";
15
+ import { canonicalContribution, fingerprint, semanticContribution, type OwnershipRecord } from "./ownership";
16
16
  import {
17
17
  protectedContributionFingerprint,
18
18
  refreshablePathsOf,
19
+ semanticProtectedContributionFingerprint,
19
20
  validRefreshablePaths,
20
21
  } from "./ownership-policy";
21
22
  import { INTEGRATION_CLIENTS, type IntegrationClientId } from "./registry";
@@ -153,21 +154,49 @@ function recordedBlockIsOwned(
153
154
  if (!observed) return false;
154
155
  if (fingerprint(canonicalContribution(observed)) === record.blockFingerprint) return true;
155
156
 
157
+ const observedSemanticFingerprint = fingerprint(semanticContribution(observed));
158
+ if (
159
+ typeof record.semanticBlockFingerprint === "string"
160
+ && observedSemanticFingerprint === record.semanticBlockFingerprint
161
+ ) return true;
162
+
163
+ const desiredFingerprint = fingerprint(canonicalContribution(desired));
164
+ if (
165
+ desiredFingerprint === record.blockFingerprint
166
+ && observedSemanticFingerprint === fingerprint(semanticContribution(desired))
167
+ ) return true;
168
+
156
169
  if (
157
170
  typeof record.protectedBlockFingerprint === "string"
158
171
  && validRefreshablePaths(observed, record.refreshablePaths)
159
172
  && record.refreshablePaths.length > 0
160
173
  ) {
161
- return protectedContributionFingerprint(observed, record.refreshablePaths)
162
- === record.protectedBlockFingerprint;
174
+ const observedProtectedFingerprint = protectedContributionFingerprint(
175
+ observed,
176
+ record.refreshablePaths,
177
+ );
178
+ if (observedProtectedFingerprint === record.protectedBlockFingerprint) return true;
179
+
180
+ const observedSemanticProtectedFingerprint = semanticProtectedContributionFingerprint(
181
+ observed,
182
+ record.refreshablePaths,
183
+ );
184
+ if (
185
+ typeof record.semanticProtectedBlockFingerprint === "string"
186
+ && observedSemanticProtectedFingerprint === record.semanticProtectedBlockFingerprint
187
+ ) return true;
188
+
189
+ return protectedContributionFingerprint(desired, record.refreshablePaths)
190
+ === record.protectedBlockFingerprint
191
+ && observedSemanticProtectedFingerprint
192
+ === semanticProtectedContributionFingerprint(desired, record.refreshablePaths);
163
193
  }
164
194
 
165
- const desiredFingerprint = fingerprint(canonicalContribution(desired));
166
195
  if (desiredFingerprint !== record.blockFingerprint) return false;
167
196
  const legacyPaths = refreshablePathsOf(desired);
168
197
  return legacyPaths.length > 0
169
- && protectedContributionFingerprint(observed, legacyPaths)
170
- === protectedContributionFingerprint(desired, legacyPaths);
198
+ && semanticProtectedContributionFingerprint(observed, legacyPaths)
199
+ === semanticProtectedContributionFingerprint(desired, legacyPaths);
171
200
  }
172
201
 
173
202
  /**
@@ -257,7 +286,11 @@ export function classifyIntegration(input: {
257
286
  }
258
287
  return { state: "stale" };
259
288
  }
260
- return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
289
+ const desiredFingerprint = typeof input.record.semanticBlockFingerprint === "string"
290
+ ? fingerprint(semanticContribution(input.contribution))
291
+ : fingerprint(canonicalContribution(input.contribution));
292
+ const recordedFingerprint = input.record.semanticBlockFingerprint ?? input.record.blockFingerprint;
293
+ return recordedFingerprint === desiredFingerprint
261
294
  ? { state: "current" }
262
295
  : { state: "stale" };
263
296
  }
@@ -15,8 +15,18 @@ import { EXPORT_CLIENTS, type ExportModel, type ManagedContribution } from "../c
15
15
  import { isLoopbackHostname } from "../codex/inject";
16
16
  import type { OcxConfig } from "../types";
17
17
  import { PARSE_FAILED, defaultIntegrationIO, loadTarget, parseConfig, type IntegrationIO } from "./config-io";
18
- import { fingerprint, canonicalContribution, fragmentPathsOf, type OwnershipRecord } from "./ownership";
19
- import { protectedContributionFingerprint, refreshablePathsOf } from "./ownership-policy";
18
+ import {
19
+ fingerprint,
20
+ canonicalContribution,
21
+ fragmentPathsOf,
22
+ semanticContribution,
23
+ type OwnershipRecord,
24
+ } from "./ownership";
25
+ import {
26
+ protectedContributionFingerprint,
27
+ refreshablePathsOf,
28
+ semanticProtectedContributionFingerprint,
29
+ } from "./ownership-policy";
20
30
  import { createdContainerPaths, mergeContribution, removeFragments } from "./merge";
21
31
  import { INTEGRATION_CLIENTS, isLoopbackOnly, type IntegrationClientId } from "./registry";
22
32
  import { classifyIntegration, exportContextOf } from "./state";
@@ -361,8 +371,13 @@ function applyOrRefreshIntegration(input: IntegrationWriteInput, allowAbsent: bo
361
371
  record: {
362
372
  clientId, configPath, fileFingerprint: fingerprint(text),
363
373
  blockFingerprint: fingerprint(canonicalContribution(contribution)),
374
+ semanticBlockFingerprint: fingerprint(semanticContribution(contribution)),
364
375
  ...(refreshablePaths.length > 0 ? {
365
376
  protectedBlockFingerprint: protectedContributionFingerprint(contribution, refreshablePaths),
377
+ semanticProtectedBlockFingerprint: semanticProtectedContributionFingerprint(
378
+ contribution,
379
+ refreshablePaths,
380
+ ),
366
381
  refreshablePaths,
367
382
  } : {}),
368
383
  fragmentPaths: fragmentPathsOf(contribution), createdContainers: created,
@@ -556,7 +571,10 @@ export function restoreIntegration(input: IntegrationRestoreInput): WriteOutcome
556
571
  ? (restoredText === null ? "absent" : "conflict")
557
572
  : !recordDescribesBytes
558
573
  ? "conflict"
559
- : restoredRecord.blockFingerprint === fingerprint(canonicalContribution(fresh))
574
+ : (
575
+ restoredRecord.semanticBlockFingerprint === fingerprint(semanticContribution(fresh))
576
+ || restoredRecord.blockFingerprint === fingerprint(canonicalContribution(fresh))
577
+ )
560
578
  ? "current"
561
579
  : "stale";
562
580
 
@@ -1,3 +1,4 @@
1
+ import { timingSafeEqual } from "node:crypto";
1
2
  import { lstatSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { getConfigDir } from "../config";
@@ -23,3 +24,26 @@ export function loadAdminTokenFromFile(configDir = getConfigDir()): string | nul
23
24
  export function configuredAdminToken(configDir = getConfigDir(), env: NodeJS.ProcessEnv = process.env): string | null {
24
25
  return env.OPENCODEX_ADMIN_AUTH_TOKEN?.trim() || loadAdminTokenFromFile(configDir);
25
26
  }
27
+
28
+ export const ADMIN_TOKEN_PREFIX = "ocx_admin_";
29
+
30
+ function secretTextEquals(left: string, right: string): boolean {
31
+ const a = Buffer.from(left);
32
+ const b = Buffer.from(right);
33
+ return a.length === b.length && timingSafeEqual(a, b);
34
+ }
35
+
36
+ /**
37
+ * True when `token` is a management credential: minted `ocx_admin_…` shape, or
38
+ * byte-equal to the configured admin token (env or admin-api-token file).
39
+ * Used by the service write/start chokepoint and by doctor so the two cannot drift.
40
+ */
41
+ export function tokenCollidesWithAdmin(
42
+ token: string,
43
+ env: NodeJS.ProcessEnv = process.env,
44
+ configDir = getConfigDir(),
45
+ ): boolean {
46
+ if (token.startsWith(ADMIN_TOKEN_PREFIX)) return true;
47
+ const admin = configuredAdminToken(configDir, env);
48
+ return admin !== null && secretTextEquals(token, admin);
49
+ }
package/src/lib/errors.ts CHANGED
@@ -349,6 +349,12 @@ export function inferHttpStatusFromAdapterMessage(message: string): number {
349
349
  // subscription/permission wording.
350
350
  if (isAuthenticationMessage(lower)) return 401;
351
351
  if (isSubscriptionGateMessage(lower) || isPermissionMessage(lower)) return 403;
352
+ // Same precedence rule as classifyCursorError: an explicit gRPC FAILED_PRECONDITION is a
353
+ // structured, deterministic rejection, so it outranks the overload keywords that routinely
354
+ // appear beside it ("failed_precondition: model unavailable for this plan"). Without this,
355
+ // the message matched "unavailable" and returned a retryable 503, so clients kept retrying
356
+ // a rejection that can never succeed.
357
+ if (lower.includes("failed_precondition") || lower.includes("failed precondition")) return 400;
352
358
  if (
353
359
  lower.includes("unavailable") ||
354
360
  lower.includes("overloaded") ||
@@ -424,6 +430,24 @@ export function httpStatusFromTerminalError(error: {
424
430
  if (message && isClientClosedMessage(message)) return 499;
425
431
  if (error.type === "invalid_request_error") return 400;
426
432
  if (error.type === "proxy_error") return 500;
427
- if (message) return inferHttpStatusFromAdapterMessage(message);
433
+ // A structured server class must not be downgraded to a CLIENT error by message wording.
434
+ // classifyError assigns `server_error` + `upstream_server_error` to every 5xx it sees, so
435
+ // the class is authoritative about blame: the upstream failed, the caller did not send a
436
+ // bad request. What it is NOT authoritative about is which server status fits — a stall is
437
+ // genuinely 504 and an overload genuinely 503, and flattening those to 502 discards
438
+ // information both the log surface and the retry policy read. So message inference still
439
+ // chooses the specific status, and only a client-error verdict is overridden.
440
+ //
441
+ // The override is deliberately narrowed to 400 alone. 429, 499, 401 and 403 are all
442
+ // actionable signals the caller routes on — retry-after, client cancellation, re-auth,
443
+ // entitlement — and overriding them would trade one kind of misreport for another. 400 is
444
+ // the single verdict that both blames the caller and stops the retry, which is the failure
445
+ // being fixed: an upstream 500 whose text happens to contain "malformed" or "invalid
446
+ // request" used to return 400, so Claude Code stopped retrying a retryable failure.
447
+ const structuredServerClass = error.type === "server_error" || error.code === "upstream_server_error";
448
+ if (message) {
449
+ const inferred = inferHttpStatusFromAdapterMessage(message);
450
+ return structuredServerClass && inferred === 400 ? 502 : inferred;
451
+ }
428
452
  return 502;
429
453
  }
@@ -23,3 +23,18 @@ export function loadServiceTokenFromFile(env: Record<string, string | undefined>
23
23
  return null;
24
24
  }
25
25
  }
26
+
27
+ /**
28
+ * Contents of the installed service token file. The launch wrapper always re-exports
29
+ * this file as OPENCODEX_API_AUTH_TOKEN, so doctor and start must inspect it even
30
+ * when the calling shell has no data-plane env var.
31
+ * Returns the token or null — never throws, never logs the value.
32
+ */
33
+ export function readInstalledServiceToken(): string | null {
34
+ try {
35
+ const token = readFileSync(serviceApiTokenFilePath(), "utf8").trim();
36
+ return token || null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
@@ -591,24 +591,34 @@ export async function upsertCredentialByIdentity(
591
591
  }, [provider, safe]);
592
592
  }
593
593
 
594
- /** Remove the ACTIVE account; remaining accounts promote the first one. */
595
- export async function removeCredential(provider: string): Promise<void> {
594
+ /**
595
+ * Remove the ACTIVE account; remaining accounts promote the first one.
596
+ *
597
+ * Returns what actually happened, which a caller cannot otherwise know. A read-then-remove
598
+ * preflight is not equivalent: `mutateStore` serializes mutations, so between a caller's
599
+ * `getAccountSet` check and its `removeCredential` call another process can remove the same
600
+ * account, and both callers would then report a removal that only one of them performed.
601
+ * Deciding inside the mutation is the only place the answer is true when it is returned.
602
+ */
603
+ export async function removeCredential(provider: string): Promise<"removed" | "not-found"> {
596
604
  let removedAccountId: string | undefined;
597
- await mutateStore(store => {
605
+ const result = await mutateStore(store => {
598
606
  const set = store[provider];
599
- if (!set) return;
607
+ if (!set) return "not-found" as const;
600
608
  removedAccountId = set.activeAccountId;
601
609
  set.accounts = set.accounts.filter(a => a.id !== set.activeAccountId);
602
610
  if (set.accounts.length === 0) {
603
611
  delete store[provider];
604
- return;
612
+ return "removed" as const;
605
613
  }
606
614
  set.activeAccountId = set.accounts[0]!.id;
615
+ return "removed" as const;
607
616
  }, [provider]);
608
- if (provider === "google-antigravity" && removedAccountId) {
617
+ if (result === "removed" && provider === "google-antigravity" && removedAccountId) {
609
618
  const { clearAntigravityRoutingStateForAccount } = await import("./antigravity-routing");
610
619
  clearAntigravityRoutingStateForAccount(removedAccountId);
611
620
  }
621
+ return result;
612
622
  }
613
623
 
614
624
  // ---------------------------------------------------------------------------
@@ -1,4 +1,5 @@
1
- import { CODEX_ACCOUNT_LOG_LABEL_RE } from "../codex/account-label";
1
+ import { CODEX_ACCOUNT_LOG_LABEL_RE, oauthAccountLogLabel } from "../codex/account-label";
2
+ import type { OcxProviderConfig } from "../types";
2
3
 
3
4
  export function canonicalUsageProviderLabel(provider: string): string {
4
5
  return provider === "chatgpt" || provider === "openai-multi" ? "openai" : provider;
@@ -17,3 +18,35 @@ export function baseProviderLabel(provider: string): string {
17
18
  if (suffix === "main") return canonicalUsageProviderLabel(provider.slice(0, cut));
18
19
  return CODEX_ACCOUNT_LOG_LABEL_RE.test(suffix) ? canonicalUsageProviderLabel(provider.slice(0, cut)) : provider;
19
20
  }
21
+
22
+ /**
23
+ * Stamp the per-account usage label for a non-Codex OAuth provider (#2699).
24
+ *
25
+ * Call this where the resolved credential is known and NOT inside a failover gate. The obvious
26
+ * place -- next to the `genericFailoverAccountId` assignment in `core.ts` -- sits inside
27
+ * `isGenericFailoverProvider`, and the rotation paths additionally require two or more stored
28
+ * accounts. Stamping there would leave the ordinary case (one xai account, failover off) with no
29
+ * label at all while every test still passed, which is the bug this fixes rather than a variant
30
+ * of it.
31
+ *
32
+ * Two providers are skipped because they already have attribution:
33
+ * - `openai` produces its own `p`-labels through `codexAuthContextLogLabel`.
34
+ * - `anthropic` folds the account into the provider label (`formatAnthropicProviderForLog`).
35
+ *
36
+ * It lives here rather than in `account-label.ts` because it needs `baseProviderLabel`, and this
37
+ * module already imports from that one -- the reverse direction would be an import cycle. This
38
+ * file stays Lab-clean, which matters because `core.ts` is one of the three files
39
+ * `tests/core-lab-boundary.test.ts` guards.
40
+ */
41
+ export function stampOAuthAccountLabel(
42
+ logCtx: { accountLogLabel?: string },
43
+ providerName: string,
44
+ provider: Pick<OcxProviderConfig, "authMode">,
45
+ accountId: string | undefined,
46
+ ): void {
47
+ if (!accountId) return;
48
+ if (provider.authMode !== "oauth") return;
49
+ const base = baseProviderLabel(providerName);
50
+ if (base === "openai" || base === "anthropic") return;
51
+ logCtx.accountLogLabel = oauthAccountLogLabel(accountId, base);
52
+ }
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { OcxAssistantMessage, OcxMessage, OcxParsedRequest } from "../types";
3
+
4
+ const DELIVERED_FINAL_ANSWER_TTL_MS = 60 * 60 * 1_000;
5
+ const DELIVERED_FINAL_ANSWER_MAX_ENTRIES = 1_024;
6
+
7
+ interface DeliveredFinalAnswerRecord {
8
+ fingerprint: string;
9
+ createdAt: number;
10
+ }
11
+
12
+ const scopesByRequest = new WeakMap<OcxParsedRequest, string>();
13
+ const deliveredFinalAnswers = new Map<string, DeliveredFinalAnswerRecord>();
14
+
15
+ function pruneDeliveredFinalAnswers(at = Date.now()): void {
16
+ for (const [scope, record] of deliveredFinalAnswers) {
17
+ if (at - record.createdAt > DELIVERED_FINAL_ANSWER_TTL_MS) deliveredFinalAnswers.delete(scope);
18
+ }
19
+ while (deliveredFinalAnswers.size > DELIVERED_FINAL_ANSWER_MAX_ENTRIES) {
20
+ const oldest = deliveredFinalAnswers.keys().next().value;
21
+ if (oldest === undefined) break;
22
+ deliveredFinalAnswers.delete(oldest);
23
+ }
24
+ }
25
+
26
+ function textFingerprint(text: string): string {
27
+ return createHash("sha256").update(text, "utf8").digest("hex");
28
+ }
29
+
30
+ function assistantText(message: OcxAssistantMessage): string | undefined {
31
+ if (message.content.some(part => part.type === "toolCall")) return undefined;
32
+ const text = message.content
33
+ .filter((part): part is Extract<typeof part, { type: "text" }> => part.type === "text")
34
+ .map(part => part.text)
35
+ .join("");
36
+ return text.trim().length > 0 ? text : undefined;
37
+ }
38
+
39
+ function deliveredFinalAnswerText(response: unknown): string | undefined {
40
+ if (!response || typeof response !== "object" || Array.isArray(response)) return undefined;
41
+ const output = (response as { output?: unknown }).output;
42
+ if (!Array.isArray(output)) return undefined;
43
+ for (let i = output.length - 1; i >= 0; i--) {
44
+ const item = output[i];
45
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
46
+ const message = item as { type?: unknown; role?: unknown; phase?: unknown; content?: unknown };
47
+ if (message.type !== "message" || message.role !== "assistant" || message.phase !== "final_answer") continue;
48
+ if (!Array.isArray(message.content)) return undefined;
49
+ const text = message.content
50
+ .filter(part => !!part && typeof part === "object" && !Array.isArray(part)
51
+ && (part as { type?: unknown }).type === "output_text"
52
+ && typeof (part as { text?: unknown }).text === "string")
53
+ .map(part => (part as { text: string }).text)
54
+ .join("");
55
+ return text.trim().length > 0 ? text : undefined;
56
+ }
57
+ return undefined;
58
+ }
59
+
60
+ /** Bind the normalized per-conversation digest without adding proxy-private fields to the wire body. */
61
+ export function bindTurnTerminationScope(parsed: OcxParsedRequest, scope: string | undefined): void {
62
+ // Only the normalized log-conversation digest may key this process-wide map. Refusing any raw
63
+ // fallback prevents a future caller from retaining a client header or account identifier here.
64
+ if (!scope || !/^[0-9a-f]{32}$/.test(scope)) return;
65
+ scopesByRequest.set(parsed, scope);
66
+ }
67
+
68
+ /** Remember only a final-answer message the proxy actually emitted for this exact conversation. */
69
+ export function rememberDeliveredFinalAnswer(parsed: OcxParsedRequest, response: unknown): void {
70
+ const scope = scopesByRequest.get(parsed);
71
+ if (!scope) return;
72
+ const text = deliveredFinalAnswerText(response);
73
+ if (!text) return;
74
+ const at = Date.now();
75
+ pruneDeliveredFinalAnswers(at);
76
+ // Refresh insertion order so cap eviction removes the least recently delivered conversation.
77
+ deliveredFinalAnswers.delete(scope);
78
+ deliveredFinalAnswers.set(scope, { fingerprint: textFingerprint(text), createdAt: at });
79
+ pruneDeliveredFinalAnswers(at);
80
+ }
81
+
82
+ /**
83
+ * Match only when the delivered assistant answer is still the trailing content-bearing message.
84
+ * A later user/tool-result message is new work and must reach the provider, even though every
85
+ * legitimate next turn necessarily contains such a message somewhere in its history.
86
+ */
87
+ export function hasRecordedTrailingDeliveredFinalAnswer(
88
+ parsed: OcxParsedRequest,
89
+ messages: readonly OcxMessage[],
90
+ ): boolean {
91
+ const scope = scopesByRequest.get(parsed);
92
+ if (!scope) return false;
93
+ pruneDeliveredFinalAnswers();
94
+ const record = deliveredFinalAnswers.get(scope);
95
+ if (!record) return false;
96
+ for (let i = messages.length - 1; i >= 0; i--) {
97
+ const message = messages[i];
98
+ if (message.role !== "assistant") return false;
99
+ const text = assistantText(message as OcxAssistantMessage);
100
+ if (text === undefined) {
101
+ if ((message as OcxAssistantMessage).content.some(part => part.type === "toolCall")) return false;
102
+ continue;
103
+ }
104
+ return textFingerprint(text) === record.fingerprint;
105
+ }
106
+ return false;
107
+ }
@@ -20,7 +20,7 @@ import {
20
20
  submitManualLoginCode,
21
21
  upsertOAuthProvider,
22
22
  } from "../../oauth";
23
- import { getAccountSet, removeCredential } from "../../oauth/store";
23
+ import { removeCredential } from "../../oauth/store";
24
24
  import { providerDestinationResolvedError } from "../../lib/destination-policy";
25
25
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "../../oauth/key-providers";
26
26
  import { deriveProviderPresets } from "../../providers/derive";
@@ -31,8 +31,6 @@ import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
31
31
  import { clearThreadAccountMap } from "../../codex/routing";
32
32
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
33
33
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
34
- import { resolveCodexHomeDir } from "../../codex/home";
35
- import { scanStorage } from "../../storage/scanner";
36
34
  import { executeArchivedCleanup, listTrashEntries, pickWireCleanupTestHooks, previewArchivedCleanup, type CleanupMode, type RestoreErrorCode } from "../../storage/cleanup";
37
35
  import { runArchivedCleanupJob } from "../../storage/cleanup-job";
38
36
  import { getRestoreTrashTestStreamResponse, runRestoreTrashEntryJob } from "../../storage/restore-job";
@@ -198,16 +196,10 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
198
196
  const filter = {
199
197
  provider: url.searchParams.get("provider"),
200
198
  model: url.searchParams.get("model"),
201
- account: url.searchParams.get("account"),
202
199
  };
203
- const fallbackAccounts: Record<string, string> = {};
204
- for (const p of listOAuthProviders()) {
205
- const set = getAccountSet(p);
206
- if (set && set.accounts.length === 1 && set.activeAccountId) fallbackAccounts[p] = set.activeAccountId;
207
- }
208
200
  const project = <T extends UsageSummary>(summary: T, entries?: PersistedUsageEntry[]) =>
209
- projectUsageSummary(summary, filter, entries, fallbackAccounts);
210
- const filterRequested = Boolean(filter.provider ?? filter.model ?? filter.account);
201
+ projectUsageSummary(summary, filter, entries);
202
+ const filterRequested = Boolean(filter.provider ?? filter.model);
211
203
  const now = Date.now();
212
204
  try {
213
205
  const cacheKey = `${range}:${surface}`;
@@ -242,7 +234,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
242
234
  const revisionReadAt = Date.now();
243
235
  const window = snapshotWindow(snapshot.entries);
244
236
  const summary = {
245
- ...summarizeUsage(snapshot.entries, range, now, surface, fallbackAccounts),
237
+ ...summarizeUsage(snapshot.entries, range, now, surface),
246
238
  historyTruncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
247
239
  truncatedPrefixBytes: snapshot.truncatedPrefixBytes,
248
240
  entriesTruncated: snapshot.entriesTruncated,
@@ -270,7 +262,7 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
270
262
  for (const nextRange of ranges) {
271
263
  for (const nextSurface of surfaces) {
272
264
  const nextSummary = nextRange === range && nextSurface === surface ? summary : {
273
- ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface, fallbackAccounts),
265
+ ...summarizeUsage(snapshot.entries, nextRange, now, nextSurface),
274
266
  historyTruncated: summary.historyTruncated,
275
267
  truncatedPrefixBytes: summary.truncatedPrefixBytes,
276
268
  entriesTruncated: summary.entriesTruncated,
@@ -343,20 +335,6 @@ export async function handleLogsUsageRoutes(ctx: ManagementContext): Promise<Res
343
335
  }
344
336
  }
345
337
 
346
- if (url.pathname === "/api/storage" && req.method === "GET") {
347
- try {
348
- return jsonResponse(scanStorage());
349
- } catch {
350
- return jsonResponse({
351
- codexHome: resolveCodexHomeDir(),
352
- generatedAt: Date.now(),
353
- total: { bytes: 0, fileCount: 0 },
354
- buckets: [],
355
- error: "scan_failed",
356
- });
357
- }
358
- }
359
-
360
338
  if (url.pathname === "/api/storage/cleanup/preview" && req.method === "POST") {
361
339
  let body: { percent?: unknown };
362
340
  try { body = await readManagementJsonBody(req); } catch (error) { rethrowManagementBodyTooLarge(error); return jsonResponse({ error: "invalid_json" }, 400); }