@metaobjectsdev/runtime-ts 0.9.0-rc.1 → 0.10.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/drivers/drizzle-driver.d.ts.map +1 -1
- package/dist/drivers/drizzle-driver.js +5 -1
- package/dist/drivers/drizzle-driver.js.map +1 -1
- package/dist/drivers/in-memory-driver.js +2 -0
- package/dist/drivers/in-memory-driver.js.map +1 -1
- package/dist/drivers/kysely-driver.js +1 -0
- package/dist/drivers/kysely-driver.js.map +1 -1
- package/dist/drizzle-fastify/index.d.ts +16 -2
- package/dist/drizzle-fastify/index.d.ts.map +1 -1
- package/dist/drizzle-fastify/index.js +45 -13
- package/dist/drizzle-fastify/index.js.map +1 -1
- package/dist/drizzle-fastify/mount-m2m.d.ts +29 -0
- package/dist/drizzle-fastify/mount-m2m.d.ts.map +1 -0
- package/dist/drizzle-fastify/mount-m2m.js +94 -0
- package/dist/drizzle-fastify/mount-m2m.js.map +1 -0
- package/dist/drizzle-fastify/util.d.ts +2 -0
- package/dist/drizzle-fastify/util.d.ts.map +1 -1
- package/dist/drizzle-fastify/util.js +5 -0
- package/dist/drizzle-fastify/util.js.map +1 -1
- package/dist/extract-object.d.ts.map +1 -1
- package/dist/extract-object.js +14 -5
- package/dist/extract-object.js.map +1 -1
- package/dist/identity-strategy.d.ts.map +1 -1
- package/dist/identity-strategy.js +2 -1
- package/dist/identity-strategy.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/llm-recorder.d.ts +72 -0
- package/dist/llm-recorder.d.ts.map +1 -0
- package/dist/llm-recorder.js +82 -0
- package/dist/llm-recorder.js.map +1 -0
- package/dist/n2m-resolver.d.ts +7 -8
- package/dist/n2m-resolver.d.ts.map +1 -1
- package/dist/n2m-resolver.js +115 -38
- package/dist/n2m-resolver.js.map +1 -1
- package/dist/object-manager.d.ts +4 -0
- package/dist/object-manager.d.ts.map +1 -1
- package/dist/object-manager.js +71 -18
- package/dist/object-manager.js.map +1 -1
- package/dist/persistence-driver.d.ts +3 -0
- package/dist/persistence-driver.d.ts.map +1 -1
- package/dist/query-builder.d.ts +13 -3
- package/dist/query-builder.d.ts.map +1 -1
- package/dist/query-builder.js +19 -10
- package/dist/query-builder.js.map +1 -1
- package/dist/tph.d.ts +14 -0
- package/dist/tph.d.ts.map +1 -0
- package/dist/tph.js +37 -0
- package/dist/tph.js.map +1 -0
- package/dist/type-coercer.d.ts.map +1 -1
- package/dist/type-coercer.js +91 -8
- package/dist/type-coercer.js.map +1 -1
- package/dist/validator-runner.d.ts.map +1 -1
- package/dist/validator-runner.js +24 -3
- package/dist/validator-runner.js.map +1 -1
- package/package.json +62 -51
- package/src/drivers/drizzle-driver.ts +5 -0
- package/src/drivers/in-memory-driver.ts +2 -0
- package/src/drivers/kysely-driver.ts +1 -0
- package/src/drizzle-fastify/index.ts +55 -14
- package/src/drizzle-fastify/mount-m2m.ts +126 -0
- package/src/drizzle-fastify/util.ts +6 -0
- package/src/extract-object.ts +16 -6
- package/src/identity-strategy.ts +2 -1
- package/src/index.ts +7 -0
- package/src/llm-recorder.ts +166 -0
- package/src/n2m-resolver.ts +143 -57
- package/src/object-manager.ts +67 -18
- package/src/persistence-driver.ts +2 -1
- package/src/query-builder.ts +33 -8
- package/src/tph.ts +46 -0
- package/src/type-coercer.ts +94 -8
- package/src/validator-runner.ts +23 -3
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// M:N traversal route — mounts `GET {path}/:id/{relationName}` returning the
|
|
2
|
+
// related target rows reached through a junction table.
|
|
3
|
+
//
|
|
4
|
+
// This is the Drizzle-direct executor for a many-to-many relationship. Codegen
|
|
5
|
+
// derives the static descriptor at BUILD time (the source/target junction FK
|
|
6
|
+
// columns come from the junction entity's two `identity.reference` children via
|
|
7
|
+
// the shared `deriveM2MFields` SSOT — see relation-resolver.ts in codegen-ts),
|
|
8
|
+
// then emits a `mountM2mRoute({...})` call carrying those resolved column names.
|
|
9
|
+
// At runtime this helper performs the same two-stage join the ObjectManager
|
|
10
|
+
// `n2m-resolver` performs, but expressed in Drizzle so it stays self-evident
|
|
11
|
+
// Drizzle code in the user's app (no ObjectManager dependency, no metadata at
|
|
12
|
+
// runtime — ADR-0001 build-time binding).
|
|
13
|
+
//
|
|
14
|
+
// Three modes, identical to the runtime resolver:
|
|
15
|
+
// 1. Hetero (sourceCol != targetCol entity): junction WHERE sourceCol = :id,
|
|
16
|
+
// collect targetCol, target WHERE pk IN (...).
|
|
17
|
+
// 2. Directed self-join: same traversal; codegen picked which junction FK is
|
|
18
|
+
// the source side (via @sourceRefField) → sourceColumn/targetColumn.
|
|
19
|
+
// 3. Symmetric self-join: junction WHERE sourceCol = :id OR targetCol = :id;
|
|
20
|
+
// per row the related id is whichever column is NOT the source id.
|
|
21
|
+
|
|
22
|
+
import type { FastifyInstance, RouteShorthandOptions } from "fastify";
|
|
23
|
+
import { eq, or, inArray } from "drizzle-orm";
|
|
24
|
+
import { parseId } from "./util.js";
|
|
25
|
+
|
|
26
|
+
// Loose Drizzle types — the helper works across libsql / better-sqlite3 / pg.
|
|
27
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle instance
|
|
28
|
+
type AnyDrizzle = any;
|
|
29
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table
|
|
30
|
+
type AnyTable = any;
|
|
31
|
+
|
|
32
|
+
export interface M2mRouteOptions {
|
|
33
|
+
fastify: FastifyInstance;
|
|
34
|
+
/** Source resource path, e.g. "/posts". The route mounts at `{path}/:id/{relationName}`. */
|
|
35
|
+
path: string;
|
|
36
|
+
/** Navigation member name, e.g. "tags" → GET /posts/:id/tags. */
|
|
37
|
+
relationName: string;
|
|
38
|
+
/** User's Drizzle instance. */
|
|
39
|
+
db: AnyDrizzle;
|
|
40
|
+
/** The junction Drizzle table const (e.g. postTags). */
|
|
41
|
+
junctionTable: AnyTable;
|
|
42
|
+
/** The target Drizzle table const (e.g. tags). */
|
|
43
|
+
targetTable: AnyTable;
|
|
44
|
+
/** Junction FK column holding the SOURCE key (physical column name, e.g. "post_id"). */
|
|
45
|
+
sourceColumn: string;
|
|
46
|
+
/** Junction FK column holding the TARGET key (physical column name, e.g. "tag_id"). */
|
|
47
|
+
targetColumn: string;
|
|
48
|
+
/** Target entity PK column (physical column name, e.g. "id"). Defaults to "id". */
|
|
49
|
+
targetPkColumn?: string;
|
|
50
|
+
/** Undirected self-join: union both junction FK columns on read. */
|
|
51
|
+
symmetric: boolean;
|
|
52
|
+
/** Fastify route-level hooks (auth, etc.). */
|
|
53
|
+
routeOptions?: RouteShorthandOptions;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function mountM2mRoute(opts: M2mRouteOptions): void {
|
|
57
|
+
const targetPk = opts.targetPkColumn ?? "id";
|
|
58
|
+
const route = `${opts.path}/:id/${opts.relationName}`;
|
|
59
|
+
const ro = opts.routeOptions ?? {};
|
|
60
|
+
|
|
61
|
+
opts.fastify.get(route, ro, async (req) => {
|
|
62
|
+
const { id } = req.params as { id: string };
|
|
63
|
+
const sourceId = parseId(id);
|
|
64
|
+
|
|
65
|
+
const srcCol = columnRef(opts.junctionTable, opts.sourceColumn);
|
|
66
|
+
const tgtCol = columnRef(opts.junctionTable, opts.targetColumn);
|
|
67
|
+
|
|
68
|
+
// Stage 1 — junction rows for this source id.
|
|
69
|
+
const joinWhere = opts.symmetric
|
|
70
|
+
? or(eq(srcCol, sourceId), eq(tgtCol, sourceId))
|
|
71
|
+
: eq(srcCol, sourceId);
|
|
72
|
+
const joinRows = (await opts.db
|
|
73
|
+
.select({ src: srcCol, tgt: tgtCol })
|
|
74
|
+
.from(opts.junctionTable)
|
|
75
|
+
.where(joinWhere)) as Array<{ src: unknown; tgt: unknown }>;
|
|
76
|
+
|
|
77
|
+
// Collect the related target ids. Symmetric: the related endpoint is the
|
|
78
|
+
// column that is NOT the source id (compared by string key to bridge
|
|
79
|
+
// number/bigint-as-string driver skew). Otherwise: always the target column.
|
|
80
|
+
const sourceKey = String(sourceId);
|
|
81
|
+
const relatedIds = new Set<string | number>();
|
|
82
|
+
for (const r of joinRows) {
|
|
83
|
+
if (!opts.symmetric) {
|
|
84
|
+
addId(relatedIds, r.tgt);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const srcIsSource = r.src != null && String(r.src) === sourceKey;
|
|
88
|
+
// Self-loop (a,a): src matches → relate to a itself (single occurrence).
|
|
89
|
+
if (srcIsSource) addId(relatedIds, r.tgt);
|
|
90
|
+
else addId(relatedIds, r.src);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (relatedIds.size === 0) return [];
|
|
94
|
+
|
|
95
|
+
// Stage 2 — load the target rows.
|
|
96
|
+
const pkCol = columnRef(opts.targetTable, targetPk);
|
|
97
|
+
return await opts.db
|
|
98
|
+
.select()
|
|
99
|
+
.from(opts.targetTable)
|
|
100
|
+
.where(inArray(pkCol, [...relatedIds]));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function addId(set: Set<string | number>, v: unknown): void {
|
|
105
|
+
if (v === null || v === undefined) return;
|
|
106
|
+
if (typeof v === "number" || typeof v === "string") set.add(v);
|
|
107
|
+
else if (typeof v === "bigint") set.add(Number(v));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Resolve a physical column name to its Drizzle column object. Drizzle exposes
|
|
112
|
+
* columns on the table object keyed by the TS property name, but the underlying
|
|
113
|
+
* `.name` is the physical column. The descriptor carries physical names (what
|
|
114
|
+
* the junction/target SQL uses), so match on `.name` and fall back to the key.
|
|
115
|
+
*/
|
|
116
|
+
// biome-ignore lint/suspicious/noExplicitAny: returns a Drizzle column ref for the loose query builder
|
|
117
|
+
function columnRef(table: AnyTable, physicalName: string): any {
|
|
118
|
+
for (const key of Object.keys(table)) {
|
|
119
|
+
const col = table[key];
|
|
120
|
+
if (col && typeof col === "object" && col.name === physicalName) return col;
|
|
121
|
+
}
|
|
122
|
+
// Fall back to the property-name lookup (covers tables whose TS key == column).
|
|
123
|
+
const direct = table[physicalName];
|
|
124
|
+
if (direct !== undefined) return direct;
|
|
125
|
+
throw new Error(`mountM2mRoute: column '${physicalName}' not found on table`);
|
|
126
|
+
}
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* Shared utilities for drizzle-fastify route helpers.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/** Coerce a path-param id to number when numeric, else keep the string key. */
|
|
6
|
+
export function parseId(raw: string): number | string {
|
|
7
|
+
const n = Number(raw);
|
|
8
|
+
return Number.isFinite(n) && raw.trim() !== "" ? n : raw;
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
// Accepts "1" or boolean true (the qs serialization of withCount: 1 from buildFilterQs).
|
|
6
12
|
export function isTruthyFlag(v: unknown): boolean {
|
|
7
13
|
if (v === undefined || v === null) return false;
|
package/src/extract-object.ts
CHANGED
|
@@ -33,14 +33,11 @@ import {
|
|
|
33
33
|
FIELD_SUBTYPE_ENUM,
|
|
34
34
|
FIELD_SUBTYPE_OBJECT,
|
|
35
35
|
FIELD_SUBTYPE_STRING,
|
|
36
|
-
FIELD_SUBTYPE_CLASS,
|
|
37
36
|
FIELD_SUBTYPE_UUID,
|
|
38
37
|
FIELD_SUBTYPE_DATE,
|
|
39
38
|
FIELD_SUBTYPE_TIME,
|
|
40
39
|
FIELD_SUBTYPE_TIMESTAMP,
|
|
41
40
|
FIELD_SUBTYPE_INT,
|
|
42
|
-
FIELD_SUBTYPE_SHORT,
|
|
43
|
-
FIELD_SUBTYPE_BYTE,
|
|
44
41
|
FIELD_SUBTYPE_LONG,
|
|
45
42
|
FIELD_SUBTYPE_CURRENCY,
|
|
46
43
|
FIELD_SUBTYPE_DOUBLE,
|
|
@@ -54,6 +51,8 @@ import {
|
|
|
54
51
|
FIELD_ATTR_DEFAULT,
|
|
55
52
|
FIELD_ATTR_NORMALIZE,
|
|
56
53
|
FIELD_ATTR_OBJECT_REF,
|
|
54
|
+
FIELD_ATTR_XML_TEXT,
|
|
55
|
+
VALIDATOR_SUBTYPE_NUMERIC,
|
|
57
56
|
NORMALIZE_DEFAULT,
|
|
58
57
|
type NormalizeMode,
|
|
59
58
|
} from "@metaobjectsdev/metadata";
|
|
@@ -62,6 +61,8 @@ import {
|
|
|
62
61
|
Format,
|
|
63
62
|
FieldKind,
|
|
64
63
|
scalar,
|
|
64
|
+
range,
|
|
65
|
+
textContentField,
|
|
65
66
|
enumField,
|
|
66
67
|
enumArray,
|
|
67
68
|
object,
|
|
@@ -175,6 +176,18 @@ function fieldSpecFor(
|
|
|
175
176
|
// Scalar array: the engine coerces each element; no per-element default fill.
|
|
176
177
|
return scalarArray(name, kind, required);
|
|
177
178
|
}
|
|
179
|
+
// @xmlText: a (non-array) scalar field marked to receive its element's XML text content.
|
|
180
|
+
if (ownAttrString(field, FIELD_ATTR_XML_TEXT) === "true") {
|
|
181
|
+
return textContentField(name, kind, required);
|
|
182
|
+
}
|
|
183
|
+
// Numeric range: source the bound from the field's numeric validator (@min/@max) — the single
|
|
184
|
+
// source of truth — so the engine clamps (lenient) / rejects (strict) out-of-range values.
|
|
185
|
+
if (kind === FieldKind.INT || kind === FieldKind.LONG || kind === FieldKind.DOUBLE) {
|
|
186
|
+
const numeric = field.validators().find((v) => v.subType === VALIDATOR_SUBTYPE_NUMERIC);
|
|
187
|
+
if (numeric !== undefined && (numeric.min !== undefined || numeric.max !== undefined)) {
|
|
188
|
+
return range(name, kind, required, numeric.min ?? null, numeric.max ?? null);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
178
191
|
return scalar(name, kind, required, dv);
|
|
179
192
|
}
|
|
180
193
|
|
|
@@ -267,7 +280,6 @@ function scalarArray(name: string, kind: FieldKind, required: boolean): FieldSpe
|
|
|
267
280
|
function scalarKind(subType: string): FieldKind {
|
|
268
281
|
switch (subType) {
|
|
269
282
|
case FIELD_SUBTYPE_STRING:
|
|
270
|
-
case FIELD_SUBTYPE_CLASS:
|
|
271
283
|
case FIELD_SUBTYPE_UUID:
|
|
272
284
|
case FIELD_SUBTYPE_DATE:
|
|
273
285
|
case FIELD_SUBTYPE_TIME:
|
|
@@ -277,8 +289,6 @@ function scalarKind(subType: string): FieldKind {
|
|
|
277
289
|
case FIELD_SUBTYPE_DECIMAL:
|
|
278
290
|
return FieldKind.STRING;
|
|
279
291
|
case FIELD_SUBTYPE_INT:
|
|
280
|
-
case FIELD_SUBTYPE_SHORT:
|
|
281
|
-
case FIELD_SUBTYPE_BYTE:
|
|
282
292
|
return FieldKind.INT;
|
|
283
293
|
case FIELD_SUBTYPE_LONG:
|
|
284
294
|
case FIELD_SUBTYPE_CURRENCY:
|
package/src/identity-strategy.ts
CHANGED
|
@@ -17,7 +17,8 @@ export type IdentityResolution =
|
|
|
17
17
|
| { kind: "preset"; values: Record<string, unknown> };
|
|
18
18
|
|
|
19
19
|
export function resolveIdentity(entity: MetaData, data: Record<string, unknown>): IdentityResolution {
|
|
20
|
-
|
|
20
|
+
// Effective children so a TPH subtype resolves the inherited primary identity.
|
|
21
|
+
const primary = entity.children().find(
|
|
21
22
|
(c) => c.type === TYPE_IDENTITY && c.subType === IDENTITY_SUBTYPE_PRIMARY,
|
|
22
23
|
);
|
|
23
24
|
if (!primary) {
|
package/src/index.ts
CHANGED
|
@@ -32,3 +32,10 @@ export {
|
|
|
32
32
|
assemble,
|
|
33
33
|
MAX_NEST_DEPTH,
|
|
34
34
|
} from "./extract-object.js";
|
|
35
|
+
|
|
36
|
+
// LLM call recorder seam + parse-then-persist helper.
|
|
37
|
+
// Format is re-exported here so callers of recordLlmCall can import it from a
|
|
38
|
+
// single location rather than reaching into @metaobjectsdev/render directly.
|
|
39
|
+
export { Format } from "@metaobjectsdev/render";
|
|
40
|
+
export { LlmCallDbRecorder, NullRecorder, recordLlmCall, buildLlmCallRow, persistLlmCallRow, truncateRow } from "./llm-recorder.js";
|
|
41
|
+
export type { LlmRecorder, LlmCallRow, LlmCallInput, RecordLlmCallOptions, RecordLlmCallResult, LlmCallDbRecorderOpts } from "./llm-recorder.js";
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// LLM call recorder seam + base-row factory + shared persist helper.
|
|
2
|
+
//
|
|
3
|
+
// LlmRecorder is a thin write-side interface for persisting LLM call rows.
|
|
4
|
+
// LlmCallDbRecorder writes via ObjectManager.create (using an entity declared
|
|
5
|
+
// in the caller's own metadata). NullRecorder is the no-op implementation
|
|
6
|
+
// used in unit tests or when tracing is disabled.
|
|
7
|
+
//
|
|
8
|
+
// recordLlmCall is the GENERIC trace path: it builds the base trace row (the
|
|
9
|
+
// envelope + the raw `llmRequest`/`llmResponse` columns declared on the shipped
|
|
10
|
+
// `LlmCallBase`) and persists it. It does NOT parse the response into a typed
|
|
11
|
+
// VO — that extract step + the typed voRequest/voResponse columns live on the
|
|
12
|
+
// generated typed helper (a later layer), so this generic path only ever writes
|
|
13
|
+
// the base field set.
|
|
14
|
+
|
|
15
|
+
import type { ObjectManager } from "./object-manager.js";
|
|
16
|
+
|
|
17
|
+
// =============================================================================
|
|
18
|
+
// Public types
|
|
19
|
+
// =============================================================================
|
|
20
|
+
|
|
21
|
+
export type LlmCallRow = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
export interface LlmRecorder {
|
|
24
|
+
record(call: LlmCallRow): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// NullRecorder — no-op (testing / disabled tracing)
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
export class NullRecorder implements LlmRecorder {
|
|
32
|
+
async record(_call: LlmCallRow): Promise<void> {
|
|
33
|
+
// deliberate no-op
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// =============================================================================
|
|
38
|
+
// LlmCallDbRecorder — persists via ObjectManager
|
|
39
|
+
// =============================================================================
|
|
40
|
+
|
|
41
|
+
export interface LlmCallDbRecorderOpts {
|
|
42
|
+
/** Called when om.create throws. Default: swallow. Telemetry never breaks the app. */
|
|
43
|
+
onError?: (error: unknown) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class LlmCallDbRecorder implements LlmRecorder {
|
|
47
|
+
private readonly om: ObjectManager;
|
|
48
|
+
private readonly entityName: string;
|
|
49
|
+
private readonly onError: (error: unknown) => void;
|
|
50
|
+
|
|
51
|
+
constructor(om: ObjectManager, entityName: string, opts?: LlmCallDbRecorderOpts) {
|
|
52
|
+
this.om = om;
|
|
53
|
+
this.entityName = entityName;
|
|
54
|
+
this.onError = opts?.onError ?? (() => {});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async record(call: LlmCallRow): Promise<void> {
|
|
58
|
+
try {
|
|
59
|
+
await this.om.create(this.entityName, call);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
this.onError(err);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// =============================================================================
|
|
67
|
+
// recordLlmCall — generic base-row persist
|
|
68
|
+
// =============================================================================
|
|
69
|
+
|
|
70
|
+
export interface LlmCallInput {
|
|
71
|
+
spanId: string;
|
|
72
|
+
traceId: string;
|
|
73
|
+
/** Parent span id; null/absent → this is a root span. */
|
|
74
|
+
parentSpanId?: string;
|
|
75
|
+
/** Logical session/conversation id (gen_ai session grouping). */
|
|
76
|
+
sessionId?: string;
|
|
77
|
+
callType: string;
|
|
78
|
+
/** gen_ai.system — provider name, caller-supplied. */
|
|
79
|
+
system?: string;
|
|
80
|
+
/** ISO 8601 timestamp, supplied by the caller before the LLM call was made. */
|
|
81
|
+
startedAt: string;
|
|
82
|
+
llmRequest: unknown;
|
|
83
|
+
/** Raw response text/body — stored as the raw `llmResponse` column. */
|
|
84
|
+
llmResponseText: string;
|
|
85
|
+
requestModel?: string;
|
|
86
|
+
/** gen_ai.response.model — the model the provider actually used. */
|
|
87
|
+
responseModel?: string;
|
|
88
|
+
inputTokens?: number;
|
|
89
|
+
outputTokens?: number;
|
|
90
|
+
costMinor?: number;
|
|
91
|
+
latencyMs?: number;
|
|
92
|
+
finishReason?: string;
|
|
93
|
+
/** Call outcome, caller-supplied (provider/parse failure → "error"). */
|
|
94
|
+
status: "ok" | "error";
|
|
95
|
+
/** Failure detail (null on success). */
|
|
96
|
+
errorDetail: string | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface RecordLlmCallOptions {
|
|
100
|
+
recorder: LlmRecorder;
|
|
101
|
+
/** Optional scrub/cap applied immediately before persist (PII/secrets). */
|
|
102
|
+
redact?: (row: LlmCallRow) => LlmCallRow;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RecordLlmCallResult {
|
|
106
|
+
status: "ok" | "error";
|
|
107
|
+
errorDetail: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Build the base trace row (envelope + raw llmRequest/llmResponse) — key set
|
|
111
|
+
* is exactly LlmCallBase's fields. Typed voRequest/voResponse are added by the
|
|
112
|
+
* generated typed helper, never here. */
|
|
113
|
+
export function buildLlmCallRow(input: LlmCallInput): LlmCallRow {
|
|
114
|
+
return {
|
|
115
|
+
traceId: input.traceId,
|
|
116
|
+
spanId: input.spanId,
|
|
117
|
+
parentSpanId: input.parentSpanId ?? null,
|
|
118
|
+
sessionId: input.sessionId ?? null,
|
|
119
|
+
callType: input.callType,
|
|
120
|
+
system: input.system ?? null,
|
|
121
|
+
requestModel: input.requestModel ?? null,
|
|
122
|
+
responseModel: input.responseModel ?? null,
|
|
123
|
+
inputTokens: input.inputTokens ?? null,
|
|
124
|
+
outputTokens: input.outputTokens ?? null,
|
|
125
|
+
costMinor: input.costMinor ?? null,
|
|
126
|
+
latencyMs: input.latencyMs ?? null,
|
|
127
|
+
finishReason: input.finishReason ?? null,
|
|
128
|
+
status: input.status,
|
|
129
|
+
errorDetail: input.errorDetail,
|
|
130
|
+
startedAt: input.startedAt,
|
|
131
|
+
llmRequest: JSON.stringify(input.llmRequest),
|
|
132
|
+
llmResponse: JSON.stringify(input.llmResponseText),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Shared persist step: redact then record. Used by recordLlmCall AND (later) the
|
|
137
|
+
* generated typed helper, so redaction applies on both paths. */
|
|
138
|
+
export async function persistLlmCallRow(
|
|
139
|
+
recorder: LlmRecorder,
|
|
140
|
+
row: LlmCallRow,
|
|
141
|
+
opts?: { redact?: (row: LlmCallRow) => LlmCallRow },
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
await recorder.record(opts?.redact ? opts.redact(row) : row);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Cap the raw `llmRequest`/`llmResponse` string columns to `maxChars`.
|
|
147
|
+
* Adopters compose this into a `redact` to bound trace-row size. Only the two
|
|
148
|
+
* raw string columns are touched; all other fields pass through unchanged. */
|
|
149
|
+
export function truncateRow(row: LlmCallRow, maxChars: number): LlmCallRow {
|
|
150
|
+
const cap = (v: unknown): unknown =>
|
|
151
|
+
typeof v === "string" && v.length > maxChars ? v.slice(0, maxChars) : v;
|
|
152
|
+
return { ...row, llmRequest: cap(row.llmRequest), llmResponse: cap(row.llmResponse) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Persist one base trace row (envelope + raw I/O). Generic — does not extract. */
|
|
156
|
+
export async function recordLlmCall(
|
|
157
|
+
input: LlmCallInput,
|
|
158
|
+
opts: RecordLlmCallOptions,
|
|
159
|
+
): Promise<RecordLlmCallResult> {
|
|
160
|
+
await persistLlmCallRow(
|
|
161
|
+
opts.recorder,
|
|
162
|
+
buildLlmCallRow(input),
|
|
163
|
+
opts.redact ? { redact: opts.redact } : undefined,
|
|
164
|
+
);
|
|
165
|
+
return { status: input.status, errorDetail: input.errorDetail };
|
|
166
|
+
}
|