@bitkyc08/opencodex 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +1 -1
  2. package/gui/dist/assets/index-BHldBl6_.js +76 -0
  3. package/gui/dist/index.html +1 -1
  4. package/package.json +1 -1
  5. package/src/adapters/anthropic-output-schema.ts +137 -0
  6. package/src/adapters/anthropic.ts +15 -0
  7. package/src/adapters/google-antigravity-replay.ts +269 -5
  8. package/src/adapters/openai-chat.ts +25 -18
  9. package/src/adapters/openai-responses.ts +63 -17
  10. package/src/bridge.ts +11 -5
  11. package/src/claude/inbound.ts +24 -3
  12. package/src/cli/account-api.ts +2 -0
  13. package/src/cli/account-extended.ts +262 -1
  14. package/src/cli/account.ts +3 -1
  15. package/src/cli/dispatch.ts +532 -0
  16. package/src/cli/doctor.ts +26 -21
  17. package/src/cli/help.ts +9 -278
  18. package/src/cli/index.ts +45 -506
  19. package/src/cli/lab.ts +171 -65
  20. package/src/cli/registry.ts +384 -0
  21. package/src/cli/root.ts +86 -0
  22. package/src/cli/status.ts +9 -2
  23. package/src/cli/v2.ts +37 -2
  24. package/src/codex/account-label.ts +14 -1
  25. package/src/codex/account-store.ts +12 -1
  26. package/src/codex/admission.ts +2 -1
  27. package/src/codex/auth-api.ts +3 -2
  28. package/src/codex/catalog/metadata.ts +6 -4
  29. package/src/codex/catalog/parsing.ts +31 -7
  30. package/src/codex/catalog/provider-fetch.ts +2 -1
  31. package/src/codex/catalog/sync.ts +65 -11
  32. package/src/codex/features.ts +437 -31
  33. package/src/codex/history-job.ts +6 -11
  34. package/src/codex/history-provider.ts +10 -9
  35. package/src/codex/inject.ts +14 -1
  36. package/src/codex/native-profile-startup.ts +49 -3
  37. package/src/codex/native-residue.ts +14 -2
  38. package/src/codex/paths.ts +75 -1
  39. package/src/codex/reset-credit-recovery.ts +1044 -0
  40. package/src/codex/shim.ts +876 -16
  41. package/src/codex/sync.ts +27 -2
  42. package/src/config.ts +148 -10
  43. package/src/generated/compatibility-version.json +2572 -0
  44. package/src/images/loop.ts +1 -1
  45. package/src/lab/artifacts/sanitize.ts +3 -3
  46. package/src/lab/automation/budgets.ts +78 -0
  47. package/src/lab/automation/config-persistence.ts +256 -0
  48. package/src/lab/automation/constants.ts +39 -0
  49. package/src/lab/automation/cooldown.ts +103 -0
  50. package/src/lab/automation/dispatch.ts +211 -0
  51. package/src/lab/automation/index.ts +13 -0
  52. package/src/lab/automation/orchestrator.ts +480 -0
  53. package/src/lab/automation/persistence.ts +512 -0
  54. package/src/lab/automation/planner.ts +371 -0
  55. package/src/lab/automation/policy.ts +136 -0
  56. package/src/lab/automation/queue.ts +191 -0
  57. package/src/lab/automation/recovery.ts +24 -0
  58. package/src/lab/automation/route-context.ts +21 -0
  59. package/src/lab/automation/run-key.ts +44 -0
  60. package/src/lab/automation/runs-query.ts +34 -0
  61. package/src/lab/automation/types.ts +160 -0
  62. package/src/lab/conformance/types.ts +23 -0
  63. package/src/lab/events/types.ts +2 -1
  64. package/src/lab/events/validate.ts +2 -1
  65. package/src/lab/fabric/constants.ts +40 -0
  66. package/src/lab/fabric/executor.ts +492 -0
  67. package/src/lab/fabric/index.ts +80 -0
  68. package/src/lab/fabric/manifest.ts +222 -0
  69. package/src/lab/fabric/observe.ts +454 -0
  70. package/src/lab/fabric/patch.ts +79 -0
  71. package/src/lab/fabric/producer-child.ts +139 -0
  72. package/src/lab/fabric/producer-isolate.ts +276 -0
  73. package/src/lab/fabric/producer-protocol.ts +61 -0
  74. package/src/lab/fabric/scratch.ts +439 -0
  75. package/src/lab/fabric/subject.ts +106 -0
  76. package/src/lab/fabric/types.ts +134 -0
  77. package/src/lab/fabric/verifier.ts +98 -0
  78. package/src/lab/index.ts +2 -0
  79. package/src/lab/ledger/store.ts +173 -0
  80. package/src/lab/live/executor.ts +29 -4
  81. package/src/lab/live/transport.ts +3 -1
  82. package/src/lab/observe/from-conformance.ts +12 -57
  83. package/src/lab/observe/from-live.ts +9 -1
  84. package/src/lab/paths.ts +74 -9
  85. package/src/lab/projection/schema.ts +14 -1
  86. package/src/lab/projection/verdicts.ts +89 -53
  87. package/src/lab/projection/verification.ts +83 -3
  88. package/src/lab/query/catalog.ts +34 -5
  89. package/src/lab/query/freshness.ts +53 -0
  90. package/src/lab/query/index.ts +11 -0
  91. package/src/lab/query/latest-observation.ts +59 -0
  92. package/src/lab/query/passive-production.ts +159 -0
  93. package/src/lab/subject/installation-salt.ts +24 -4
  94. package/src/lab/subject/protocol-subject.ts +80 -0
  95. package/src/lab/subject/route-subject.ts +2 -1
  96. package/src/lib/fabric-task-execution-authority.ts +7 -0
  97. package/src/lib/fabric-task-host.ts +29 -0
  98. package/src/lib/lab-live-route-production.ts +130 -0
  99. package/src/lib/local-management-capability.ts +100 -0
  100. package/src/lib/server-resource-ownership.ts +71 -0
  101. package/src/lib/windows-elevation.ts +8 -1
  102. package/src/lib/windows-user-principal.ts +62 -2
  103. package/src/lib/winsw.ts +8 -2
  104. package/src/oauth/account-import/google-antigravity-adapter.ts +74 -0
  105. package/src/oauth/account-import/index.ts +15 -0
  106. package/src/oauth/account-import/parser.ts +83 -0
  107. package/src/oauth/account-import/registry.ts +18 -0
  108. package/src/oauth/account-import/service.ts +75 -0
  109. package/src/oauth/account-import/types.ts +91 -0
  110. package/src/oauth/anthropic.ts +12 -1
  111. package/src/oauth/callback-server.ts +8 -2
  112. package/src/oauth/chatgpt.ts +12 -1
  113. package/src/oauth/google-antigravity.ts +27 -0
  114. package/src/oauth/health.ts +12 -48
  115. package/src/oauth/index.ts +122 -0
  116. package/src/oauth/kimi.ts +16 -2
  117. package/src/oauth/nous.ts +749 -0
  118. package/src/oauth/store.ts +56 -3
  119. package/src/providers/codex-capacity.ts +1 -0
  120. package/src/providers/registry.ts +161 -16
  121. package/src/responses/custom-tool-compat.ts +182 -0
  122. package/src/responses/parser.ts +22 -0
  123. package/src/responses/reasoning-replay-cache.ts +168 -11
  124. package/src/responses/schema.ts +1 -1
  125. package/src/router.ts +5 -20
  126. package/src/routing/capability.ts +9 -3
  127. package/src/routing/compatibility/assemble.ts +159 -0
  128. package/src/routing/compatibility/behavior.ts +224 -0
  129. package/src/routing/compatibility/catalog.ts +99 -0
  130. package/src/routing/compatibility/endpoint.ts +52 -0
  131. package/src/routing/compatibility/policy.ts +181 -0
  132. package/src/routing/compatibility/reader.ts +110 -0
  133. package/src/routing/compatibility/subject.ts +191 -0
  134. package/src/routing/compatibility/types.ts +64 -0
  135. package/src/routing/compatibility/version.ts +104 -0
  136. package/src/routing/evaluator.ts +27 -32
  137. package/src/routing/profile.ts +118 -4
  138. package/src/routing/trace.ts +82 -28
  139. package/src/server/auth-cors.ts +7 -0
  140. package/src/server/background-lifecycle.ts +20 -7
  141. package/src/server/chat-completions.ts +2 -2
  142. package/src/server/claude-messages.ts +6 -3
  143. package/src/server/direct-local-http.ts +343 -0
  144. package/src/server/index.ts +80 -18
  145. package/src/server/lifecycle.ts +12 -4
  146. package/src/server/local-management-read-client.ts +90 -0
  147. package/src/server/management/agent-settings-routes.ts +27 -3
  148. package/src/server/management/lab-automation-routes.ts +206 -0
  149. package/src/server/management/lab-routes.ts +13 -0
  150. package/src/server/management/logs-usage-routes.ts +1 -0
  151. package/src/server/management/oauth-account-routes.ts +57 -1
  152. package/src/server/management/provider-routes.ts +16 -0
  153. package/src/server/management/routing-profile-routes.ts +12 -24
  154. package/src/server/management-api.ts +2 -0
  155. package/src/server/management-auth.ts +68 -3
  156. package/src/server/proxy-liveness.ts +3 -2
  157. package/src/server/request-decompress.ts +9 -3
  158. package/src/server/request-log.ts +53 -4
  159. package/src/server/responses/collaboration.ts +93 -17
  160. package/src/server/responses/compact.ts +3 -0
  161. package/src/server/responses/core.ts +202 -15
  162. package/src/server/responses/policy-fallback.ts +1 -0
  163. package/src/server/responses-custom-tool-repair.ts +280 -0
  164. package/src/server/responses-terminal-repair.ts +342 -0
  165. package/src/server/startup-action-control.ts +8 -1
  166. package/src/service.ts +278 -12
  167. package/src/types.ts +49 -3
  168. package/src/usage/expected-prices.ts +30 -0
  169. package/src/usage/log.ts +30 -4
  170. package/src/usage/summary.ts +153 -2
  171. package/src/web-search/loop.ts +1 -1
  172. package/gui/dist/assets/index-Cw1_mxO8.js +0 -70
  173. package/src/cli/internal-dispatch.ts +0 -20
