@coseung2/opencodex 2.8.0-cs.15 → 2.8.0-cs.17

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 (61) hide show
  1. package/gui/dist/assets/{index-BhXIu7c0.js → index-Ch-99jy3.js} +2 -2
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/packages/ocx-notch/README.md +3 -1
  5. package/src/adapters/base.ts +12 -0
  6. package/src/adapters/identity.ts +1 -1
  7. package/src/adapters/kiro-calibration.ts +83 -0
  8. package/src/adapters/kiro-constants.ts +11 -2
  9. package/src/adapters/kiro-errors.ts +11 -0
  10. package/src/adapters/kiro-events.ts +19 -1
  11. package/src/adapters/kiro-thinking.ts +18 -2
  12. package/src/adapters/kiro-tools.ts +12 -3
  13. package/src/adapters/kiro.ts +300 -78
  14. package/src/adapters/openai-chat.ts +1 -42
  15. package/src/adapters/openai-responses.ts +126 -9
  16. package/src/adapters/xai-schema-analysis.ts +78 -0
  17. package/src/adapters/xai-tool-schema.ts +274 -0
  18. package/src/adapters/xai-web-search.ts +138 -0
  19. package/src/bridge.ts +61 -6
  20. package/src/cli/observe.ts +18 -3
  21. package/src/codex/app-server-processes.ts +3 -5
  22. package/src/codex/catalog/effort.ts +4 -2
  23. package/src/codex/catalog/metadata.ts +42 -9
  24. package/src/codex/catalog/parsing.ts +17 -2
  25. package/src/codex/catalog/provider-fetch.ts +9 -3
  26. package/src/codex/catalog/sync.ts +11 -5
  27. package/src/codex/data/upstream-models.json +169 -0
  28. package/src/grok/inject.ts +1 -1
  29. package/src/lib/errors.ts +18 -0
  30. package/src/lib/token-estimate.ts +42 -38
  31. package/src/lib/translator-budget.ts +34 -0
  32. package/src/oauth/index.ts +10 -4
  33. package/src/oauth/kiro.ts +71 -6
  34. package/src/oauth/store.ts +3 -1
  35. package/src/oauth/types.ts +4 -0
  36. package/src/providers/derive.ts +7 -5
  37. package/src/providers/opencode-go-transport.ts +59 -0
  38. package/src/providers/quota.ts +68 -60
  39. package/src/providers/registry.ts +41 -10
  40. package/src/providers/xai-transport.ts +10 -0
  41. package/src/responses/compaction.ts +8 -1
  42. package/src/responses/namespace-aliases.ts +56 -0
  43. package/src/responses/parser.ts +12 -0
  44. package/src/responses/reasoning-envelope.ts +9 -1
  45. package/src/responses/snapshot-policy.ts +108 -0
  46. package/src/responses/state.ts +23 -10
  47. package/src/responses/turn-termination.ts +108 -0
  48. package/src/responses/xai-custom-tool-compat.ts +237 -0
  49. package/src/server/grok-responses-snapshot-repair.ts +338 -0
  50. package/src/server/index.ts +2 -1
  51. package/src/server/relay-eager.ts +1 -0
  52. package/src/server/request-log-conversation.ts +8 -0
  53. package/src/server/request-log.ts +5 -4
  54. package/src/server/responses/core.ts +233 -16
  55. package/src/server/responses-image-gen-repair.ts +2 -2
  56. package/src/server/sse-payload-rewrite.ts +20 -3
  57. package/src/types.ts +10 -1
  58. package/src/usage/cost.ts +0 -0
  59. package/src/usage/expected-prices.ts +7 -0
  60. package/src/usage/log.ts +1 -2
  61. package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
