@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.
- package/gui/dist/assets/{index-BhXIu7c0.js → index-Ch-99jy3.js} +2 -2
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/packages/ocx-notch/README.md +3 -1
- package/src/adapters/base.ts +12 -0
- package/src/adapters/identity.ts +1 -1
- package/src/adapters/kiro-calibration.ts +83 -0
- package/src/adapters/kiro-constants.ts +11 -2
- package/src/adapters/kiro-errors.ts +11 -0
- package/src/adapters/kiro-events.ts +19 -1
- package/src/adapters/kiro-thinking.ts +18 -2
- package/src/adapters/kiro-tools.ts +12 -3
- package/src/adapters/kiro.ts +300 -78
- package/src/adapters/openai-chat.ts +1 -42
- package/src/adapters/openai-responses.ts +126 -9
- package/src/adapters/xai-schema-analysis.ts +78 -0
- package/src/adapters/xai-tool-schema.ts +274 -0
- package/src/adapters/xai-web-search.ts +138 -0
- package/src/bridge.ts +61 -6
- package/src/cli/observe.ts +18 -3
- package/src/codex/app-server-processes.ts +3 -5
- package/src/codex/catalog/effort.ts +4 -2
- package/src/codex/catalog/metadata.ts +42 -9
- package/src/codex/catalog/parsing.ts +17 -2
- package/src/codex/catalog/provider-fetch.ts +9 -3
- package/src/codex/catalog/sync.ts +11 -5
- package/src/codex/data/upstream-models.json +169 -0
- package/src/grok/inject.ts +1 -1
- package/src/lib/errors.ts +18 -0
- package/src/lib/token-estimate.ts +42 -38
- package/src/lib/translator-budget.ts +34 -0
- package/src/oauth/index.ts +10 -4
- package/src/oauth/kiro.ts +71 -6
- package/src/oauth/store.ts +3 -1
- package/src/oauth/types.ts +4 -0
- package/src/providers/derive.ts +7 -5
- package/src/providers/opencode-go-transport.ts +59 -0
- package/src/providers/quota.ts +68 -60
- package/src/providers/registry.ts +41 -10
- package/src/providers/xai-transport.ts +10 -0
- package/src/responses/compaction.ts +8 -1
- package/src/responses/namespace-aliases.ts +56 -0
- package/src/responses/parser.ts +12 -0
- package/src/responses/reasoning-envelope.ts +9 -1
- package/src/responses/snapshot-policy.ts +108 -0
- package/src/responses/state.ts +23 -10
- package/src/responses/turn-termination.ts +108 -0
- package/src/responses/xai-custom-tool-compat.ts +237 -0
- package/src/server/grok-responses-snapshot-repair.ts +338 -0
- package/src/server/index.ts +2 -1
- package/src/server/relay-eager.ts +1 -0
- package/src/server/request-log-conversation.ts +8 -0
- package/src/server/request-log.ts +5 -4
- package/src/server/responses/core.ts +233 -16
- package/src/server/responses-image-gen-repair.ts +2 -2
- package/src/server/sse-payload-rewrite.ts +20 -3
- package/src/types.ts +10 -1
- package/src/usage/cost.ts +0 -0
- package/src/usage/expected-prices.ts +7 -0
- package/src/usage/log.ts +1 -2
- 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
|
+
}
|
package/src/responses/state.ts
CHANGED
|
@@ -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 <
|
|
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
|
-
|
|
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); },
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|