@@ -16,7 +16,7 @@
16
16
  } catch (e) {}
17
17
  })();
18
18
  </script>
19
- <script type="module" crossorigin src="/assets/index-Cw1_mxO8.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-BHldBl6_.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-BNVYzdn0.css">
21
21
  </head>
22
22
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitkyc08/opencodex",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Universal provider proxy for OpenAI Codex & Claude Code — use any LLM with Codex CLI/App/SDK and Claude Code",
5
5
  "type": "module",
6
6
  "main": "./bin/package-main.mjs",
@@ -0,0 +1,137 @@
1
+ // Based on Anthropic SDK's transformJSONSchema, preserving root $defs required by root $ref:
2
+ // https://github.com/anthropics/anthropic-sdk-typescript/blob/main/src/lib/transform-json-schema.ts
3
+ const SUPPORTED_STRING_FORMATS = new Set([
4
+ "date-time",
5
+ "time",
6
+ "date",
7
+ "duration",
8
+ "email",
9
+ "hostname",
10
+ "uri",
11
+ "ipv4",
12
+ "ipv6",
13
+ "uuid",
14
+ ]);
15
+
16
+ function isRecord(value: unknown): value is Record<string, unknown> {
17
+ return value !== null && typeof value === "object" && !Array.isArray(value);
18
+ }
19
+
20
+ function take(schema: Record<string, unknown>, key: string): unknown {
21
+ const value = schema[key];
22
+ delete schema[key];
23
+ return value;
24
+ }
25
+
26
+ function normalizeSubschema(value: unknown): unknown {
27
+ return isRecord(value) ? normalizeSchema(value) : value;
28
+ }
29
+
30
+ function normalizeSchema(schema: Record<string, unknown>): Record<string, unknown> {
31
+ const normalized: Record<string, unknown> = {};
32
+
33
+ const defs = take(schema, "$defs");
34
+ if (isRecord(defs)) {
35
+ normalized.$defs = Object.fromEntries(
36
+ Object.entries(defs).map(([name, definition]) => [name, normalizeSubschema(definition)]),
37
+ );
38
+ }
39
+
40
+ const ref = take(schema, "$ref");
41
+ if (ref !== undefined) {
42
+ normalized.$ref = ref;
43
+ return normalized;
44
+ }
45
+
46
+ const type = take(schema, "type");
47
+ const anyOf = schema.anyOf;
48
+ const oneOf = schema.oneOf;
49
+ const allOf = schema.allOf;
50
+
51
+ if (Array.isArray(anyOf)) {
52
+ take(schema, "anyOf");
53
+ normalized.anyOf = anyOf.map(normalizeSubschema);
54
+ } else if (Array.isArray(oneOf)) {
55
+ take(schema, "oneOf");
56
+ normalized.anyOf = oneOf.map(normalizeSubschema);
57
+ } else if (Array.isArray(allOf)) {
58
+ take(schema, "allOf");
59
+ normalized.allOf = allOf.map(normalizeSubschema);
60
+ } else {
61
+ if (type === undefined) {
62
+ throw new Error("JSON schema must have a type defined if anyOf/oneOf/allOf are not used");
63
+ }
64
+ normalized.type = type;
65
+ }
66
+
67
+ const description = take(schema, "description");
68
+ if (description !== undefined) {
69
+ normalized.description = description;
70
+ }
71
+
72
+ const title = take(schema, "title");
73
+ if (title !== undefined) {
74
+ normalized.title = title;
75
+ }
76
+
77
+ if (type === "object") {
78
+ const properties = take(schema, "properties");
79
+ normalized.properties = isRecord(properties)
80
+ ? Object.fromEntries(
81
+ Object.entries(properties).map(([name, property]) => [name, normalizeSubschema(property)]),
82
+ )
83
+ : {};
84
+ take(schema, "additionalProperties");
85
+ normalized.additionalProperties = false;
86
+
87
+ const required = take(schema, "required");
88
+ if (required !== undefined) {
89
+ normalized.required = required;
90
+ }
91
+ } else if (type === "string") {
92
+ const format = take(schema, "format");
93
+ if (typeof format === "string" && SUPPORTED_STRING_FORMATS.has(format)) {
94
+ normalized.format = format;
95
+ } else if (format !== undefined) {
96
+ schema.format = format;
97
+ }
98
+ } else if (type === "array") {
99
+ const items = take(schema, "items");
100
+ if (items !== undefined) {
101
+ normalized.items = normalizeSubschema(items);
102
+ }
103
+
104
+ const minItems = take(schema, "minItems");
105
+ if (minItems === 0 || minItems === 1) {
106
+ normalized.minItems = minItems;
107
+ } else if (minItems !== undefined) {
108
+ schema.minItems = minItems;
109
+ }
110
+ }
111
+
112
+ const unsupported = Object.entries(schema);
113
+ if (unsupported.length > 0) {
114
+ const existingDescription =
115
+ typeof normalized.description === "string" ? `${normalized.description}\n\n` : "";
116
+ normalized.description = `${existingDescription}{${unsupported
117
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
118
+ .join(", ")}}`;
119
+ }
120
+
121
+ return normalized;
122
+ }
123
+
124
+ export function normalizeAnthropicOutputSchema(
125
+ schema: Record<string, unknown>,
126
+ ): Record<string, unknown> {
127
+ return normalizeSchema(structuredClone(schema));
128
+ }
129
+
130
+ export function isAnthropicOutputSchema(schema: Record<string, unknown>): boolean {
131
+ try {
132
+ normalizeAnthropicOutputSchema(schema);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
@@ -18,6 +18,7 @@ import { ANTHROPIC_OAUTH_BETA, CLAUDE_CODE_SYSTEM_INSTRUCTION, applyClaudeToolPr
18
18
  import { parseDataUrl } from "./image";
19
19
  import { enforceAnthropicImageLimits } from "./anthropic-image-guard";
20
20
  import { normalizeAnthropicImages } from "./anthropic-image-normalize";
21
+ import { normalizeAnthropicOutputSchema } from "./anthropic-output-schema";
21
22
  import { identifyRoutedModel } from "./identity";
22
23
  import { redactSecretString } from "../lib/redact";
23
24
  import { CLAUDE_CODE_HEADERS, claudeCodeSessionId } from "./client-fingerprint";
@@ -898,6 +899,20 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti
898
899
  delete body.top_p;
899
900
  }
900
901
 
902
+ const textFormat = parsed.options.textFormat;
903
+ if (textFormat?.type === "json_schema" && textFormat.schema) {
904
+ const outputConfig = body.output_config;
905
+ body.output_config = {
906
+ ...(outputConfig && typeof outputConfig === "object" && !Array.isArray(outputConfig)
907
+ ? outputConfig
908
+ : {}),
909
+ format: {
910
+ type: "json_schema",
911
+ schema: normalizeAnthropicOutputSchema(textFormat.schema),
912
+ },
913
+ };
914
+ }
915
+
901
916
  if (parsed.options.toolChoice && (tools || parsed.options.toolChoice === "none")) {
902
917
  const tc = parsed.options.toolChoice;
903
918
  if (tc === "auto") body.tool_choice = { type: "auto" };
@@ -1,4 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { atomicWriteFileAsync, getConfigDir, type AtomicWriteAsyncTestSeam } from "../config";
2
5
  import { enforceAppOwnedMemoryBudget } from "../lib/app-owned-memory";
3
6
 
4
7
  /**
@@ -26,6 +29,8 @@ interface ReplayEntry {
26
29
  bytes: number;
27
30
  expiresAtMs: number;
28
31
  oldestAtMs: number | null;
32
+ /** Most recent observe/apply activity; orders sessions under the snapshot cap. */
33
+ lastActiveAtMs: number;
29
34
  }
30
35
 
31
36
  const MIN_SIGNATURE_LEN = 16;
@@ -38,6 +43,16 @@ export const ANTIGRAVITY_REPLAY_MAX_TOTAL_BYTES = 64 * 1024 * 1024;
38
43
  const REPLAY_MAX_SIGNATURE_BYTES = 64 * 1024;
39
44
  /** Fixed 64-hex outer key length, counted once per session entry. */
40
45
  const REPLAY_SESSION_KEY_BYTES = 64;
46
+ const REPLAY_SNAPSHOT_FILE = "antigravity-replay.json";
47
+ const REPLAY_SNAPSHOT_VERSION = 1;
48
+ const REPLAY_SNAPSHOT_DEBOUNCE_MS = 2_000;
49
+ /** Upper bound on flush retries: mutations arriving faster than writes complete
50
+ * must not let shutdown hang; the last completed write is already on disk. */
51
+ const REPLAY_FLUSH_MAX_ATTEMPTS = 8;
52
+ /** Write bound for the durable snapshot (mirrors the responses-state cap). */
53
+ const REPLAY_SNAPSHOT_MAX_BYTES = 24 * 1024 * 1024;
54
+ /** Refuse-to-parse ceiling for an existing snapshot file. */
55
+ const REPLAY_SNAPSHOT_REFUSE_BYTES = 32 * 1024 * 1024;
41
56
 
42
57
  interface ReplayLimits {
43
58
  maxCallsPerSession: number;
@@ -57,6 +72,216 @@ let replayLimits = { ...DEFAULT_REPLAY_LIMITS };
57
72
  let replayBytes = 0;
58
73
  let replayOldestSessionKey: string | undefined;
59
74
  let replayOldestAt: number | null = null;
75
+ let replaySnapshotLoaded = false;
76
+ let replaySnapshotPersistTimer: ReturnType<typeof setTimeout> | null = null;
77
+ let replaySnapshotPersistGate: Promise<void> = Promise.resolve();
78
+ /** Mutation generation: bumped on every cache change that needs persisting. */
79
+ let replayMutationGeneration = 0;
80
+ /** Generation of the data the last successful snapshot write actually captured. */
81
+ let replayWrittenGeneration = 0;
82
+ let replaySnapshotWriteSeam: AtomicWriteAsyncTestSeam | undefined;
83
+ let replaySnapshotLoadDiscarded = false;
84
+ let replaySnapshotMaxBytes = REPLAY_SNAPSHOT_MAX_BYTES;
85
+
86
+ function replaySnapshotPath(): string {
87
+ return join(getConfigDir(), REPLAY_SNAPSHOT_FILE);
88
+ }
89
+
90
+ function loadReplaySnapshotEntry(key: string, value: unknown): void {
91
+ if (!/^[0-9a-f]{64}$/.test(key)) return;
92
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
93
+ const rec = value as { byCall?: unknown; expiresAtMs?: unknown; lastActiveAtMs?: unknown };
94
+ if (typeof rec.expiresAtMs !== "number" || !Number.isFinite(rec.expiresAtMs)) return;
95
+ // Expired sessions are dropped at load; a stale snapshot is self-healing.
96
+ if (rec.expiresAtMs <= Date.now()) {
97
+ replaySnapshotLoadDiscarded = true;
98
+ return;
99
+ }
100
+ if (!Array.isArray(rec.byCall)) return;
101
+ const byCall = new Map<string, ReplayCall>();
102
+ let bytes = REPLAY_SESSION_KEY_BYTES;
103
+ for (const pair of rec.byCall) {
104
+ if (!Array.isArray(pair) || pair.length !== 2 || typeof pair[0] !== "string") continue;
105
+ if (!/^[0-9a-f]{64}$/.test(pair[0])) continue;
106
+ const call = pair[1] as { signature?: unknown; touchedAtMs?: unknown } | null;
107
+ if (!call || typeof call !== "object" || Array.isArray(call)) continue;
108
+ if (typeof call.signature !== "string" || call.signature.length < MIN_SIGNATURE_LEN) continue;
109
+ if (typeof call.touchedAtMs !== "number" || !Number.isFinite(call.touchedAtMs)) continue;
110
+ // Never trust a serialized sizeBytes: a forged snapshot could claim a tiny
111
+ // size for a huge signature and bypass every byte cap. Recompute from the
112
+ // signature itself, exactly like the live write path.
113
+ const signatureBytes = utf8.encode(call.signature).byteLength;
114
+ if (signatureBytes > replayLimits.maxSignatureBytes) continue;
115
+ const callBytes = utf8.encode(pair[0]).byteLength + signatureBytes;
116
+ if (callBytes > replayLimits.maxBytesPerSession) continue;
117
+ // A duplicated call key would overstate entry.bytes (the map keeps only the
118
+ // last value) and could evict valid sessions; keep the first occurrence.
119
+ if (byCall.has(pair[0])) {
120
+ replaySnapshotLoadDiscarded = true;
121
+ continue;
122
+ }
123
+ byCall.set(pair[0], {
124
+ signature: call.signature,
125
+ sizeBytes: callBytes,
126
+ touchedAtMs: call.touchedAtMs,
127
+ });
128
+ bytes += callBytes;
129
+ }
130
+ if (byCall.size === 0) {
131
+ replaySnapshotLoadDiscarded = true;
132
+ return;
133
+ }
134
+ const lastActiveAtMs = typeof rec.lastActiveAtMs === "number" && Number.isFinite(rec.lastActiveAtMs)
135
+ ? rec.lastActiveAtMs
136
+ : rec.expiresAtMs;
137
+ const entry: ReplayEntry = { byCall, bytes, expiresAtMs: rec.expiresAtMs, oldestAtMs: null, lastActiveAtMs };
138
+ const loadedCallCount = entry.byCall.size;
139
+ // Account BEFORE trimming: evictInnerCalls decrements the global byte count
140
+ // through deleteReplayCall, so the entry must already be on the books.
141
+ replayCache.set(key, entry);
142
+ replayBytes += entry.bytes;
143
+ // Same per-session caps as live writes, in case limits changed across versions.
144
+ evictInnerCalls(entry);
145
+ if (entry.byCall.size < loadedCallCount) replaySnapshotLoadDiscarded = true;
146
+ if (entry.byCall.size === 0) {
147
+ deleteReplaySession(key);
148
+ return;
149
+ }
150
+ refreshReplaySessionCandidate(key, entry);
151
+ }
152
+
153
+ /**
154
+ * Lazy load of the durable snapshot on first cache access, so signatures observed
155
+ * before a proxy restart are available again once the session id re-derives to the
156
+ * same key (the session id is anchored on the first user message text). Load is
157
+ * best-effort: missing, corrupt, or oversized files start the cache empty.
158
+ */
159
+ function ensureReplaySnapshotLoaded(): void {
160
+ if (replaySnapshotLoaded) return;
161
+ replaySnapshotLoaded = true;
162
+ replaySnapshotLoadDiscarded = false;
163
+ try {
164
+ const path = replaySnapshotPath();
165
+ if (!existsSync(path)) return;
166
+ const stat = statSync(path);
167
+ // Bound the read BEFORE parse: the 24 MiB write cap constrains snapshots this
168
+ // process wrote, not a pre-existing oversized file.
169
+ if (!stat.isFile() || stat.size > REPLAY_SNAPSHOT_REFUSE_BYTES) return;
170
+ const raw = JSON.parse(readFileSync(path, "utf8")) as { version?: unknown; sessions?: unknown };
171
+ if (raw.version !== REPLAY_SNAPSHOT_VERSION || !Array.isArray(raw.sessions)) return;
172
+ for (const entry of raw.sessions) {
173
+ if (!Array.isArray(entry) || entry.length !== 2 || typeof entry[0] !== "string") continue;
174
+ loadReplaySnapshotEntry(entry[0], entry[1]);
175
+ }
176
+ } catch {
177
+ // Missing/corrupt snapshot: start empty.
178
+ }
179
+ const sessionsAfterLoad = replayCache.size;
180
+ // Load-time admission must respect the same global caps as live writes.
181
+ evictIfNeeded();
182
+ if (replayCache.size < sessionsAfterLoad) replaySnapshotLoadDiscarded = true;
183
+ // Persist the cleaned state once: expired/over-limit sessions must not stay
184
+ // on disk and be re-parsed (and re-dropped) after every restart.
185
+ if (replaySnapshotLoadDiscarded) markReplayDirty();
186
+ enforceAppOwnedMemoryBudget();
187
+ }
188
+
189
+ function markReplayDirty(): void {
190
+ replayMutationGeneration += 1;
191
+ if (replaySnapshotPersistTimer) return;
192
+ replaySnapshotPersistTimer = setTimeout(() => {
193
+ void persistReplaySnapshotNow().catch(() => {
194
+ // Redacted static message only: never echo the underlying error, which
195
+ // can carry paths or other environment details.
196
+ console.warn("[antigravity] replay snapshot persist failed; cached signatures will not survive a restart");
197
+ });
198
+ }, REPLAY_SNAPSHOT_DEBOUNCE_MS);
199
+ (replaySnapshotPersistTimer as { unref?: () => void }).unref?.();
200
+ }
201
+
202
+ async function persistReplaySnapshotNow(): Promise<void> {
203
+ if (replaySnapshotPersistTimer) {
204
+ clearTimeout(replaySnapshotPersistTimer);
205
+ replaySnapshotPersistTimer = null;
206
+ }
207
+ // Serialize writers so a flush and a debounced write cannot race on temps/ACL.
208
+ const previous = replaySnapshotPersistGate;
209
+ let release!: () => void;
210
+ replaySnapshotPersistGate = new Promise<void>(resolve => { release = resolve; });
211
+ await previous;
212
+ const writeGeneration = replayMutationGeneration;
213
+ try {
214
+ const sessions: Array<[string, unknown]> = [];
215
+ // Account for the bytes actually written, not just the entries: the document
216
+ // is `{"version":N,"sessions":[...]}`, so the framing and the comma between
217
+ // entries count against the cap too. Summing per-entry sizes alone let the
218
+ // written file exceed replaySnapshotMaxBytes by a margin that grew with every
219
+ // additional session.
220
+ const prefix = `{"version":${JSON.stringify(REPLAY_SNAPSHOT_VERSION)},"sessions":[`;
221
+ const suffix = "]}";
222
+ const serialized: string[] = [];
223
+ let total = Buffer.byteLength(prefix, "utf8") + Buffer.byteLength(suffix, "utf8");
224
+ // Most-recently-active first so the sessions that survive the snapshot
225
+ // byte cap are the ones actually in use (Map order is insertion order).
226
+ for (const [key, entry] of [...replayCache].sort((a, b) => b[1].lastActiveAtMs - a[1].lastActiveAtMs)) {
227
+ const byCall = [...entry.byCall].map(([callKey, call]) => [
228
+ callKey,
229
+ { signature: call.signature, touchedAtMs: call.touchedAtMs },
230
+ ]);
231
+ const persistEntry: [string, unknown] = [key, {
232
+ byCall,
233
+ expiresAtMs: entry.expiresAtMs,
234
+ lastActiveAtMs: entry.lastActiveAtMs,
235
+ }];
236
+ const encoded = JSON.stringify(persistEntry);
237
+ const size = Buffer.byteLength(encoded, "utf8") + (serialized.length > 0 ? 1 : 0);
238
+ if (total + size > replaySnapshotMaxBytes) break;
239
+ total += size;
240
+ serialized.push(encoded);
241
+ sessions.push(persistEntry);
242
+ }
243
+ serialized.reverse();
244
+ const document = `${prefix}${serialized.join(",")}${suffix}`;
245
+ // Defensive: the admitted entries are what we serialize, so this can only
246
+ // trip if the accounting above and the payload below ever drift apart.
247
+ if (Buffer.byteLength(document, "utf8") > replaySnapshotMaxBytes) {
248
+ throw new Error("Antigravity replay snapshot exceeded its configured byte cap.");
249
+ }
250
+ mkdirSync(dirname(replaySnapshotPath()), { recursive: true, mode: 0o700 });
251
+ try { chmodSync(dirname(replaySnapshotPath()), 0o700); } catch { /* best-effort (e.g. Windows) */ }
252
+ await atomicWriteFileAsync(
253
+ replaySnapshotPath(),
254
+ document,
255
+ undefined,
256
+ replaySnapshotWriteSeam,
257
+ );
258
+ replayWrittenGeneration = writeGeneration;
259
+ } finally {
260
+ release();
261
+ }
262
+ }
263
+
264
+ /** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
265
+ export async function flushAntigravityReplay(): Promise<void> {
266
+ // Persist until the durable generation catches up with the latest mutation:
267
+ // a change that lands while a writer is in flight must not be lost when
268
+ // shutdown exits right after the first write completes.
269
+ for (let attempt = 0; attempt < REPLAY_FLUSH_MAX_ATTEMPTS; attempt += 1) {
270
+ if (replaySnapshotPersistTimer || replayMutationGeneration > replayWrittenGeneration) {
271
+ await persistReplaySnapshotNow();
272
+ }
273
+ // No pending timer: still await any in-flight write so shutdown does not race it.
274
+ await replaySnapshotPersistGate;
275
+ if (replayMutationGeneration === replayWrittenGeneration && replaySnapshotPersistTimer === null) return;
276
+ }
277
+ // Budget exhausted without convergence. Resolving here would tell
278
+ // drainAndShutdown() the snapshot is durable while the latest thought
279
+ // signature may never have reached disk, so shutdown diagnostics would claim
280
+ // a durability we cannot demonstrate. Reject instead, with fixed text: the
281
+ // shutdown path logs this message, so it must not carry session, model, or
282
+ // signature detail.
283
+ throw new Error("Antigravity replay snapshot flush did not converge.");
284
+ }
60
285
 
61
286
  /**
62
287
  * Fixed-size identity for a (model, sessionId) pair: SHA-256 over
@@ -372,6 +597,7 @@ export function antigravityUsesReplayCache(model: string): boolean {
372
597
  */
373
598
  export function observeAntigravityReplay(model: string, sessionId: string, parts: unknown[]): void {
374
599
  if (!antigravityUsesReplayCache(model) || !Array.isArray(parts) || parts.length === 0) return;
600
+ ensureReplaySnapshotLoaded();
375
601
  const now = Date.now();
376
602
  deleteExpiredReplaySessionsThrottled(now);
377
603
  const key = replayKey(model, sessionId);
@@ -381,6 +607,7 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
381
607
  bytes: REPLAY_SESSION_KEY_BYTES,
382
608
  expiresAtMs: 0,
383
609
  oldestAtMs: null,
610
+ lastActiveAtMs: 0,
384
611
  };
385
612
  let inserted = false;
386
613
  let pendingThoughtSig: string | undefined;
@@ -418,15 +645,21 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
418
645
  // The fixed session overhead can exceed the per-session cap on its own
419
646
  // (test-sized limits): an entry holding zero calls is unusable — drop it
420
647
  // instead of retaining an unevictable shell.
421
- if (existing) deleteReplaySession(key);
422
- else replayBytes -= REPLAY_SESSION_KEY_BYTES;
648
+ if (existing) {
649
+ deleteReplaySession(key);
650
+ markReplayDirty();
651
+ } else {
652
+ replayBytes -= REPLAY_SESSION_KEY_BYTES;
653
+ }
423
654
  return;
424
655
  }
425
656
  entry.expiresAtMs = now + REPLAY_TTL_MS;
657
+ entry.lastActiveAtMs = now;
426
658
  replayCache.set(key, entry);
427
659
  refreshReplaySessionCandidate(key, entry);
428
660
  evictIfNeeded();
429
661
  enforceAppOwnedMemoryBudget();
662
+ markReplayDirty();
430
663
  }
431
664
 
432
665
  /**
@@ -436,6 +669,7 @@ export function observeAntigravityReplay(model: string, sessionId: string, parts
436
669
  */
437
670
  export function applyAntigravityReplay(model: string, sessionId: string, contents: unknown[]): unknown[] {
438
671
  if (!antigravityUsesReplayCache(model) || !Array.isArray(contents)) return contents;
672
+ ensureReplaySnapshotLoaded();
439
673
  const now = Date.now();
440
674
  deleteExpiredReplaySessionsThrottled(now);
441
675
  const entry = replayCache.get(replayKey(model, sessionId));
@@ -461,13 +695,18 @@ export function applyAntigravityReplay(model: string, sessionId: string, content
461
695
  }
462
696
  }
463
697
  }
