@gfrmin/credence-pi-openclaw 0.1.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/index.js ADDED
@@ -0,0 +1,258 @@
1
+ // index.ts — credence-pi OpenClaw-plugin body.
2
+ //
3
+ // Governs the pi/OpenClaw agent loop by intercepting tool calls and
4
+ // routing the decision to the credence-pi Julia daemon (the opaque
5
+ // brain). Reuses credence-pi's async wire UNCHANGED: POST /sensor (a
6
+ // tool-proposed sensor event) then await the correlated effector signal
7
+ // off the SSE /signals stream; map it to OpenClaw's before_tool_call
8
+ // result. Also logs tool outcomes and reconstructed per-turn cost so the
9
+ // observation log accumulates the data the dollars-saved surface (Move 2)
10
+ // needs.
11
+ //
12
+ // Discipline (matches the pi-extension body):
13
+ // - The BRAIN decides; the body only translates. ask -> requireApproval
14
+ // (OpenClaw enforces the user's choice natively); the body posts
15
+ // user-responded via onResolution so the brain learns, but does not
16
+ // itself decide proceed/block on the reply.
17
+ // - Fail-open everywhere: daemon unreachable / slow ⇒ the tool proceeds,
18
+ // with one warning per outage.
19
+ //
20
+ // The orchestration lives in `createGovernor`, separated from `register`
21
+ // so it can be unit-tested with an injected DaemonClient (see
22
+ // tests/index.test.ts).
23
+ import { randomUUID } from "node:crypto";
24
+ import { createDaemonClient, } from "./daemon-client.js";
25
+ import { FeatureTracker } from "./features.js";
26
+ import { buildPriceTable, computeTurnCost } from "./cost.js";
27
+ const DEFAULT_DAEMON_URL = "http://127.0.0.1:8787";
28
+ const DEFAULT_HOOK_TIMEOUT_MS = 3_000;
29
+ // How long OpenClaw waits for the human on an `ask` (requireApproval).
30
+ // Distinct from the daemon-decision timeout above.
31
+ const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000;
32
+ function newEventId() {
33
+ return `evt_${randomUUID().slice(0, 12)}`;
34
+ }
35
+ export function mapSignal(sig, originatingEventId, client, approvalTimeoutMs) {
36
+ if (!sig)
37
+ return undefined; // timeout / fail-open ⇒ proceed
38
+ const p = sig.parameters ?? {};
39
+ switch (sig.effector) {
40
+ case "proceed":
41
+ return undefined;
42
+ case "block":
43
+ return {
44
+ block: true,
45
+ blockReason: `credence-pi: ${typeof p.reason === "string"
46
+ ? p.reason
47
+ : "tool call vetoed by expected-utility calculation"}`,
48
+ };
49
+ case "ask": {
50
+ const description = typeof p.text === "string" ? p.text : "Confirm this tool call?";
51
+ const requireApproval = {
52
+ title: "credence-pi governance",
53
+ description,
54
+ severity: "warning",
55
+ timeoutMs: approvalTimeoutMs,
56
+ timeoutBehavior: "deny",
57
+ allowedDecisions: ["allow-once", "allow-always", "deny"],
58
+ onResolution: async (decision) => {
59
+ const response = decision === "allow-once" || decision === "allow-always"
60
+ ? "yes"
61
+ : decision === "deny"
62
+ ? "no"
63
+ : "timeout";
64
+ // Fire-and-forget: the brain conditions on the reply; OpenClaw
65
+ // has already enforced the decision. The daemon's follow-up
66
+ // proceed/block signal is unneeded here and harmlessly dropped
67
+ // (no awaiter).
68
+ await client.postSensor({
69
+ event_type: "user-responded",
70
+ event_id: newEventId(),
71
+ in_response_to: originatingEventId,
72
+ timestamp: new Date().toISOString(),
73
+ response,
74
+ });
75
+ },
76
+ };
77
+ return { requireApproval };
78
+ }
79
+ default:
80
+ return undefined; // unknown effector ⇒ fail open
81
+ }
82
+ }
83
+ // The governance orchestration over an injected DaemonClient. register()
84
+ // wires this to the OpenClaw hook API; tests drive it with a fake client.
85
+ export function createGovernor(client, opts) {
86
+ const { hookTimeoutMs, approvalTimeoutMs, priceTable, redactToolInputs, log } = opts;
87
+ const tracker = new FeatureTracker();
88
+ // event_id -> resolver for the awaited effector signal. The single SSE
89
+ // consumer dispatches signals here by in_response_to. Unmatched signals
90
+ // (e.g. an ask-followup after the hook already resolved) find no resolver
91
+ // and are dropped.
92
+ const awaiters = new Map();
93
+ const sse = client.connectSignalsStream((sig) => {
94
+ const resolve = awaiters.get(sig.in_response_to);
95
+ if (resolve)
96
+ resolve(sig);
97
+ });
98
+ let warnedDown = false;
99
+ let announcedUp = false;
100
+ let down = false;
101
+ async function beforeToolCall(event, ctx) {
102
+ const eventId = newEventId();
103
+ const signalPromise = new Promise((resolve) => {
104
+ const timer = setTimeout(() => {
105
+ awaiters.delete(eventId);
106
+ resolve(undefined);
107
+ }, hookTimeoutMs);
108
+ awaiters.set(eventId, (sig) => {
109
+ clearTimeout(timer);
110
+ awaiters.delete(eventId);
111
+ resolve(sig);
112
+ });
113
+ });
114
+ const features = tracker.extractAndRecord(event, ctx);
115
+ const post = await client.postSensor({
116
+ event_type: "tool-proposed",
117
+ event_id: eventId,
118
+ session_id: ctx.sessionId ?? ctx.sessionKey ?? "",
119
+ timestamp: new Date().toISOString(),
120
+ features,
121
+ // Tool inputs can carry secrets (commands, tokens). Operators may
122
+ // redact them; the brain does not condition on input (Move 1), only
123
+ // the daemon's ask-text preview uses it.
124
+ proposed_call: {
125
+ tool_name: event.toolName,
126
+ input: redactToolInputs ? null : event.params,
127
+ },
128
+ });
129
+ if (!post.ok) {
130
+ const r = awaiters.get(eventId);
131
+ if (r)
132
+ r(undefined); // clean up timer + awaiter
133
+ if (!warnedDown) {
134
+ log(`credence-pi: daemon unreachable at the configured URL; proceeding without governance`);
135
+ warnedDown = true;
136
+ }
137
+ down = true;
138
+ announcedUp = false;
139
+ return undefined; // fail open
140
+ }
141
+ if (down && !announcedUp) {
142
+ log("credence-pi: daemon reachable again; governance resumed");
143
+ announcedUp = true;
144
+ down = false;
145
+ warnedDown = false; // re-arm the unreachable warning for the next outage
146
+ }
147
+ const sig = await signalPromise;
148
+ return mapSignal(sig, eventId, client, approvalTimeoutMs);
149
+ }
150
+ async function afterToolCall(event, _ctx) {
151
+ // Correlate by the stable toolCallId (tools run in parallel).
152
+ await client.postSensor({
153
+ event_type: "tool-completed",
154
+ event_id: newEventId(),
155
+ in_response_to: event.toolCallId ?? "",
156
+ timestamp: new Date().toISOString(),
157
+ outcome: {
158
+ success: event.error == null,
159
+ duration_ms: event.durationMs ?? null,
160
+ result_summary: null,
161
+ error: event.error ?? null,
162
+ },
163
+ });
164
+ }
165
+ async function llmOutput(event, ctx) {
166
+ const tc = computeTurnCost(event, priceTable);
167
+ await client.postSensor({
168
+ event_type: "turn-cost",
169
+ event_id: newEventId(),
170
+ session_id: ctx.sessionId ?? event.sessionId ?? "",
171
+ timestamp: new Date().toISOString(),
172
+ usd: tc.usd,
173
+ total_tokens: tc.total_tokens,
174
+ input_tokens: tc.input_tokens,
175
+ output_tokens: tc.output_tokens,
176
+ cache_read: tc.cache_read,
177
+ cache_write: tc.cache_write,
178
+ model: tc.model,
179
+ });
180
+ }
181
+ function cleanup() {
182
+ sse.close();
183
+ for (const resolve of awaiters.values())
184
+ resolve(undefined);
185
+ awaiters.clear();
186
+ }
187
+ return {
188
+ beforeToolCall,
189
+ afterToolCall,
190
+ llmOutput,
191
+ cleanup,
192
+ pendingCount: () => awaiters.size,
193
+ };
194
+ }
195
+ const plugin = {
196
+ id: "credence-pi",
197
+ name: "credence-pi governance",
198
+ description: "Bayesian in-loop governance for the pi/OpenClaw agent — intercepts tool calls (allow/block/ask) via the credence-pi brain and logs outcomes + per-turn cost.",
199
+ register(api) {
200
+ const cfg = api.pluginConfig ?? {};
201
+ const daemonUrl = typeof cfg.daemonUrl === "string" ? cfg.daemonUrl : DEFAULT_DAEMON_URL;
202
+ const hookTimeoutMs = typeof cfg.hookTimeoutMs === "number"
203
+ ? cfg.hookTimeoutMs
204
+ : DEFAULT_HOOK_TIMEOUT_MS;
205
+ const approvalTimeoutMs = typeof cfg.approvalTimeoutMs === "number"
206
+ ? cfg.approvalTimeoutMs
207
+ : DEFAULT_APPROVAL_TIMEOUT_MS;
208
+ const silent = cfg.silent === true;
209
+ const redactToolInputs = cfg.redactToolInputs === true;
210
+ const priceTable = buildPriceTable(cfg.pricing);
211
+ const log = (m, e) => {
212
+ if (silent)
213
+ return;
214
+ const msg = e === undefined ? m : `${m} ${String(e)}`;
215
+ api.logger?.warn?.(msg);
216
+ };
217
+ const client = createDaemonClient({
218
+ baseUrl: daemonUrl,
219
+ timeoutMs: hookTimeoutMs,
220
+ logger: log,
221
+ });
222
+ const gov = createGovernor(client, {
223
+ hookTimeoutMs,
224
+ approvalTimeoutMs,
225
+ priceTable,
226
+ redactToolInputs,
227
+ log,
228
+ });
229
+ api.on("before_tool_call", gov.beforeToolCall, {
230
+ priority: 100,
231
+ timeoutMs: hookTimeoutMs + 1_000,
232
+ });
233
+ api.on("after_tool_call", gov.afterToolCall);
234
+ // Per-turn cost REQUIRES plugins.entries.credence-pi.hooks
235
+ // .allowConversationAccess: true. Wrapped so a blocked registration
236
+ // never breaks governance — cost is just absent.
237
+ try {
238
+ api.on("llm_output", gov.llmOutput);
239
+ }
240
+ catch (err) {
241
+ log("credence-pi: llm_output hook unavailable (set hooks.allowConversationAccess:true for the cost signal)", err);
242
+ }
243
+ // Close the SSE stream + drain awaiters on reset/delete/reload so a
244
+ // hot-reload does not accumulate daemon connections. Optional-chained
245
+ // for hosts predating the lifecycle API.
246
+ try {
247
+ api.lifecycle?.registerRuntimeLifecycle?.({
248
+ id: "credence-pi-governor",
249
+ description: "Close the credence-pi daemon SSE stream and drain pending tool-call awaiters.",
250
+ cleanup: () => gov.cleanup(),
251
+ });
252
+ }
253
+ catch (err) {
254
+ log("credence-pi: could not register lifecycle cleanup", err);
255
+ }
256
+ },
257
+ };
258
+ export default plugin;
@@ -0,0 +1,96 @@
1
+ export type PluginApprovalResolution = "allow-once" | "allow-always" | "deny" | "timeout" | "cancelled";
2
+ export interface RequireApprovalPayload {
3
+ title: string;
4
+ description: string;
5
+ severity?: "info" | "warning" | "critical";
6
+ timeoutMs?: number;
7
+ timeoutBehavior?: "allow" | "deny";
8
+ allowedDecisions?: Array<"allow-once" | "allow-always" | "deny">;
9
+ pluginId?: string;
10
+ onResolution?: (decision: PluginApprovalResolution) => Promise<void> | void;
11
+ }
12
+ export interface BeforeToolCallEvent {
13
+ toolName: string;
14
+ params: Record<string, unknown>;
15
+ toolKind?: string;
16
+ toolInputKind?: string;
17
+ runId?: string;
18
+ toolCallId?: string;
19
+ derivedPaths?: readonly string[];
20
+ }
21
+ export interface BeforeToolCallResult {
22
+ params?: Record<string, unknown>;
23
+ block?: boolean;
24
+ blockReason?: string;
25
+ requireApproval?: RequireApprovalPayload;
26
+ }
27
+ export interface AfterToolCallEvent {
28
+ toolName: string;
29
+ params: Record<string, unknown>;
30
+ runId?: string;
31
+ toolCallId?: string;
32
+ result?: unknown;
33
+ error?: string;
34
+ durationMs?: number;
35
+ }
36
+ export interface LlmUsage {
37
+ input?: number;
38
+ output?: number;
39
+ cacheRead?: number;
40
+ cacheWrite?: number;
41
+ total?: number;
42
+ }
43
+ export interface LlmOutputEvent {
44
+ runId?: string;
45
+ sessionId?: string;
46
+ provider?: string;
47
+ model?: string;
48
+ resolvedRef?: string;
49
+ usage?: LlmUsage;
50
+ }
51
+ export interface ToolContext {
52
+ agentId?: string;
53
+ sessionKey?: string;
54
+ sessionId?: string;
55
+ runId?: string;
56
+ toolName?: string;
57
+ toolCallId?: string;
58
+ channelId?: string;
59
+ workspaceDir?: string;
60
+ }
61
+ export interface PluginLogger {
62
+ info?: (msg: string) => void;
63
+ warn?: (msg: string) => void;
64
+ error?: (msg: string) => void;
65
+ }
66
+ export type HookHandler<E, R = void> = (event: E, ctx: ToolContext) => Promise<R | void> | R | void;
67
+ export interface HookOpts {
68
+ priority?: number;
69
+ timeoutMs?: number;
70
+ }
71
+ export interface OpenClawPluginApi {
72
+ id?: string;
73
+ logger?: PluginLogger;
74
+ pluginConfig?: Record<string, unknown>;
75
+ lifecycle?: {
76
+ registerRuntimeLifecycle: (registration: {
77
+ id: string;
78
+ description?: string;
79
+ cleanup?: (ctx: {
80
+ reason?: string;
81
+ sessionKey?: string;
82
+ runId?: string;
83
+ }) => void | Promise<void>;
84
+ }) => void;
85
+ };
86
+ on(hook: "before_tool_call", handler: HookHandler<BeforeToolCallEvent, BeforeToolCallResult>, opts?: HookOpts): void;
87
+ on(hook: "after_tool_call", handler: HookHandler<AfterToolCallEvent, void>, opts?: HookOpts): void;
88
+ on(hook: "llm_output", handler: HookHandler<LlmOutputEvent, void>, opts?: HookOpts): void;
89
+ on(hook: string, handler: (...args: unknown[]) => unknown, opts?: HookOpts): void;
90
+ }
91
+ export interface PluginEntry {
92
+ id: string;
93
+ name: string;
94
+ description?: string;
95
+ register: (api: OpenClawPluginApi) => void | Promise<void>;
96
+ }
@@ -0,0 +1,15 @@
1
+ // openclaw-types.ts — minimal local declarations of the OpenClaw plugin
2
+ // API surface this body consumes.
3
+ //
4
+ // Sourced from OpenClaw (HEAD 36a596aa9f, 2026-06-02):
5
+ // - src/plugins/types.ts (OpenClawPluginApi, api.on)
6
+ // - src/plugins/hook-types.ts (before_tool_call / after_tool_call /
7
+ // llm_output events + results)
8
+ //
9
+ // We declare ONLY what we consume so the plugin builds standalone with no
10
+ // @openclaw/openclaw dependency — mirroring the dependency-free pattern of
11
+ // apps/openclaw-plugin/ (which used `api: any`). At runtime OpenClaw calls
12
+ // register(api) with the real, fully-typed api; these declarations are our
13
+ // compile-time view. If the host surface drifts, the runtime still works;
14
+ // only this file needs occasional resync. Keep it narrow.
15
+ export {};
@@ -0,0 +1,45 @@
1
+ {
2
+ "id": "credence-pi",
3
+ "version": "0.1.0",
4
+ "name": "credence-pi governance",
5
+ "description": "Bayesian in-loop governance for the pi/OpenClaw agent — intercepts tool calls and halts/asks on low expected-value spend; logs outcomes and per-turn cost for the dollars-saved surface.",
6
+ "activation": {
7
+ "onStartup": true
8
+ },
9
+ "configSchema": {
10
+ "type": "object",
11
+ "additionalProperties": false,
12
+ "properties": {
13
+ "daemonUrl": {
14
+ "type": "string",
15
+ "description": "Base URL of the credence-pi Julia daemon (POST /sensor, GET /signals).",
16
+ "default": "http://127.0.0.1:8787"
17
+ },
18
+ "hookTimeoutMs": {
19
+ "type": "number",
20
+ "description": "Max time to await the daemon's decision before failing open (proceeding without governance).",
21
+ "default": 3000
22
+ },
23
+ "approvalTimeoutMs": {
24
+ "type": "number",
25
+ "description": "How long OpenClaw waits for the user on an `ask` (requireApproval) before applying timeoutBehavior=deny.",
26
+ "default": 120000
27
+ },
28
+ "redactToolInputs": {
29
+ "type": "boolean",
30
+ "description": "Omit tool-call inputs from sensor events sent to the daemon (they can carry secrets/commands). Governance still works; the ask-dialog preview becomes generic.",
31
+ "default": false
32
+ },
33
+ "silent": {
34
+ "type": "boolean",
35
+ "description": "Suppress info/warn logging.",
36
+ "default": false
37
+ },
38
+ "pricing": {
39
+ "type": "object",
40
+ "description": "Optional per-model USD price overrides in $/million tokens: { \"<model-id>\": { \"input\": <num>, \"output\": <num>, \"cacheRead\": <num>, \"cacheWrite\": <num> } }. Merged over the built-in table.",
41
+ "additionalProperties": true
42
+ }
43
+ }
44
+ }
45
+ }
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@gfrmin/credence-pi-openclaw",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "credence-pi body: OpenClaw plugin that governs tool calls via the credence-pi Bayesian brain (before_tool_call -> allow/block/ask) and logs outcomes + per-turn cost.",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "license": "AGPL-3.0-or-later",
9
+ "author": "Guy Freeman",
10
+ "homepage": "https://github.com/gfrmin/credence/tree/master/apps/credence-pi",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/gfrmin/credence.git",
14
+ "directory": "apps/credence-pi/openclaw-plugin"
15
+ },
16
+ "keywords": [
17
+ "openclaw",
18
+ "openclaw-plugin",
19
+ "ai-agent",
20
+ "governance",
21
+ "bayesian",
22
+ "tool-call",
23
+ "llm-cost",
24
+ "expected-utility"
25
+ ],
26
+ "files": [
27
+ "dist",
28
+ "src",
29
+ "openclaw.plugin.json",
30
+ "README.md"
31
+ ],
32
+ "openclaw": {
33
+ "extensions": ["./src/index.ts"],
34
+ "runtimeExtensions": ["./dist/index.js"],
35
+ "compat": {
36
+ "pluginApi": ">=2026.3.24-beta.2",
37
+ "minGatewayVersion": "2026.3.24-beta.2"
38
+ },
39
+ "build": {
40
+ "openclawVersion": "2026.6.2",
41
+ "pluginSdkVersion": "2026.3.24-beta.2"
42
+ }
43
+ },
44
+ "scripts": {
45
+ "build": "tsc",
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "node --import tsx --test tests/*.test.ts"
48
+ },
49
+ "dependencies": {},
50
+ "devDependencies": {
51
+ "typescript": "^5.5.0",
52
+ "@types/node": "^22.0.0",
53
+ "tsx": "^4.19.0"
54
+ },
55
+ "engines": {
56
+ "node": ">=22"
57
+ }
58
+ }
package/src/cost.ts ADDED
@@ -0,0 +1,130 @@
1
+ // cost.ts — reconstruct per-turn USD from llm_output token counts.
2
+ //
3
+ // OpenClaw does not hand plugins a dollar figure (only token counts +
4
+ // model id on llm_output). The host computes USD internally via
5
+ // calculateCost(model, usage), but does not expose it to plugins, and we
6
+ // stay dependency-free (no `openclaw` runtime import). So we reconstruct
7
+ // USD from a small, CONFIG-OVERRIDABLE price table (USD per million
8
+ // tokens). The built-in numbers are approximate defaults — the operator
9
+ // should verify/override via the plugin's `pricing` config for an exact
10
+ // dollars-saved figure. When a model can't be priced, usd is null and the
11
+ // Move-2 surface falls back to token counts.
12
+
13
+ import type { LlmOutputEvent } from "./openclaw-types.js";
14
+
15
+ export interface ModelPrice {
16
+ input: number; // USD / million input tokens
17
+ output: number; // USD / million output tokens
18
+ cacheRead?: number; // USD / million cache-read tokens
19
+ cacheWrite?: number; // USD / million cache-write tokens
20
+ }
21
+
22
+ export type PriceTable = Record<string, ModelPrice>;
23
+
24
+ // Built-in defaults, keyed by a lowercase family substring matched against
25
+ // the model id. Approximate; override via config `pricing`. Ordered by
26
+ // specificity is not required — we pick the longest matching key.
27
+ export const DEFAULT_PRICES: PriceTable = {
28
+ "claude-opus": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
29
+ "claude-sonnet": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
30
+ "claude-haiku": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 },
31
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
32
+ "gpt-4o": { input: 2.5, output: 10 },
33
+ "gpt-4.1": { input: 2, output: 8 },
34
+ "o3": { input: 2, output: 8 },
35
+ "gemini-2.5-pro": { input: 1.25, output: 10 },
36
+ "gemini-2.5-flash": { input: 0.3, output: 2.5 },
37
+ };
38
+
39
+ export interface TurnCost {
40
+ usd: number | null;
41
+ total_tokens: number | null;
42
+ input_tokens: number | null;
43
+ output_tokens: number | null;
44
+ cache_read: number | null;
45
+ cache_write: number | null;
46
+ model: string | null;
47
+ }
48
+
49
+ function escapeRegExp(s: string): string {
50
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ }
52
+
53
+ function resolvePrice(
54
+ model: string | undefined,
55
+ table: PriceTable,
56
+ ): ModelPrice | undefined {
57
+ if (!model) return undefined;
58
+ const m = model.toLowerCase();
59
+ // Exact match first.
60
+ if (table[m]) return table[m];
61
+ // Longest key that appears as a SEGMENT of the id — anchored at the
62
+ // start or after a non-alphanumeric separator. So "o3" matches
63
+ // "o3-mini" and "openai/o3" but NOT "foo3"; longest match wins.
64
+ let best: { key: string; price: ModelPrice } | undefined;
65
+ for (const [key, price] of Object.entries(table)) {
66
+ const re = new RegExp(`(^|[^a-z0-9])${escapeRegExp(key)}`);
67
+ if (re.test(m) && (best === undefined || key.length > best.key.length)) {
68
+ best = { key, price };
69
+ }
70
+ }
71
+ return best?.price;
72
+ }
73
+
74
+ /** Merge operator `pricing` config over the built-in table. */
75
+ export function buildPriceTable(overrides: unknown): PriceTable {
76
+ const table: PriceTable = { ...DEFAULT_PRICES };
77
+ if (overrides && typeof overrides === "object") {
78
+ for (const [k, v] of Object.entries(overrides as Record<string, unknown>)) {
79
+ if (v && typeof v === "object") {
80
+ const o = v as Record<string, unknown>;
81
+ const input = typeof o.input === "number" ? o.input : undefined;
82
+ const output = typeof o.output === "number" ? o.output : undefined;
83
+ if (input !== undefined && output !== undefined) {
84
+ table[k.toLowerCase()] = {
85
+ input,
86
+ output,
87
+ cacheRead: typeof o.cacheRead === "number" ? o.cacheRead : undefined,
88
+ cacheWrite:
89
+ typeof o.cacheWrite === "number" ? o.cacheWrite : undefined,
90
+ };
91
+ }
92
+ }
93
+ }
94
+ }
95
+ return table;
96
+ }
97
+
98
+ export function computeTurnCost(
99
+ event: LlmOutputEvent,
100
+ table: PriceTable,
101
+ ): TurnCost {
102
+ const u = event.usage ?? {};
103
+ const input = u.input ?? 0;
104
+ const output = u.output ?? 0;
105
+ const cacheRead = u.cacheRead ?? 0;
106
+ const cacheWrite = u.cacheWrite ?? 0;
107
+ const total = u.total ?? input + output + cacheRead + cacheWrite;
108
+ const model = event.model ?? null;
109
+
110
+ const price = resolvePrice(event.model, table);
111
+ let usd: number | null = null;
112
+ if (price) {
113
+ usd =
114
+ (input * price.input +
115
+ output * price.output +
116
+ cacheRead * (price.cacheRead ?? price.input) +
117
+ cacheWrite * (price.cacheWrite ?? price.input)) /
118
+ 1_000_000;
119
+ }
120
+
121
+ return {
122
+ usd,
123
+ total_tokens: total,
124
+ input_tokens: input,
125
+ output_tokens: output,
126
+ cache_read: cacheRead,
127
+ cache_write: cacheWrite,
128
+ model,
129
+ };
130
+ }