11import {
2- recordLlmCall ,
2+ buildLlmCallRow ,
3+ persistLlmCallRow ,
34 type LlmRecorder ,
45 type LlmCallInput ,
5- type RecordLlmCallOptions ,
6+ type LlmCallRow ,
67 type RecordLlmCallResult ,
7- type Format ,
88} from "@metaobjectsdev/runtime-ts" ;
9- import type { MetaObject } from "@metaobjectsdev/metadata" ;
109import {
1110 systemClock ,
1211 uuidIds ,
1312 type Clock ,
1413 type IdGen ,
1514 type LlmClient ,
1615 type LlmRequest ,
16+ type LlmCompletion ,
1717} from "./client.js" ;
1818import { builtinCost , type CostFn } from "./cost.js" ;
1919
20- export interface CallLlmInput {
21- /** Discriminator / call identity; defaults to the generated entity name . */
20+ export interface RunLlmCallInput {
21+ /** Discriminator / call identity. */
2222 callType : string ;
23- /** The typed request VO, passed through for call-site traceability. (Not
24- * persisted directly — `request` is what gets logged as llmRequest.) */
25- payload : unknown ;
26- /** What we send to the client (already-rendered prompt + model + params). */
23+ /** Already-rendered request sent to the client. */
2724 request : LlmRequest ;
2825 /** Existing trace to attach to; a new trace id is generated when absent. */
2926 traceId ?: string ;
30- /** Parent span id; absent → this is a root span. */
3127 parentSpanId ?: string ;
32- /** Logical session/conversation id. */
3328 sessionId ?: string ;
3429}
3530
36- export interface CallLlmDeps {
31+ export interface RunLlmCallDeps {
3732 client : LlmClient ;
38- recorder : LlmRecorder ;
39- /** MetaObject for the response VO (passed to extract, same as recordLlmCall). */
40- responseMo : MetaObject ;
41- format ?: Format ;
4233 cost ?: CostFn ;
4334 clock ?: Clock ;
4435 ids ?: IdGen ;
4536}
4637
38+ export interface RunLlmCallResult {
39+ /** Ready-to-persist BASE recorder input (envelope + raw I/O + status). */
40+ input : LlmCallInput ;
41+ /** Provider completion, or undefined if the client threw. */
42+ completion ?: LlmCompletion ;
43+ }
44+
4745/**
48- * CALL → PARSE → WRITE. The caller supplies the already-rendered prompt in
49- * `input.request.prompt`; callLlm performs the CALL, computes latency/cost/ids,
50- * then delegates PARSE+WRITE to recordLlmCall. Finally-style: a client throw
51- * still writes an error row and never rethrows into the caller .
46+ * Perform the LLM CALL: generate ids, time it, call the client (never-throw),
47+ * compute cost. Returns a ready base LlmCallInput + the completion. Does NOT
48+ * persist and does NOT extract — callers do that (callLlm persists the base row;
49+ * the generated call<Entity> additionally extracts + writes typed voRequest/voResponse) .
5250 */
53- export async function callLlm (
54- input : CallLlmInput ,
55- deps : CallLlmDeps ,
56- ) : Promise < RecordLlmCallResult > {
51+ export async function runLlmCall (
52+ input : RunLlmCallInput ,
53+ deps : RunLlmCallDeps ,
54+ ) : Promise < RunLlmCallResult > {
5755 const clock = deps . clock ?? systemClock ;
5856 const ids = deps . ids ?? uuidIds ;
5957 const cost = deps . cost ?? builtinCost ;
@@ -63,63 +61,64 @@ export async function callLlm(
6361 const t0 = clock . now ( ) ;
6462 const startedAt = new Date ( t0 ) . toISOString ( ) ;
6563
66- let completion : Awaited < ReturnType < LlmClient [ "complete" ] > > ;
64+ let completion : LlmCompletion | undefined ;
65+ let status : "ok" | "error" = "ok" ;
66+ let errorDetail : string | null = null ;
6767 try {
6868 completion = await deps . client . complete ( input . request ) ;
6969 } catch ( err ) {
70- const errorDetail = err instanceof Error ? err . message : String ( err ) ;
71- // Hand-built error row: the client never returned, so there is no response
72- // to extract — we cannot route through recordLlmCall. Its column set mirrors
73- // recordLlmCall's row (same keys, null-valued where the call produced nothing).
74- const row = {
75- spanId,
76- traceId,
77- parentSpanId : input . parentSpanId ?? null ,
78- sessionId : input . sessionId ?? null ,
79- callType : input . callType ,
80- requestModel : input . request . model ,
81- responseModel : null ,
82- inputTokens : null ,
83- outputTokens : null ,
84- costMinor : null ,
85- latencyMs : clock . now ( ) - t0 ,
86- finishReason : null ,
87- status : "error" as const ,
88- errorDetail,
89- startedAt,
90- llmRequest : JSON . stringify ( input . request ) ,
91- voResponse : null ,
92- } ;
93- await deps . recorder . record ( row ) ;
94- return { voResponse : null , status : "error" , errorDetail } ;
70+ status = "error" ;
71+ errorDetail = err instanceof Error ? err . message : String ( err ) ;
9572 }
73+ const latencyMs = clock . now ( ) - t0 ;
9674
97- // Build the recordLlmCall input with only the optional fields that are
98- // actually present — `exactOptionalPropertyTypes` forbids assigning an
99- // explicit `undefined` to an optional `T?` property.
75+ // exactOptionalPropertyTypes: assign optionals only when defined.
10076 const recInput : LlmCallInput = {
10177 spanId,
10278 traceId,
10379 callType : input . callType ,
10480 startedAt,
81+ latencyMs,
10582 requestModel : input . request . model ,
106- latencyMs : clock . now ( ) - t0 ,
107- llmRequest : completion . request ?? input . request ,
108- llmResponseText : completion . body ,
83+ llmRequest : completion ?. request ?? input . request ,
84+ llmResponseText : completion ?. body ?? "" ,
85+ status,
86+ errorDetail,
10987 } ;
88+ if ( input . request . system !== undefined ) recInput . system = input . request . system ;
11089 if ( input . parentSpanId !== undefined ) recInput . parentSpanId = input . parentSpanId ;
11190 if ( input . sessionId !== undefined ) recInput . sessionId = input . sessionId ;
112- if ( completion . model !== undefined ) recInput . responseModel = completion . model ;
113- if ( completion . usage ?. inputTokens !== undefined ) recInput . inputTokens = completion . usage . inputTokens ;
114- if ( completion . usage ?. outputTokens !== undefined ) recInput . outputTokens = completion . usage . outputTokens ;
115- const costMinor = cost ( completion . model ?? input . request . model , completion . usage ) ;
116- if ( costMinor !== null ) recInput . costMinor = costMinor ;
117- if ( completion . finishReason !== undefined ) recInput . finishReason = completion . finishReason ;
91+ if ( completion ?. model !== undefined ) recInput . responseModel = completion . model ;
92+ if ( completion ?. usage ?. inputTokens !== undefined ) recInput . inputTokens = completion . usage . inputTokens ;
93+ if ( completion ?. usage ?. outputTokens !== undefined ) recInput . outputTokens = completion . usage . outputTokens ;
94+ if ( completion ?. finishReason !== undefined ) recInput . finishReason = completion . finishReason ;
95+ if ( completion !== undefined ) {
96+ const c = cost ( completion . model ?? input . request . model , completion . usage ) ;
97+ if ( c !== null ) recInput . costMinor = c ;
98+ }
11899
119- const recOpts : RecordLlmCallOptions = {
120- recorder : deps . recorder ,
121- responseMo : deps . responseMo ,
122- } ;
123- if ( deps . format !== undefined ) recOpts . format = deps . format ;
124- return recordLlmCall ( recInput , recOpts ) ;
100+ return completion !== undefined ? { input : recInput , completion } : { input : recInput } ;
101+ }
102+
103+ export interface CallLlmDeps extends RunLlmCallDeps {
104+ recorder : LlmRecorder ;
105+ redact ?: ( row : LlmCallRow ) => LlmCallRow ;
106+ }
107+
108+ /**
109+ * Generic loop: CALL (runLlmCall) → persist the BASE row. No extract, no typed
110+ * voRequest/voResponse (that's the generated call<Entity>). Finally-style: a
111+ * client throw still persists an error row and never rethrows.
112+ */
113+ export async function callLlm (
114+ input : RunLlmCallInput ,
115+ deps : CallLlmDeps ,
116+ ) : Promise < RecordLlmCallResult > {
117+ const { input : recInput } = await runLlmCall ( input , deps ) ;
118+ await persistLlmCallRow (
119+ deps . recorder ,
120+ buildLlmCallRow ( recInput ) ,
121+ deps . redact ? { redact : deps . redact } : undefined ,
122+ ) ;
123+ return { status : recInput . status , errorDetail : recInput . errorDetail } ;
125124}
0 commit comments