464
- if (touched) refreshReplaySessionCandidate(replayKey(model, sessionId), entry);
698
+ if (touched) {
699
+ entry.lastActiveAtMs = now;
700
+ refreshReplaySessionCandidate(replayKey(model, sessionId), entry);
701
+ markReplayDirty();
702
+ }
465
703
  return contents;
466
704
  }
467
705
 
468
706
  /** Drop the cache entry when upstream rejects a signature (clear-on-invalid). */
469
707
  export function clearAntigravityReplay(model: string, sessionId: string): void {
470
- deleteReplaySession(replayKey(model, sessionId));
708
+ ensureReplaySnapshotLoaded();
709
+ if (deleteReplaySession(replayKey(model, sessionId)) > 0) markReplayDirty();
471
710
  }
472
711
 
473
712
  export function antigravityReplayMetrics(): {
@@ -476,6 +715,7 @@ export function antigravityReplayMetrics(): {
476
715
  totalBytes: number;
477
716
  largestSessionBytes: number;
478
717
  } {
718
+ ensureReplaySnapshotLoaded();
479
719
  let calls = 0;
480
720
  let largestSessionBytes = 0;
481
721
  for (const entry of replayCache.values()) {
@@ -492,6 +732,7 @@ export function antigravityReplayRetainedStoreSnapshot(): {
492
732
  pinnedBytes: number;
493
733
  oldestAt: number | null;
494
734
  } {
735
+ ensureReplaySnapshotLoaded();
495
736
  return {
496
737
  count: replayCache.size,
497
738
  bytes: replayBytes,
@@ -502,7 +743,10 @@ export function antigravityReplayRetainedStoreSnapshot(): {
502
743
  }
503
744
 
504
745
  export function evictOldestAntigravityReplayForBudget(): number {
505
- return replayOldestSessionKey === undefined ? 0 : deleteReplaySession(replayOldestSessionKey);
746
+ ensureReplaySnapshotLoaded();
747
+ const removed = replayOldestSessionKey === undefined ? 0 : deleteReplaySession(replayOldestSessionKey);
748
+ if (removed > 0) markReplayDirty();
749
+ return removed;
506
750
  }
507
751
 
508
752
  export function setAntigravityReplayLimitsForTests(limits?: Partial<ReplayLimits>): void {
@@ -510,11 +754,31 @@ export function setAntigravityReplayLimitsForTests(limits?: Partial<ReplayLimits
510
754
  replayLimits = limits ? { ...DEFAULT_REPLAY_LIMITS, ...limits } : { ...DEFAULT_REPLAY_LIMITS };
511
755
  }
512
756
 
757
+ /** Test-only write seam (mirrors AtomicWriteAsyncTestSeam usage elsewhere). */
758
+ export function setAntigravityReplayWriteSeamForTests(seam: AtomicWriteAsyncTestSeam | undefined): void {
759
+ replaySnapshotWriteSeam = seam;
760
+ }
761
+
762
+ /** Test-only snapshot byte cap (mirrors the limits seam). */
763
+ export function setAntigravityReplaySnapshotMaxBytesForTests(maxBytes: number): void {
764
+ replaySnapshotMaxBytes = maxBytes;
765
+ }
766
+
513
767
  /** Test seam. */
514
768
  export function __resetAntigravityReplayCache(): void {
769
+ if (replaySnapshotPersistTimer) {
770
+ clearTimeout(replaySnapshotPersistTimer);
771
+ replaySnapshotPersistTimer = null;
772
+ }
773
+ replaySnapshotLoaded = false;
515
774
  lastLazySweepAt = Number.NEGATIVE_INFINITY;
516
775
  replayCache.clear();
517
776
  replayBytes = 0;
518
777
  replayOldestSessionKey = undefined;
519
778
  replayOldestAt = null;
779
+ replayMutationGeneration = 0;
780
+ replayWrittenGeneration = 0;
781
+ replaySnapshotWriteSeam = undefined;
782
+ replaySnapshotLoadDiscarded = false;
783
+ replaySnapshotMaxBytes = REPLAY_SNAPSHOT_MAX_BYTES;
520
784
  }
@@ -226,7 +226,7 @@ function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[]
226
226
  function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
227
227
  const out: unknown[] = [];
228
228
  const { context, options } = parsed;
229
- const replayCacheScope = parsed._clientThreadId ?? "global";
229
+ const replayCacheScope = parsed._reasoningReplayScope;
230
230
 
231
231
  interface PendingToolCall { id: string; name: string }
232
232
  let pendingToolCalls: PendingToolCall[] = [];
@@ -813,19 +813,25 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
813
813
  if (provider.promptCacheKey && parsed.options.promptCacheKey !== undefined) {
814
814
  body.prompt_cache_key = parsed.options.promptCacheKey;
815
815
  }
816
- const textFormat = parsed.options.textFormat;
817
- if (textFormat?.type === "json_object") {
818
- body.response_format = { type: "json_object" };
819
- } else if (textFormat?.type === "json_schema") {
820
- body.response_format = {
821
- type: "json_schema",
822
- json_schema: {
823
- name: textFormat.name ?? "response",
824
- ...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
825
- ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
826
- ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
827
- },
828
- };
816
+ // Structured-output support varies by the physical upstream model even when one
817
+ // gateway exposes a uniform OpenAI-compatible endpoint. Keep the #1137 translation
818
+ // as the default, but let an exact model opt out instead of forcing a provider-wide
819
+ // rollback that would silently return prose for siblings that support JSON Schema.
820
+ if (!provider.noStructuredOutputModels?.includes(parsed.modelId)) {
821
+ const textFormat = parsed.options.textFormat;
822
+ if (textFormat?.type === "json_object") {
823
+ body.response_format = { type: "json_object" };
824
+ } else if (textFormat?.type === "json_schema") {
825
+ body.response_format = {
826
+ type: "json_schema",
827
+ json_schema: {
828
+ name: textFormat.name ?? "response",
829
+ ...(textFormat.description !== undefined ? { description: textFormat.description } : {}),
830
+ ...(textFormat.schema !== undefined ? { schema: textFormat.schema } : {}),
831
+ ...(textFormat.strict !== undefined ? { strict: textFormat.strict } : {}),
832
+ },
833
+ };
834
+ }
829
835
  }
830
836
 
831
837
  if (tools) {
@@ -969,10 +975,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
969
975
  }
970
976
 
971
977
  const rawToolCalls = delta.tool_calls;
972
- if (rawToolCalls !== undefined) {
973
- // A claimed tool-call payload is not benign padding. Dropping it can leave the
978
+ if (rawToolCalls !== undefined && rawToolCalls !== null) {
979
+ // A non-null claimed tool-call payload is not benign padding. Dropping it can leave the
974
980
  // matching result permanently orphaned, so malformed nested shapes fail closed
975
- // through the adapter error channel instead of escaping as TypeError (#1325).
981
+ // through the adapter error channel instead of escaping as TypeError (#1325). Null is
982
+ // tolerated as absent because OpenAI-compatible providers may emit it as stream padding.
976
983
  if (!Array.isArray(rawToolCalls)) {
977
984
  return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
978
985
  }
@@ -1140,7 +1147,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
1140
1147
  if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText });
1141
1148
  if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content });
1142
1149
  const rawToolCalls = msg.tool_calls;
1143
- if (rawToolCalls !== undefined) {
1150
+ if (rawToolCalls !== undefined && rawToolCalls !== null) {
1144
1151
  if (!Array.isArray(rawToolCalls)) return [invalidToolCallsEvent(usage)];
1145
1152
  for (const rawToolCall of rawToolCalls) {
1146
1153
  if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) {