@@ -0,0 +1,108 @@
1
+ import { lstatSync, realpathSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import {
4
+ forgetHardenedSecretPath,
5
+ hardenSecretPathAsync,
6
+ windowsSecretAclApplies,
7
+ } from "../lib/windows-secret-acl";
8
+
9
+ const BASE_DEBOUNCE_MS = 2_000;
10
+ const SCALE_FROM_BYTES = 1024 * 1024;
11
+ const MAX_DEBOUNCE_MS = 30_000;
12
+
13
+ /** Large snapshots trade a bounded hard-kill recovery window for fewer atomic replacements. */
14
+ export function responseSnapshotDebounceMs(bytes: number): number {
15
+ if (!Number.isFinite(bytes) || bytes <= SCALE_FROM_BYTES) return BASE_DEBOUNCE_MS;
16
+ return Math.min(MAX_DEBOUNCE_MS, Math.round(BASE_DEBOUNCE_MS * bytes / SCALE_FROM_BYTES));
17
+ }
18
+
19
+ export interface ResponseSnapshotWriteMetrics {
20
+ writes: number;
21
+ unchangedSkips: number;
22
+ bytesWritten: number;
23
+ lastSnapshotBytes: number;
24
+ debounceMs: number;
25
+ }
26
+
27
+ type SnapshotWrite = (path: string, payload: string) => Promise<void>;
28
+
29
+ /**
30
+ * Memoize only a digest, size and resolved target, never another copy of the snapshot.
31
+ * The state store owns the single-flight writer gate; this class does not queue writes.
32
+ */
33
+ export class ResponseSnapshotWriter {
34
+ private digest: string | null = null;
35
+ private target: string | null = null;
36
+ private bytes = 0;
37
+ private writes = 0;
38
+ private unchangedSkips = 0;
39
+ private bytesWritten = 0;
40
+
41
+ constructor(private readonly write: SnapshotWrite) {}
42
+
43
+ metrics(): ResponseSnapshotWriteMetrics {
44
+ return {
45
+ writes: this.writes,
46
+ unchangedSkips: this.unchangedSkips,
47
+ bytesWritten: this.bytesWritten,
48
+ lastSnapshotBytes: this.bytes,
49
+ debounceMs: responseSnapshotDebounceMs(this.bytes),
50
+ };
51
+ }
52
+
53
+ reset(): void {
54
+ this.digest = null;
55
+ this.target = null;
56
+ this.bytes = 0;
57
+ this.writes = 0;
58
+ this.unchangedSkips = 0;
59
+ this.bytesWritten = 0;
60
+ }
61
+
62
+ private async diskMatches(path: string, payload: string, bytes: number): Promise<boolean> {
63
+ try {
64
+ const before = lstatSync(path);
65
+ // Atomic replacement would replace a symlink or break a hardlink. An optimization must
66
+ // not silently preserve either, even if it currently resolves to identical bytes.
67
+ if (!before.isFile() || before.nlink !== 1 || before.size !== bytes) return false;
68
+ if (realpathSync(path) !== this.target) return false;
69
+ if (!windowsSecretAclApplies()) {
70
+ if ((before.mode & 0o777) !== 0o600) return false;
71
+ if ((statSync(dirname(path)).mode & 0o777) !== 0o700) return false;
72
+ } else {
73
+ // An ordinary atomic write hardens a new temp. A skipped write must still use the
74
+ // required publication policy, not a potentially stale pathname-only success memo.
75
+ forgetHardenedSecretPath(path);
76
+ try { await hardenSecretPathAsync(path, { required: true }); }
77
+ finally { forgetHardenedSecretPath(path); }
78
+ }
79
+ // The digest records our last write, not the current disk contents. A same-size edit,
80
+ // a second process or a deleted snapshot must never turn into a false cache hit.
81
+ if (await Bun.file(path).text() !== payload) return false;
82
+ const after = lstatSync(path);
83
+ return after.isFile() && after.nlink === 1 && after.dev === before.dev
84
+ && after.ino === before.ino && after.size === before.size
85
+ && after.mtimeMs === before.mtimeMs && realpathSync(path) === this.target;
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ async persist(path: string, payload: string): Promise<void> {
92
+ const bytes = Buffer.byteLength(payload, "utf8");
93
+ const digest = Bun.hash(payload).toString(36);
94
+ if (this.digest === digest && this.bytes === bytes && await this.diskMatches(path, payload, bytes)) {
95
+ this.unchangedSkips += 1;
96
+ return;
97
+ }
98
+ await this.write(path, payload);
99
+ // Publish the memo only after a successful atomic write. A failed write keeps the previous
100
+ // fingerprint, which is harmless because every prospective skip rechecks the disk.
101
+ this.digest = digest;
102
+ this.bytes = bytes;
103
+ this.writes += 1;
104
+ this.bytesWritten += bytes;
105
+ try { this.target = realpathSync(path); }
106
+ catch { this.target = null; }
107
+ }
108
+ }
@@ -4,6 +4,7 @@ import { isDeepStrictEqual } from "node:util";
4
4
  import { atomicWriteFileAsync, getConfigDir } from "../config";
5
5
  import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/app-owned-memory";
6
6
  import type { OcxProviderContinuationState } from "../types";
7
+ import { ResponseSnapshotWriter } from "./snapshot-policy";
7
8
  import {
8
9
  deleteResponseSpill,
9
10
  noteStubSwapForTest,
@@ -17,7 +18,6 @@ import {
17
18
 
18
19
  const MAX_STORED_RESPONSES = 1_000;
19
20
  const RESPONSE_TTL_MS = 60 * 60 * 1_000;
20
- const SNAPSHOT_DEBOUNCE_MS = 2_000;
21
21
  /** In-memory high-water byte cap across all entries. Forced store:false retention (kiro/cursor
22
22
  * continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
23
23
  * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
@@ -69,6 +69,17 @@ let oldestResidentId: string | undefined;
69
69
  let oldestResidentAt: number | null = null;
70
70
  let byteCapOverride: number | null = null;
71
71
  let stateRevision = 0;
72
+ const snapshotWriter = new ResponseSnapshotWriter(async (path, payload) => {
73
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
74
+ try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
75
+ await atomicWriteFileAsync(path, payload);
76
+ });
77
+
78
+ /** Scalar-only observation; does not load, prune or retain snapshot contents. */
79
+ export function responseSnapshotMetricsForTests() {
80
+ return snapshotWriter.metrics();
81
+ }
82
+
72
83
  const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
73
84
  const admissionCounters = { directSpills: 0, oversizedDrops: 0, snapshotOversizedRefusals: 0 };
74
85
 
@@ -573,14 +584,14 @@ function ensureLoaded(): void {
573
584
 
574
585
  type SnapshotWriteOutcome = "stable" | "unstable" | "failed";
575
586
 
576
- async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome> {
587
+ async function writeBoundedSnapshot(path: string, attemptLimit: number): Promise<SnapshotWriteOutcome> {
577
588
  // Serialize writers so concurrent flush + debounce cannot race on temps / ACL (#612).
578
589
  const previous = persistGate;
579
590
  let release!: () => void;
580
591
  persistGate = new Promise<void>(resolve => { release = resolve; });
581
592
  await previous;
582
593
  try {
583
- for (let attempt = 0; attempt < MAX_SNAPSHOT_REWRITE_ATTEMPTS; attempt += 1) {
594
+ for (let attempt = 0; attempt < attemptLimit; attempt += 1) {
584
595
  const revision = stateRevision;
585
596
  const entries: Array<[string, unknown]> = [];
586
597
  let total = 0;
@@ -602,9 +613,7 @@ async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome>
602
613
  entries.push(persistEntry);
603
614
  }
604
615
  entries.reverse();
605
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
606
- try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
607
- await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
616
+ await snapshotWriter.persist(path, JSON.stringify({ version: 2, states: entries }));
608
617
  persistAttemptHookForTests?.();
609
618
  if (revision === stateRevision) return "stable";
610
619
  }
@@ -627,7 +636,7 @@ function schedulePersistAt(path: string, replace = false): void {
627
636
  if (persistTimer && !replace) return;
628
637
  if (persistTimer) clearTimeout(persistTimer);
629
638
  pendingPersistPath = path;
630
- persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
639
+ persistTimer = setTimeout(() => { void persistNow(path); }, snapshotWriter.metrics().debounceMs);
631
640
  (persistTimer as { unref?: () => void }).unref?.();
632
641
  }
633
642
 
@@ -637,12 +646,15 @@ async function persistNow(path: string, awaitFollowUp = false): Promise<void> {
637
646
  persistTimer = null;
638
647
  }
639
648
  pendingPersistPath = null;
640
- let outcome = await writeBoundedSnapshot(path);
649
+ // Background traffic gets one write per debounce, not four immediate full-file rewrites.
650
+ // Explicit shutdown flushes retain the existing bounded stabilization contract.
651
+ const attemptLimit = awaitFollowUp ? MAX_SNAPSHOT_REWRITE_ATTEMPTS : 1;
652
+ let outcome = await writeBoundedSnapshot(path, attemptLimit);
641
653
  if (outcome === "unstable" && awaitFollowUp) {
642
654
  if (persistTimer) clearTimeout(persistTimer);
643
655
  persistTimer = null;
644
656
  pendingPersistPath = null;
645
- outcome = await writeBoundedSnapshot(path);
657
+ outcome = await writeBoundedSnapshot(path, attemptLimit);
646
658
  }
647
659
  if (outcome === "stable") drainPendingSpillUnlinks();
648
660
  else if (outcome === "unstable" && !awaitFollowUp) schedulePersistAt(path, true);
@@ -957,7 +969,7 @@ export function rememberResponseState(
957
969
  schedulePersist();
958
970
  }
959
971
 
960
- /** Test-only persistence churn hook; invoked after each atomic snapshot rewrite. */
972
+ /** Test-only persistence churn hook; invoked after each write/unchanged-validation attempt. */
961
973
  export function setResponseStatePersistAttemptHookForTests(hook: (() => void) | null): void {
962
974
  persistAttemptHookForTests = hook;
963
975
  }
@@ -986,6 +998,7 @@ export function clearResponseStateMemoryForTests(): void {
986
998
  oldestResidentId = undefined;
987
999
  oldestResidentAt = null;
988
1000
  stateRevision = 0;
1001
+ snapshotWriter.reset();
989
1002
  pendingSpillUnlinks.length = 0;
990
1003
  spillCounters.writes = 0;
991
1004
  spillCounters.writeFailures = 0;
@@ -0,0 +1,108 @@
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 index = output.length - 1; index >= 0; index -= 1) {
44
+ const item = output[index];
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 only the already-normalized per-conversation digest; raw client ids never enter this map. */
61
+ export function bindTurnTerminationScope(parsed: OcxParsedRequest, scope: string | undefined): void {
62
+ if (!scope || !/^[0-9a-f]{32}$/.test(scope)) return;
63
+ scopesByRequest.set(parsed, scope);
64
+ }
65
+
66
+ /** Remember only a final-answer message the proxy actually emitted for this exact conversation. */
67
+ export function rememberDeliveredFinalAnswer(parsed: OcxParsedRequest, response: unknown): void {
68
+ const scope = scopesByRequest.get(parsed);
69
+ if (!scope) return;
70
+ const text = deliveredFinalAnswerText(response);
71
+ if (!text) return;
72
+ const at = Date.now();
73
+ pruneDeliveredFinalAnswers(at);
74
+ deliveredFinalAnswers.delete(scope);
75
+ deliveredFinalAnswers.set(scope, { fingerprint: textFingerprint(text), createdAt: at });
76
+ pruneDeliveredFinalAnswers(at);
77
+ }
78
+
79
+ /**
80
+ * Match only when the remembered assistant answer is still the trailing content-bearing message.
81
+ * Any later user/tool-result message is new work and must reach Kiro.
82
+ */
83
+ export function hasRecordedTrailingDeliveredFinalAnswer(
84
+ parsed: OcxParsedRequest,
85
+ messages: readonly OcxMessage[],
86
+ ): boolean {
87
+ const scope = scopesByRequest.get(parsed);
88
+ if (!scope) return false;
89
+ pruneDeliveredFinalAnswers();
90
+ const record = deliveredFinalAnswers.get(scope);
91
+ if (!record) return false;
92
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
93
+ const message = messages[index];
94
+ if (message.role !== "assistant") return false;
95
+ const text = assistantText(message as OcxAssistantMessage);
96
+ if (text === undefined) {
97
+ if ((message as OcxAssistantMessage).content.some(part => part.type === "toolCall")) return false;
98
+ continue;
99
+ }
100
+ return textFingerprint(text) === record.fingerprint;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ /** Test-only reset for deterministic cross-test isolation. */
106
+ export function clearDeliveredFinalAnswersForTests(): void {
107
+ deliveredFinalAnswers.clear();
108
+ }
@@ -0,0 +1,237 @@
1
+ import type { SsePayloadRewrite } from "../server/sse-payload-rewrite";
2
+
3
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
4
+ return !!value && typeof value === "object" && !Array.isArray(value);
5
+ }
6
+
7
+ function collectBareCustomToolNames(value: unknown, out: Set<string>): void {
8
+ if (Array.isArray(value)) {
9
+ for (const entry of value) collectBareCustomToolNames(entry, out);
10
+ return;
11
+ }
12
+ if (!isPlainObject(value)) return;
13
+ if (value.type === "custom" && typeof value.name === "string" && typeof value.namespace !== "string") {
14
+ out.add(value.name);
15
+ }
16
+ for (const entry of Object.values(value)) collectBareCustomToolNames(entry, out);
17
+ }
18
+
19
+ function collectConvertedCallIds(value: unknown, names: ReadonlySet<string>, out: Set<string>): void {
20
+ if (Array.isArray(value)) {
21
+ for (const entry of value) collectConvertedCallIds(entry, names, out);
22
+ return;
23
+ }
24
+ if (!isPlainObject(value)) return;
25
+ if (value.type === "custom_tool_call" && typeof value.name === "string" && names.has(value.name) && typeof value.call_id === "string") {
26
+ out.add(value.call_id);
27
+ }
28
+ for (const entry of Object.values(value)) collectConvertedCallIds(entry, names, out);
29
+ }
30
+
31
+ function rewriteToolChoiceForUpstream(value: unknown, names: ReadonlySet<string>): unknown {
32
+ if (!isPlainObject(value)) return value;
33
+ if ((value.type === "custom" || value.type === "function") && typeof value.name === "string" && names.has(value.name)) {
34
+ return value.type === "function" ? value : { ...value, type: "function" };
35
+ }
36
+ if (value.type === "allowed_tools" && Array.isArray(value.tools)) {
37
+ let changed = false;
38
+ const tools = value.tools.map(tool => {
39
+ if (!isPlainObject(tool) || tool.type !== "custom" || typeof tool.name !== "string" || !names.has(tool.name)) return tool;
40
+ changed = true;
41
+ return { ...tool, type: "function" };
42
+ });
43
+ return changed ? { ...value, tools } : value;
44
+ }
45
+ return value;
46
+ }
47
+
48
+ function rewriteForUpstream(value: unknown, names: ReadonlySet<string>, callIds: ReadonlySet<string>): unknown {
49
+ if (Array.isArray(value)) return value.map(entry => rewriteForUpstream(entry, names, callIds));
50
+ if (!isPlainObject(value)) return value;
51
+
52
+ if (value.type === "custom" && typeof value.name === "string" && names.has(value.name)) {
53
+ const { format: _format, ...rest } = value;
54
+ return {
55
+ ...rest,
56
+ type: "function",
57
+ parameters: {
58
+ type: "object",
59
+ properties: {
60
+ input: { type: "string", description: "Raw input for this client-executed custom tool." },
61
+ },
62
+ required: ["input"],
63
+ additionalProperties: false,
64
+ },
65
+ };
66
+ }
67
+
68
+ if (value.type === "custom_tool_call" && typeof value.name === "string" && names.has(value.name)) {
69
+ const { input, id: _id, ...rest } = value;
70
+ return {
71
+ ...rest,
72
+ type: "function_call",
73
+ arguments: JSON.stringify({ input: typeof input === "string" ? input : "" }),
74
+ };
75
+ }
76
+
77
+ if (value.type === "custom_tool_call_output" && typeof value.call_id === "string" && callIds.has(value.call_id)) {
78
+ return { ...value, type: "function_call_output" };
79
+ }
80
+
81
+ let changed = false;
82
+ const next: Record<string, unknown> = {};
83
+ for (const [key, entry] of Object.entries(value)) {
84
+ const rewritten = key === "tool_choice"
85
+ ? rewriteToolChoiceForUpstream(entry, names)
86
+ : rewriteForUpstream(entry, names, callIds);
87
+ next[key] = rewritten;
88
+ changed ||= rewritten !== entry;
89
+ }
90
+ return changed ? next : value;
91
+ }
92
+
93
+ /** Names of bare Responses custom tools that xAI must receive as ordinary functions. */
94
+ export function xaiResponsesCustomToolNames(body: unknown): Set<string> {
95
+ const names = new Set<string>();
96
+ collectBareCustomToolNames(body, names);
97
+ return names;
98
+ }
99
+
100
+ /** Lower bare Responses custom tools to functions for xAI, preserving enough metadata to restore calls. */
101
+ export function lowerXaiResponsesCustomTools(body: unknown): { body: unknown; names: Set<string> } {
102
+ const names = xaiResponsesCustomToolNames(body);
103
+ if (names.size === 0) return { body, names };
104
+ const callIds = new Set<string>();
105
+ collectConvertedCallIds(body, names, callIds);
106
+ return { body: rewriteForUpstream(body, names, callIds), names };
107
+ }
108
+
109
+ function customItemId(id: unknown): unknown {
110
+ return typeof id === "string" && id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id;
111
+ }
112
+
113
+ function unwrapInput(argumentsText: unknown): string {
114
+ if (typeof argumentsText !== "string") return "";
115
+ try {
116
+ const parsed: unknown = JSON.parse(argumentsText);
117
+ if (isPlainObject(parsed) && typeof parsed.input === "string") return parsed.input;
118
+ } catch {
119
+ // Provider may return raw input; preserve it.
120
+ }
121
+ return argumentsText;
122
+ }
123
+
124
+ function restoreItem(item: unknown, names: ReadonlySet<string>): unknown {
125
+ if (!isPlainObject(item) || (item.type !== "function_call" && item.type !== "custom_tool_call")) return item;
126
+ if (typeof item.name !== "string" || !names.has(item.name)) return item;
127
+ const sourceInput = item.type === "function_call" ? item.arguments : item.input;
128
+ const restored: Record<string, unknown> = {
129
+ ...item,
130
+ type: "custom_tool_call",
131
+ id: customItemId(item.id),
132
+ input: unwrapInput(sourceInput),
133
+ };
134
+ delete restored.arguments;
135
+ return restored;
136
+ }
137
+
138
+ function restorePayload(value: unknown, names: ReadonlySet<string>): unknown {
139
+ if (!isPlainObject(value)) return value;
140
+ let changed = false;
141
+ const next: Record<string, unknown> = { ...value };
142
+
143
+ if (Array.isArray(value.output)) {
144
+ const output = value.output.map(item => {
145
+ const restored = restoreItem(item, names);
146
+ changed ||= restored !== item;
147
+ return restored;
148
+ });
149
+ if (changed) next.output = output;
150
+ }
151
+
152
+ if ((value.type === "response.output_item.added" || value.type === "response.output_item.done") && isPlainObject(value.item)) {
153
+ const restored = restoreItem(value.item, names);
154
+ if (restored !== value.item) {
155
+ next.item = restored;
156
+ changed = true;
157
+ }
158
+ }
159
+
160
+ if (typeof value.type === "string" && value.type.startsWith("response.") && isPlainObject(value.response)) {
161
+ const restored = restorePayload(value.response, names);
162
+ if (restored !== value.response) {
163
+ next.response = restored;
164
+ changed = true;
165
+ }
166
+ }
167
+ return changed ? next : value;
168
+ }
169
+
170
+ /** Restore converted calls in a non-streaming Responses JSON body. */
171
+ export function restoreXaiCustomCallsInJson(text: string, names: ReadonlySet<string>): string {
172
+ if (names.size === 0) return text;
173
+ try {
174
+ const parsed = JSON.parse(text) as unknown;
175
+ const restored = restorePayload(parsed, names);
176
+ return restored === parsed ? text : JSON.stringify(restored);
177
+ } catch {
178
+ return text;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Client-facing SSE rewrite. Argument deltas stay preview-only: until the `{input:string}` wrapper
184
+ * becomes valid JSON they are emitted as empty custom-input deltas, then the done frame and final
185
+ * item carry the authoritative raw input. This avoids leaking wrapper JSON while keeping the event
186
+ * sequence valid for Codex.
187
+ */
188
+ export function createXaiCustomToolPayloadRewrite(names: ReadonlySet<string>): SsePayloadRewrite | undefined {
189
+ if (names.size === 0) return undefined;
190
+ const itemIds = new Map<string, string>();
191
+ const convertedItemIds = new Set<string>();
192
+ return (payload: string): string => {
193
+ if (payload === "[DONE]") return payload;
194
+ let value: unknown;
195
+ try { value = JSON.parse(payload); } catch { return payload; }
196
+ if (!isPlainObject(value)) return payload;
197
+
198
+ if ((value.type === "response.output_item.added" || value.type === "response.output_item.done") && isPlainObject(value.item)) {
199
+ const item = value.item;
200
+ if ((item.type === "function_call" || item.type === "custom_tool_call") && typeof item.name === "string" && names.has(item.name)) {
201
+ const oldId = typeof item.id === "string" ? item.id : undefined;
202
+ const newId = customItemId(oldId);
203
+ if (oldId && typeof newId === "string") {
204
+ itemIds.set(oldId, newId);
205
+ convertedItemIds.add(oldId);
206
+ }
207
+ }
208
+ }
209
+
210
+ if (value.type === "response.function_call_arguments.delta") {
211
+ const itemId = typeof value.item_id === "string" ? value.item_id : undefined;
212
+ if (!itemId || !convertedItemIds.has(itemId)) return payload;
213
+ return JSON.stringify({
214
+ ...value,
215
+ type: "response.custom_tool_call_input.delta",
216
+ ...(itemId ? { item_id: itemIds.get(itemId) ?? customItemId(itemId) } : {}),
217
+ delta: "",
218
+ });
219
+ }
220
+ if (value.type === "response.function_call_arguments.done") {
221
+ const itemId = typeof value.item_id === "string" ? value.item_id : undefined;
222
+ if (!itemId || !convertedItemIds.has(itemId)) return payload;
223
+ const input = unwrapInput(value.arguments);
224
+ const next: Record<string, unknown> = {
225
+ ...value,
226
+ type: "response.custom_tool_call_input.done",
227
+ ...(itemId ? { item_id: itemIds.get(itemId) ?? customItemId(itemId) } : {}),
228
+ input,
229
+ };
230
+ delete next.arguments;
231
+ return JSON.stringify(next);
232
+ }
233
+
234
+ const restored = restorePayload(value, names);
235
+ return restored === value ? payload : JSON.stringify(restored);
236
+ };
237
+ }