Skip to content

Commit 718b111

Browse files
dmealingclaude
andcommitted
refactor(ai): factor runLlmCall (call, no persist); callLlm persists the generic base row
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 02288e6 commit 718b111

3 files changed

Lines changed: 159 additions & 124 deletions

File tree

Lines changed: 68 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,57 @@
11
import {
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";
109
import {
1110
systemClock,
1211
uuidIds,
1312
type Clock,
1413
type IdGen,
1514
type LlmClient,
1615
type LlmRequest,
16+
type LlmCompletion,
1717
} from "./client.js";
1818
import { 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
}

server/typescript/packages/ai-runtime/src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ export {
1414

1515
export { builtinCost, type CostFn } from "./cost.js";
1616

17-
export { callLlm, type CallLlmInput, type CallLlmDeps } from "./call-loop.js";
17+
export {
18+
callLlm,
19+
runLlmCall,
20+
type CallLlmDeps,
21+
type RunLlmCallInput,
22+
type RunLlmCallDeps,
23+
type RunLlmCallResult,
24+
} from "./call-loop.js";
1825

1926
export { CompositeRecorder, type CompositeRecorderOpts } from "./composite.js";

0 commit comments

Comments
 (0)