@vincemakes/kiso-runtime 0.1.38 → 0.2.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.
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +2 -0
- package/dist/index.d.ts +32 -11
- package/dist/index.js +41 -11
- package/dist/internal.d.ts +25 -0
- package/dist/internal.js +25 -0
- package/dist/lock-adapter.js +46 -1
- package/dist/run.js +21 -1
- package/dist/session.d.ts +9 -0
- package/dist/session.js +8 -0
- package/dist/trace/analyze.d.ts +35 -0
- package/dist/trace/analyze.js +51 -0
- package/dist/trace/guard.d.ts +46 -0
- package/dist/trace/guard.js +208 -0
- package/dist/trace/hash.d.ts +22 -0
- package/dist/trace/hash.js +34 -0
- package/dist/trace/manifest.d.ts +36 -0
- package/dist/trace/manifest.js +100 -0
- package/dist/trace/record.d.ts +129 -0
- package/dist/trace/record.js +237 -0
- package/dist/trace/writer.d.ts +43 -0
- package/dist/trace/writer.js +154 -0
- package/dist/usage/canonical.d.ts +123 -0
- package/dist/usage/canonical.js +152 -0
- package/package.json +9 -5
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — the Request Trace record schema (proposal §1.1 as adopted
|
|
3
|
+
* by the R1 ruling: "adopt as-is, keep ts, per-round additive"). This is
|
|
4
|
+
* the 1.2.0 field-set lock: every field below is spec'd, and the
|
|
5
|
+
* closed-field-set gate (ruling R1a) pins the shape bidirectionally —
|
|
6
|
+
* `TRACE_RECORD_FIELDS`/`TRACE_SEGMENT_FIELDS` must stay in exact
|
|
7
|
+
* agreement with the interfaces, or trace-schema.test.ts goes red.
|
|
8
|
+
*
|
|
9
|
+
* R1a: `schemaVersion` pins BOTH the record shape AND the hash /
|
|
10
|
+
* fingerprint algorithms (`HASH_SPEC_BY_VERSION`). A version bump must
|
|
11
|
+
* pin a spec for the new version before any writer can use it
|
|
12
|
+
* (`hashSpecFor` throws otherwise).
|
|
13
|
+
*
|
|
14
|
+
* The ledger is OUT-side (ADR-0051 §6): versionable, never part of the
|
|
15
|
+
* correctness ABI. These types are runtime-internal.
|
|
16
|
+
*
|
|
17
|
+
* E2 (1.3.0) — schemaVersion 2: the record gains the `canonical` block
|
|
18
|
+
* (E2 proposal §1.5 Case A — the nested block, ruled 2026-08-13; raw
|
|
19
|
+
* quartet stays as provider observation above it). The validators accept
|
|
20
|
+
* BOTH generations (R1d-1): a v1 sidecar has no canonical block and reads
|
|
21
|
+
* as defaults at every consumer — never a crash.
|
|
22
|
+
*/
|
|
23
|
+
/** schemaVersion: 2 for 1.3.0 (the canonical block). Version 1 = the 1.2.0
|
|
24
|
+
* shape, kept for generation-compat reads (R1d-1). Algorithm and shape
|
|
25
|
+
* changes bump it (ADR-0051 §6 OUT-side versioning). */
|
|
26
|
+
export const TRACE_SCHEMA_VERSION = 2;
|
|
27
|
+
/** The versions a reader may meet in a ledger. v1 records are accepted
|
|
28
|
+
* (generation-compat, R1d-1) and read as defaults — no canonical block. */
|
|
29
|
+
export const TRACE_SCHEMA_VERSIONS = new Set([1, TRACE_SCHEMA_VERSION]);
|
|
30
|
+
import { PRICING_TABLE_V1, priceFor, pricingTableFor, validateCanonicalUsage } from "../usage/canonical.js";
|
|
31
|
+
export const HASH_SPEC_BY_VERSION = {
|
|
32
|
+
1: { algorithm: "sha-256", output: "full-hex" },
|
|
33
|
+
2: { algorithm: "sha-256", output: "full-hex" }, // E2 — the algorithms do not change
|
|
34
|
+
};
|
|
35
|
+
export function hashSpecFor(version) {
|
|
36
|
+
const spec = HASH_SPEC_BY_VERSION[version];
|
|
37
|
+
if (spec === undefined)
|
|
38
|
+
throw new Error(`no hash spec pinned for trace schemaVersion ${version}`);
|
|
39
|
+
return spec;
|
|
40
|
+
}
|
|
41
|
+
// ── The closed-field-set gate (R1a) ───────────────────────────────────────
|
|
42
|
+
// Trace-schema.test.ts asserts Object.keys of a fully populated record is
|
|
43
|
+
// EXACTLY this set (both directions).
|
|
44
|
+
/** The 1.2.0 field set (schemaVersion 1) — kept verbatim for
|
|
45
|
+
* generation-compat reads of old sidecars (R1d-1): a v1 record has no
|
|
46
|
+
* canonical block and reads as defaults at every consumer. */
|
|
47
|
+
export const TRACE_RECORD_FIELDS_V1 = [
|
|
48
|
+
"schemaVersion",
|
|
49
|
+
"kind",
|
|
50
|
+
"requestId",
|
|
51
|
+
"runId",
|
|
52
|
+
"requestIndex",
|
|
53
|
+
"retryAttempt",
|
|
54
|
+
"provider",
|
|
55
|
+
"model",
|
|
56
|
+
"adapterVersion",
|
|
57
|
+
"systemPromptHash",
|
|
58
|
+
"toolSchemaHash",
|
|
59
|
+
"contextHash",
|
|
60
|
+
"contextManifest",
|
|
61
|
+
"segmentHashes",
|
|
62
|
+
"stablePrefixFingerprint",
|
|
63
|
+
"freshInput",
|
|
64
|
+
"cacheRead",
|
|
65
|
+
"cacheWrite",
|
|
66
|
+
"output",
|
|
67
|
+
"latencyMs",
|
|
68
|
+
"ttftMs",
|
|
69
|
+
"toolCalls",
|
|
70
|
+
"outcome",
|
|
71
|
+
"lineageLink",
|
|
72
|
+
"ts",
|
|
73
|
+
];
|
|
74
|
+
/** The 1.3.0 field set (schemaVersion 2) = the v1 set + `canonical`. */
|
|
75
|
+
export const TRACE_RECORD_FIELDS = [...TRACE_RECORD_FIELDS_V1, "canonical"];
|
|
76
|
+
export const TRACE_SEGMENT_FIELDS = ["role", "seqRange", "estTokens", "freshness"];
|
|
77
|
+
// ── Validators ────────────────────────────────────────────────────────────
|
|
78
|
+
// Strict by design: extra keys are rejected (the closed set), so a
|
|
79
|
+
// misspelled field can never silently enter the ledger.
|
|
80
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
81
|
+
const keysOf = (v) => (isRecord(v) ? Object.keys(v) : []);
|
|
82
|
+
const isNonNegInt = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
83
|
+
const isNumber = (v) => typeof v === "number" && Number.isFinite(v);
|
|
84
|
+
const isHex64 = (v) => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);
|
|
85
|
+
/** The closed set, both directions: no key outside the spec, and every
|
|
86
|
+
* spec'd key present except the explicit optional ones. */
|
|
87
|
+
const hasClosedKeys = (v, spec, optional = []) => {
|
|
88
|
+
const keys = keysOf(v);
|
|
89
|
+
if (keys.some((k) => !spec.includes(k)))
|
|
90
|
+
return false;
|
|
91
|
+
const optionalSet = new Set(optional);
|
|
92
|
+
return spec.every((k) => optionalSet.has(k) || keys.includes(k));
|
|
93
|
+
};
|
|
94
|
+
const VALID_SEGMENT_ROLES = new Set(["system", "tools", "turn", "current_turn"]);
|
|
95
|
+
const VALID_FRESHNESS = new Set(["fresh", "cache_read", "cache_write"]);
|
|
96
|
+
const VALID_OUTCOMES = new Set(["ok", "provider_error", "aborted"]);
|
|
97
|
+
function isValidSeqRange(v) {
|
|
98
|
+
if (v === null)
|
|
99
|
+
return true;
|
|
100
|
+
if (!Array.isArray(v) || v.length !== 2)
|
|
101
|
+
return false;
|
|
102
|
+
const [a, b] = v;
|
|
103
|
+
return isNonNegInt(a) && isNonNegInt(b) && a <= b;
|
|
104
|
+
}
|
|
105
|
+
export function validateTraceSegment(v) {
|
|
106
|
+
if (!isRecord(v) || !hasClosedKeys(v, TRACE_SEGMENT_FIELDS))
|
|
107
|
+
return false;
|
|
108
|
+
if (typeof v.role !== "string" || !VALID_SEGMENT_ROLES.has(v.role))
|
|
109
|
+
return false;
|
|
110
|
+
if (!isValidSeqRange(v.seqRange))
|
|
111
|
+
return false;
|
|
112
|
+
if (!isNonNegInt(v.estTokens))
|
|
113
|
+
return false;
|
|
114
|
+
if (!VALID_FRESHNESS.has(v.freshness))
|
|
115
|
+
return false;
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
export function validateTraceRecord(v) {
|
|
119
|
+
if (!isRecord(v))
|
|
120
|
+
return false;
|
|
121
|
+
const version = v.schemaVersion;
|
|
122
|
+
// generation-compat (R1d-1): a v1 sidecar has no canonical block and
|
|
123
|
+
// reads as defaults — accepted, never a crash; the current version is
|
|
124
|
+
// fully checked (shape + the canonical block + its consistency).
|
|
125
|
+
if (version !== 1 && version !== TRACE_SCHEMA_VERSION)
|
|
126
|
+
return false;
|
|
127
|
+
const fields = version === 1 ? TRACE_RECORD_FIELDS_V1 : TRACE_RECORD_FIELDS;
|
|
128
|
+
if (!hasClosedKeys(v, fields, ["lineageLink"]))
|
|
129
|
+
return false;
|
|
130
|
+
if (v.kind !== "request")
|
|
131
|
+
return false;
|
|
132
|
+
if (typeof v.requestId !== "string" || typeof v.runId !== "string")
|
|
133
|
+
return false;
|
|
134
|
+
if (!isNonNegInt(v.requestIndex) || !isNonNegInt(v.retryAttempt))
|
|
135
|
+
return false;
|
|
136
|
+
if (typeof v.provider !== "string" || typeof v.model !== "string")
|
|
137
|
+
return false;
|
|
138
|
+
if (v.adapterVersion !== null && typeof v.adapterVersion !== "string")
|
|
139
|
+
return false;
|
|
140
|
+
if (!isHex64(v.systemPromptHash) || !isHex64(v.toolSchemaHash) || !isHex64(v.contextHash))
|
|
141
|
+
return false;
|
|
142
|
+
if (!isHex64(v.stablePrefixFingerprint))
|
|
143
|
+
return false;
|
|
144
|
+
if (!Array.isArray(v.contextManifest) || !v.contextManifest.every(validateTraceSegment))
|
|
145
|
+
return false;
|
|
146
|
+
// segmentHashes must mirror the manifest 1:1 — a misaligned list
|
|
147
|
+
// would silently corrupt the break derivation (R4b)
|
|
148
|
+
if (!Array.isArray(v.segmentHashes) ||
|
|
149
|
+
v.segmentHashes.length !== v.contextManifest.length ||
|
|
150
|
+
!v.segmentHashes.every(isHex64))
|
|
151
|
+
return false;
|
|
152
|
+
if (!isNumber(v.freshInput) || !isNumber(v.cacheRead))
|
|
153
|
+
return false;
|
|
154
|
+
if (v.cacheWrite !== null && !isNumber(v.cacheWrite))
|
|
155
|
+
return false;
|
|
156
|
+
if (!isNumber(v.output) || !isNumber(v.latencyMs))
|
|
157
|
+
return false;
|
|
158
|
+
if (!isNumber(v.ttftMs))
|
|
159
|
+
return false; // 0 = unknown, never null (locked set)
|
|
160
|
+
if (!Array.isArray(v.toolCalls) || !v.toolCalls.every((t) => typeof t === "string"))
|
|
161
|
+
return false;
|
|
162
|
+
if (typeof v.outcome !== "string" || !VALID_OUTCOMES.has(v.outcome))
|
|
163
|
+
return false;
|
|
164
|
+
if (v.lineageLink !== undefined) {
|
|
165
|
+
const l = v.lineageLink;
|
|
166
|
+
if (!isRecord(l))
|
|
167
|
+
return false;
|
|
168
|
+
if (typeof l.parentSessionId !== "string" || typeof l.parentRunId !== "string")
|
|
169
|
+
return false;
|
|
170
|
+
if (!isNonNegInt(l.parentInvocationSeq))
|
|
171
|
+
return false;
|
|
172
|
+
if (typeof l.role !== "string")
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
if (version !== 1) {
|
|
176
|
+
// the canonical block: the schema's invariants machine-checked
|
|
177
|
+
if (!validateCanonicalUsage(v.canonical))
|
|
178
|
+
return false;
|
|
179
|
+
const c = v.canonical;
|
|
180
|
+
// the block formalizes the raw quartet — a divergence means the
|
|
181
|
+
// derivation drifted (a future bug, caught at the ledger)
|
|
182
|
+
if (c.input !== v.freshInput || c.cacheRead !== v.cacheRead || c.output !== v.output || c.cacheWrite !== v.cacheWrite)
|
|
183
|
+
return false;
|
|
184
|
+
// cost consistency: recomputed from the components × the version's
|
|
185
|
+
// pinned table, at the record's own route (the route context lives
|
|
186
|
+
// in the record; the standalone schema cannot check this). A null
|
|
187
|
+
// costUsd is the R5b-④c absent stamp (the table has no rate for
|
|
188
|
+
// this route) — nothing to recompute against, accepted. The
|
|
189
|
+
// cross-check runs only for builtin-id records: a foreign table's
|
|
190
|
+
// consistency is the billing layer's accounting, not the ledger's.
|
|
191
|
+
if (c.costUsd !== null && c.pricingTableId === PRICING_TABLE_V1.id) {
|
|
192
|
+
const expected = priceFor(v.provider, { input: c.input, output: c.output, cacheRead: c.cacheRead, cacheWrite: c.cacheWrite }, pricingTableFor(c.pricingTableVersion));
|
|
193
|
+
// non-null for the builtin table by construction (both real
|
|
194
|
+
// routes + the mirror fallback) — a null here is a code bug,
|
|
195
|
+
// and an epsilon check has nothing to compare
|
|
196
|
+
if (expected !== null && Math.abs(c.costUsd - expected) > 1e-6)
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (!isNumber(v.ts))
|
|
201
|
+
return false;
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
export function validateTraceLine(v) {
|
|
205
|
+
if (!isRecord(v))
|
|
206
|
+
return false;
|
|
207
|
+
// both ledger generations are readable (R1d-1); the per-kind shapes are
|
|
208
|
+
// identical across 1 → 2 — only the request line's field set differs
|
|
209
|
+
// (v1 lacks the canonical block), handled by validateTraceRecord's
|
|
210
|
+
// version dispatch
|
|
211
|
+
if (!TRACE_SCHEMA_VERSIONS.has(v.schemaVersion))
|
|
212
|
+
return false;
|
|
213
|
+
switch (v.kind) {
|
|
214
|
+
case "header":
|
|
215
|
+
return (hasClosedKeys(v, ["schemaVersion", "kind", "sessionId", "kisoVersion", "createdAt"]) &&
|
|
216
|
+
typeof v.sessionId === "string" &&
|
|
217
|
+
typeof v.kisoVersion === "string" &&
|
|
218
|
+
isNumber(v.createdAt));
|
|
219
|
+
case "request":
|
|
220
|
+
return validateTraceRecord(v);
|
|
221
|
+
case "run_end":
|
|
222
|
+
return (hasClosedKeys(v, ["schemaVersion", "kind", "runId", "ts", "lastRequestIndex"]) &&
|
|
223
|
+
typeof v.runId === "string" &&
|
|
224
|
+
isNumber(v.ts) &&
|
|
225
|
+
// -1 = the run made no adapter calls (an empty run still
|
|
226
|
+
// settles cleanly); anything below that is not a request index
|
|
227
|
+
typeof v.lastRequestIndex === "number" &&
|
|
228
|
+
Number.isInteger(v.lastRequestIndex) &&
|
|
229
|
+
v.lastRequestIndex >= -1);
|
|
230
|
+
case "crash":
|
|
231
|
+
return (hasClosedKeys(v, ["schemaVersion", "kind", "ts", "note"]) &&
|
|
232
|
+
isNumber(v.ts) &&
|
|
233
|
+
typeof v.note === "string");
|
|
234
|
+
default:
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 2, the trace writer (proposal §1.2 + §6).
|
|
3
|
+
*
|
|
4
|
+
* The ledger is OUT-side (ADR-0051 §6): a versionable observation
|
|
5
|
+
* artifact, never part of the correctness ABI. The soft-fail law
|
|
6
|
+
* (work-order): trace loss costs ONE stderr line, then the session runs
|
|
7
|
+
* on — the writer degrades to a no-op on its first I/O failure and
|
|
8
|
+
* never throws into the caller.
|
|
9
|
+
*
|
|
10
|
+
* Async discipline (work-order §9): request lines are ENQUEUED (a sync
|
|
11
|
+
* push, sub-microsecond) and flushed on setImmediate — the model hot
|
|
12
|
+
* path never blocks on the ledger. The run_end line is flushed
|
|
13
|
+
* SYNCHRONOUSLY from the run's finally block: clean-settle marking is
|
|
14
|
+
* where the ledger's story (every hole reconciles as a crash, never as
|
|
15
|
+
* a writer bug) needs the write to have happened before the run ends.
|
|
16
|
+
*/
|
|
17
|
+
import { type TraceLine } from "./record.js";
|
|
18
|
+
/** The runtime's own version, read from the package.json next to the
|
|
19
|
+
* build; null on failure (soft-fail — the header still ships). */
|
|
20
|
+
export declare function runtimeVersion(): string | null;
|
|
21
|
+
export interface TraceWriterDeps {
|
|
22
|
+
/** The sessions dir (store.root). The ledger lives in <root>/traces/ —
|
|
23
|
+
* NOT flat in root: a flat `<sid>.trace.jsonl` would be enumerated as
|
|
24
|
+
* a phantom session by SessionStore.list() and corrupt the bench
|
|
25
|
+
* sessions glob (proposal §3.2; the R3a gate pins it). */
|
|
26
|
+
root: string;
|
|
27
|
+
sessionId: string;
|
|
28
|
+
}
|
|
29
|
+
export declare class TraceWriter {
|
|
30
|
+
#private;
|
|
31
|
+
constructor(deps: TraceWriterDeps);
|
|
32
|
+
/** One-time setup: mkdir the ledger dir, mark a previous run that left
|
|
33
|
+
* no run_end (crash), write the header — the header once per ledger
|
|
34
|
+
* file (session-level, §1.2), so a resume appends markers, never a
|
|
35
|
+
* second header. Never throws. */
|
|
36
|
+
init(): void;
|
|
37
|
+
/** Enqueue a line for the next flush — the hot path's only cost.
|
|
38
|
+
* Never throws; after a degradation the line is dropped. */
|
|
39
|
+
enqueue(line: TraceLine): void;
|
|
40
|
+
/** Clean-settle marking: the run_end line lands NOW (synchronous),
|
|
41
|
+
* together with any still-pending request lines. */
|
|
42
|
+
finishRun(runId: string, lastRequestIndex: number): void;
|
|
43
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E1 (1.2.0) — slice 2, the trace writer (proposal §1.2 + §6).
|
|
3
|
+
*
|
|
4
|
+
* The ledger is OUT-side (ADR-0051 §6): a versionable observation
|
|
5
|
+
* artifact, never part of the correctness ABI. The soft-fail law
|
|
6
|
+
* (work-order): trace loss costs ONE stderr line, then the session runs
|
|
7
|
+
* on — the writer degrades to a no-op on its first I/O failure and
|
|
8
|
+
* never throws into the caller.
|
|
9
|
+
*
|
|
10
|
+
* Async discipline (work-order §9): request lines are ENQUEUED (a sync
|
|
11
|
+
* push, sub-microsecond) and flushed on setImmediate — the model hot
|
|
12
|
+
* path never blocks on the ledger. The run_end line is flushed
|
|
13
|
+
* SYNCHRONOUSLY from the run's finally block: clean-settle marking is
|
|
14
|
+
* where the ledger's story (every hole reconciles as a crash, never as
|
|
15
|
+
* a writer bug) needs the write to have happened before the run ends.
|
|
16
|
+
*/
|
|
17
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { TRACE_SCHEMA_VERSION } from "./record.js";
|
|
21
|
+
let cachedRuntimeVersion;
|
|
22
|
+
/** The runtime's own version, read from the package.json next to the
|
|
23
|
+
* build; null on failure (soft-fail — the header still ships). */
|
|
24
|
+
export function runtimeVersion() {
|
|
25
|
+
if (cachedRuntimeVersion === undefined) {
|
|
26
|
+
cachedRuntimeVersion = (() => {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"));
|
|
29
|
+
return pkg.version ?? null;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
}
|
|
36
|
+
return cachedRuntimeVersion;
|
|
37
|
+
}
|
|
38
|
+
export class TraceWriter {
|
|
39
|
+
#root;
|
|
40
|
+
#sessionId;
|
|
41
|
+
#path;
|
|
42
|
+
#pending = [];
|
|
43
|
+
#flushScheduled = false;
|
|
44
|
+
#degraded = false;
|
|
45
|
+
constructor(deps) {
|
|
46
|
+
this.#root = deps.root;
|
|
47
|
+
this.#sessionId = deps.sessionId;
|
|
48
|
+
this.#path = join(deps.root, "traces", `${deps.sessionId}.jsonl`);
|
|
49
|
+
}
|
|
50
|
+
/** One-time setup: mkdir the ledger dir, mark a previous run that left
|
|
51
|
+
* no run_end (crash), write the header — the header once per ledger
|
|
52
|
+
* file (session-level, §1.2), so a resume appends markers, never a
|
|
53
|
+
* second header. Never throws. */
|
|
54
|
+
init() {
|
|
55
|
+
if (this.#degraded)
|
|
56
|
+
return;
|
|
57
|
+
try {
|
|
58
|
+
mkdirSync(join(this.#root, "traces"), { recursive: true });
|
|
59
|
+
const fresh = !existsSync(this.#path);
|
|
60
|
+
if (this.#crashDetect()) {
|
|
61
|
+
this.#writeSync([
|
|
62
|
+
{ schemaVersion: TRACE_SCHEMA_VERSION, kind: "crash", ts: Date.now(), note: "previous run left no run_end" },
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
if (fresh) {
|
|
66
|
+
this.#writeSync([
|
|
67
|
+
{
|
|
68
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
69
|
+
kind: "header",
|
|
70
|
+
sessionId: this.#sessionId,
|
|
71
|
+
kisoVersion: runtimeVersion() ?? "?",
|
|
72
|
+
createdAt: Date.now(),
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
this.#degrade(err);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** Enqueue a line for the next flush — the hot path's only cost.
|
|
82
|
+
* Never throws; after a degradation the line is dropped. */
|
|
83
|
+
enqueue(line) {
|
|
84
|
+
if (this.#degraded)
|
|
85
|
+
return;
|
|
86
|
+
this.#pending.push(line);
|
|
87
|
+
if (!this.#flushScheduled) {
|
|
88
|
+
this.#flushScheduled = true;
|
|
89
|
+
setImmediate(() => {
|
|
90
|
+
this.#flushScheduled = false;
|
|
91
|
+
this.#flushSync();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Clean-settle marking: the run_end line lands NOW (synchronous),
|
|
96
|
+
* together with any still-pending request lines. */
|
|
97
|
+
finishRun(runId, lastRequestIndex) {
|
|
98
|
+
this.enqueue({
|
|
99
|
+
schemaVersion: TRACE_SCHEMA_VERSION,
|
|
100
|
+
kind: "run_end",
|
|
101
|
+
runId,
|
|
102
|
+
ts: Date.now(),
|
|
103
|
+
lastRequestIndex,
|
|
104
|
+
});
|
|
105
|
+
this.#flushSync();
|
|
106
|
+
}
|
|
107
|
+
/** True when the ledger already exists and its LAST line is neither
|
|
108
|
+
* run_end nor crash — an un-terminated run (killed mid-run), to be
|
|
109
|
+
* marked. A crash-marked ledger is not re-marked. */
|
|
110
|
+
#crashDetect() {
|
|
111
|
+
if (!existsSync(this.#path))
|
|
112
|
+
return false;
|
|
113
|
+
let last;
|
|
114
|
+
try {
|
|
115
|
+
for (const line of readFileSync(this.#path, "utf8").split("\n")) {
|
|
116
|
+
if (line.trim() === "")
|
|
117
|
+
continue;
|
|
118
|
+
last = JSON.parse(line);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return false; // unreadable ledger — do not compound the failure
|
|
123
|
+
}
|
|
124
|
+
if (last === undefined)
|
|
125
|
+
return false;
|
|
126
|
+
return last.kind !== "run_end" && last.kind !== "crash";
|
|
127
|
+
}
|
|
128
|
+
#writeSync(lines) {
|
|
129
|
+
if (lines.length === 0)
|
|
130
|
+
return;
|
|
131
|
+
try {
|
|
132
|
+
appendFileSync(this.#path, `${lines.map((l) => JSON.stringify(l)).join("\n")}\n`);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
this.#degrade(err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
#flushSync() {
|
|
139
|
+
if (this.#pending.length === 0)
|
|
140
|
+
return;
|
|
141
|
+
const lines = this.#pending;
|
|
142
|
+
this.#pending = [];
|
|
143
|
+
this.#writeSync(lines);
|
|
144
|
+
}
|
|
145
|
+
/** The soft-fail law: one stderr line, then the ledger is dropped and
|
|
146
|
+
* the session runs on. Idempotent. */
|
|
147
|
+
#degrade(err) {
|
|
148
|
+
if (this.#degraded)
|
|
149
|
+
return;
|
|
150
|
+
this.#degraded = true;
|
|
151
|
+
this.#pending = [];
|
|
152
|
+
console.error(`[kiso] trace writer degraded (${err instanceof Error ? err.message : String(err)}); request traces dropped`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* E2 (1.3.0) — Canonical Usage: the reconciled usage schema (proposal §1
|
|
3
|
+
* as ruled 2026-08-13; R1a-R5 all adopted).
|
|
4
|
+
*
|
|
5
|
+
* THE PINNED SENTENCE (R1a): one canonical meaning for `input`, everywhere
|
|
6
|
+
* in kiso, forever: `input` is FRESH-ONLY — the tokens NOT served from
|
|
7
|
+
* cache. `total = input + cacheRead + cacheWrite` is the derived quantity,
|
|
8
|
+
* never a reported one. A cache ratio is always `cacheRead / total` — the
|
|
9
|
+
* numerator can never exceed the denominator. The >100% disease is
|
|
10
|
+
* structurally impossible under the canonical schema.
|
|
11
|
+
*
|
|
12
|
+
* The mapping keys on the ROUTE, not the provider (R2a): DeepSeek serves
|
|
13
|
+
* both routes and reports input fresh-only on one (anthropic-compat) and
|
|
14
|
+
* TOTAL on the other (openai-compat) — the SAME canonical meaning falls
|
|
15
|
+
* out of the route key (the dual-endpoint closure). The route string is
|
|
16
|
+
* the config adapter identity the guard already holds (`"anthropic" |
|
|
17
|
+
* "openai-compat"`).
|
|
18
|
+
*
|
|
19
|
+
* Cost comes from the versioned pricing table — every cost carries the
|
|
20
|
+
* table version it was computed with (R1c). The built-in v1 table prices
|
|
21
|
+
* both routes at DeepSeek's published rates; the caveat sentence from the
|
|
22
|
+
* bench README carries forward verbatim ("an approximation, not a bill").
|
|
23
|
+
* The commercial table is the product's Phase 10 (the table is injectable;
|
|
24
|
+
* the version travels with every cost).
|
|
25
|
+
*
|
|
26
|
+
* The frozen usage union stays provider-raw — this module derives at the
|
|
27
|
+
* accounting boundary (R4 Case B, pure derivation; the union does not
|
|
28
|
+
* move). Raw is provider observation; canonical is the accounting truth.
|
|
29
|
+
*/
|
|
30
|
+
/** The union's usage shape (provider-raw — core protocol `events.Usage`). */
|
|
31
|
+
export interface RawUsage {
|
|
32
|
+
readonly inputTokens: number | null;
|
|
33
|
+
readonly outputTokens: number | null;
|
|
34
|
+
readonly cacheRead: number | null;
|
|
35
|
+
readonly cacheWrite: number | null;
|
|
36
|
+
}
|
|
37
|
+
/** The ONE canonical usage record. Every field has exactly one meaning
|
|
38
|
+
* (proposal §1.2). wall/ttft live in the trace record (latencyMs/ttftMs)
|
|
39
|
+
* — one source, never duplicated here. */
|
|
40
|
+
export interface CanonicalUsage {
|
|
41
|
+
/** FRESH-ONLY — the pinned sentence above. total = input + cacheRead +
|
|
42
|
+
* cacheWrite is the derived quantity, never a reported one. */
|
|
43
|
+
readonly input: number;
|
|
44
|
+
readonly output: number;
|
|
45
|
+
readonly cacheRead: number;
|
|
46
|
+
/** null = the provider reports none (openai-compat honestly does). */
|
|
47
|
+
readonly cacheWrite: number | null;
|
|
48
|
+
/** null = no reasoning split reported (reserved — every provider today). */
|
|
49
|
+
readonly reasoning: number | null;
|
|
50
|
+
/** USD from the pricing table below — null is the R5b-④c ABSENT stamp:
|
|
51
|
+
* the table has no rate for this route (an injected table's hole is
|
|
52
|
+
* explicit absent, never backfilled from the builtin table). The
|
|
53
|
+
* builtin v1 table covers both real routes, so the null branch is
|
|
54
|
+
* unreachable today — but the type must be able to express it.
|
|
55
|
+
* Never reported without its (pricingTableId, pricingTableVersion)
|
|
56
|
+
* tuple — the billing-basis ground. */
|
|
57
|
+
readonly costUsd: number | null;
|
|
58
|
+
/** The table the cost was computed with (R5b-④b): the (id, version)
|
|
59
|
+
* two-tuple stamp — cross-table version reconciliation and billing
|
|
60
|
+
* disputes answerable. "builtin" = the pinned in-repo table below;
|
|
61
|
+
* injected tables carry their own id. */
|
|
62
|
+
readonly pricingTableId: string;
|
|
63
|
+
readonly pricingTableVersion: number;
|
|
64
|
+
}
|
|
65
|
+
/** The versioned pricing table — a rate change is a version bump, never an
|
|
66
|
+
* edit; every cost records the version it was computed with (R1c). The
|
|
67
|
+
* id is the table's identity (R5b-④b): "builtin" for the pinned in-repo
|
|
68
|
+
* table, anything else for an injected commercial table. */
|
|
69
|
+
export interface PricingTable {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
readonly version: number;
|
|
72
|
+
readonly entries: Readonly<Record<string, PricingEntry>>;
|
|
73
|
+
}
|
|
74
|
+
export interface PricingEntry {
|
|
75
|
+
/** USD per 1M FRESH input tokens. */
|
|
76
|
+
readonly inputPerM: number;
|
|
77
|
+
readonly outputPerM: number;
|
|
78
|
+
readonly cacheReadPerM: number;
|
|
79
|
+
/** USD per 1M cache-creation tokens. 0 = the provider does not price
|
|
80
|
+
* cache writes separately (DeepSeek's automatic caching). */
|
|
81
|
+
readonly cacheWritePerM: number;
|
|
82
|
+
}
|
|
83
|
+
/** The route → input-convention table (R2a). `input` semantics differ
|
|
84
|
+
* between the two routes; the convention is a property of the ROUTE, not
|
|
85
|
+
* the provider. Unknown routes fall back to "total" — exactly the
|
|
86
|
+
* incumbent guard behavior (the guard's else-branch: non-anthropic =
|
|
87
|
+
* total), preserved by construction. */
|
|
88
|
+
export declare const INPUT_CONVENTIONS: Readonly<Record<string, "fresh" | "total">>;
|
|
89
|
+
/** Pricing table v1. Freeze date 2026-08-13 (the E2 ruling). Rates:
|
|
90
|
+
* DeepSeek's published rates (https://api-docs.deepseek.com/quick_start/pricing),
|
|
91
|
+
* the 0.1 cache-hit ratio the bench has used since the 0.1.23 round. The
|
|
92
|
+
* caveat sentence carries forward verbatim (bench README): "an
|
|
93
|
+
* approximation, not a bill." */
|
|
94
|
+
export declare const PRICING_TABLE_V1: PricingTable;
|
|
95
|
+
export declare const PRICING_TABLES: Readonly<Record<number, PricingTable>>;
|
|
96
|
+
/** The table pinned for a version; throws when none is pinned (a cost
|
|
97
|
+
* recorded against an unpinned version is a ledger corruption). */
|
|
98
|
+
export declare function pricingTableFor(version: number): PricingTable;
|
|
99
|
+
export declare function priceFor(route: string, u: {
|
|
100
|
+
input: number;
|
|
101
|
+
output: number;
|
|
102
|
+
cacheRead: number;
|
|
103
|
+
cacheWrite: number | null;
|
|
104
|
+
}, table: PricingTable): number | null;
|
|
105
|
+
/** Raw → canonical (the accounting boundary). Behavior-preserving against
|
|
106
|
+
* the guard's incumbent per-provider branch (guard.ts settle): anthropic
|
|
107
|
+
* input is fresh as-is; every other route subtracts cacheRead.
|
|
108
|
+
*
|
|
109
|
+
* The trailing `table` parameter is the injection slot (R5b-④a): the
|
|
110
|
+
* R5a-1-commercial table rides it on day one, defaulting to the pinned
|
|
111
|
+
* builtin v1 table so the export's default behavior is the pinned one
|
|
112
|
+
* and a future injection never widens this frozen signature. */
|
|
113
|
+
export declare function canonicalizeUsage(route: string, raw: RawUsage, table?: PricingTable): CanonicalUsage;
|
|
114
|
+
/** The canonical schema's invariants, machine-checked: closed key set;
|
|
115
|
+
* non-negative numbers; cacheWrite/reasoning null-or-number; the pinned
|
|
116
|
+
* sentence — cacheRead can never exceed total (= input + cacheRead +
|
|
117
|
+
* cacheWrite) — structurally true for non-negatives but pinned against
|
|
118
|
+
* future edits; costUsd null-or-number (R5b-④c — null is the injected
|
|
119
|
+
* table's absent stamp); the (pricingTableId, pricingTableVersion)
|
|
120
|
+
* tuple, with the registry pin scoped to the builtin id (R5b-④b) — an
|
|
121
|
+
* injected table's version is the injector's accounting, the ledger
|
|
122
|
+
* cannot know tables it does not carry. */
|
|
123
|
+
export declare function validateCanonicalUsage(v: unknown): v is CanonicalUsage;
|