pi-jev-guard 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/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "pi-jev-guard",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Jev validation guard for pi: on-demand tool + automatic provider gate. Dual backend: TypeSafe direct + OpenRouter.",
6
+ "keywords": ["pi-package"],
7
+ "files": ["extensions", "src", "skills", "config"],
8
+ "pi": {
9
+ "extensions": ["./extensions/index.ts"],
10
+ "skills": ["./skills"]
11
+ },
12
+ "scripts": {
13
+ "typecheck": "tsc --noEmit",
14
+ "test:unit": "vitest run test/unit",
15
+ "test:integration": "vitest run test/integration",
16
+ "bench:jev": "tsx scripts/bench-jev.ts",
17
+ "jev-check": "tsx scripts/jev-check.ts"
18
+ },
19
+ "dependencies": {
20
+ "@openrouter/sdk": "1.3.1",
21
+ "@typesafe-ai/sdk": "0.6.0",
22
+ "mdast-util-from-markdown": "2.0.2",
23
+ "unist-util-visit": "5.0.0"
24
+ },
25
+ "peerDependencies": {
26
+ "@earendil-works/pi-coding-agent": "*",
27
+ "@earendil-works/pi-ai": "*",
28
+ "typebox": "*"
29
+ },
30
+ "devDependencies": {
31
+ "@earendil-works/pi-ai": "0.85.1",
32
+ "@earendil-works/pi-coding-agent": "0.85.1",
33
+ "@types/node": "22.19.0",
34
+ "tsx": "4.23.13",
35
+ "typebox": "1.3.34",
36
+ "typescript": "5.9.3",
37
+ "vitest": "5.0.1"
38
+ }
39
+ }
@@ -0,0 +1,25 @@
1
+ ---
2
+ name: jev-review
3
+ description: Validate a code fragment or technical decision with Jev when explicitly requested or when an independent targeted check is useful.
4
+ ---
5
+
6
+ # Jev review
7
+
8
+ Identify the candidate and its explicit requirements.
9
+
10
+ Read the necessary surrounding code before validating an isolated fragment.
11
+
12
+ Call `jev_validate` with the candidate and relevant requirements.
13
+
14
+ For `block`, correct the flagged issue and validate the changed candidate.
15
+
16
+ For `review`, collect missing context or explain the uncertainty.
17
+
18
+ For `unavailable`, state that validation was not completed.
19
+
20
+ Never describe Jev approval as proof of correctness.
21
+ Run the appropriate compiler, static checks, or targeted tests separately.
22
+
23
+ For ad-hoc typed judgements (classification, relevance, rubric scores) use
24
+ `jev_ask` with model-defined questions instead; read p >= 0.90 as yes,
25
+ p <= 0.20 as no, and choice/score below 0.60 confidence as uncertain.
@@ -0,0 +1,289 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { OpenRouter } from "@openrouter/sdk";
3
+ import { extractCodeBlocks } from "../markdown.ts";
4
+ import { applyPolicy } from "../policy.ts";
5
+ import type {
6
+ CheckResult,
7
+ JevBackend,
8
+ ReviewInput,
9
+ ReviewResult,
10
+ RuleDefinition,
11
+ TypedAnswer,
12
+ TypedAskFn,
13
+ TypedQuestion,
14
+ } from "../reviewer.ts";
15
+ import { DEFAULT_RULES } from "../reviewer.ts";
16
+
17
+ export interface TransientRetry {
18
+ enabled: boolean;
19
+ maxRetries: number;
20
+ }
21
+
22
+ export interface OpenRouterAdapterConfig {
23
+ model: string;
24
+ timeoutMs: number;
25
+ retryTransients: TransientRetry;
26
+ maxPayloadBytes: number;
27
+ maxCodeBlocks: number;
28
+ passMax: number;
29
+ blockMin: number;
30
+ rules?: RuleDefinition[];
31
+ /** Chiave risolta (env vince sul file). Fallback env per compatibilità. */
32
+ apiKey?: string;
33
+ /** Opzionali per ranking OpenRouter, mai segreti. */
34
+ httpReferer?: string;
35
+ appTitle?: string;
36
+ }
37
+
38
+ /**
39
+ * Retry solo su transienti: backoff SDK su 429/5xx (statusCodes del
40
+ * router) ed errori di connessione. Timeout = abort, non ritentato.
41
+ * Nota: l'SDK OpenRouter non espone un conteggio tentativi, quindi qui
42
+ * `enabled` accende il backoff di default e `maxRetries` non si applica;
43
+ * il conteggio vale per il backend diretto. Spento → "none".
44
+ */
45
+ export function openRouterRetryOption(retry: TransientRetry): { strategy: "none" } | { strategy: "backoff"; retryConnectionErrors: true } {
46
+ return retry.enabled && Math.floor(retry.maxRetries) > 0
47
+ ? { strategy: "backoff", retryConnectionErrors: true }
48
+ : { strategy: "none" };
49
+ }
50
+
51
+ export const OPENROUTER_BACKEND: JevBackend = "openrouter";
52
+
53
+ function buildState(input: ReviewInput, maxCodeBlocks: number): { state: string; dropped: number } {
54
+ const allBlocks = extractCodeBlocks(input.candidate);
55
+ const codeBlocks = allBlocks.slice(0, maxCodeBlocks);
56
+ const dropped = allBlocks.length - codeBlocks.length;
57
+ return {
58
+ state: JSON.stringify({
59
+ requirements: input.requirements,
60
+ candidate: input.candidate,
61
+ codeBlocks,
62
+ truncatedBlocks: dropped,
63
+ }),
64
+ dropped,
65
+ };
66
+ }
67
+
68
+ export function createOpenRouterReviewer(adapterConfig: OpenRouterAdapterConfig) {
69
+ let client: OpenRouter | undefined;
70
+ const rules = adapterConfig.rules ?? DEFAULT_RULES;
71
+
72
+ return async function reviewOpenRouter(
73
+ input: ReviewInput,
74
+ signal?: AbortSignal,
75
+ ): Promise<ReviewResult> {
76
+ const started = performance.now();
77
+
78
+ try {
79
+ signal?.throwIfAborted();
80
+
81
+ const apiKey = adapterConfig.apiKey ?? process.env.OPENROUTER_API_KEY;
82
+ if (!apiKey) {
83
+ return {
84
+ status: "unavailable",
85
+ checks: [],
86
+ elapsedMs: performance.now() - started,
87
+ backend: OPENROUTER_BACKEND,
88
+ errorCode: "MISSING_API_KEY",
89
+ };
90
+ }
91
+
92
+ const { state, dropped } = buildState(input, adapterConfig.maxCodeBlocks);
93
+ const droppedInfo = dropped > 0 ? { truncated: { codeBlocksDropped: dropped } } : {};
94
+
95
+ if (Buffer.byteLength(state, "utf8") > adapterConfig.maxPayloadBytes) {
96
+ return {
97
+ status: "unavailable",
98
+ checks: [],
99
+ elapsedMs: performance.now() - started,
100
+ backend: OPENROUTER_BACKEND,
101
+ errorCode: "PAYLOAD_LIMIT",
102
+ };
103
+ }
104
+
105
+ client ??= new OpenRouter({
106
+ apiKey,
107
+ ...(adapterConfig.httpReferer
108
+ ? { httpReferer: adapterConfig.httpReferer }
109
+ : {}),
110
+ ...(adapterConfig.appTitle ? { appTitle: adapterConfig.appTitle } : {}),
111
+ });
112
+
113
+ const questions: Record<
114
+ string,
115
+ { type: "noul"; instructions: string; criteria?: { true: string; false: string } }
116
+ > = {};
117
+ for (const rule of rules) {
118
+ questions[rule.ruleId] = {
119
+ type: "noul",
120
+ instructions: rule.question,
121
+ ...(rule.criteria ? { criteria: { ...rule.criteria } } : {}),
122
+ };
123
+ }
124
+
125
+ const decision = await client.alpha.decisions.create(
126
+ {
127
+ decisionsRequest: {
128
+ model: adapterConfig.model,
129
+ state,
130
+ questions,
131
+ },
132
+ },
133
+ {
134
+ signal,
135
+ timeoutMs: adapterConfig.timeoutMs,
136
+ retries: openRouterRetryOption(adapterConfig.retryTransients),
137
+ } as any,
138
+ );
139
+
140
+ const checks: CheckResult[] = rules.map((rule) => {
141
+ const answer = (decision.answers as Record<string, any>)[rule.ruleId];
142
+ if (!answer || answer.type !== "noul") {
143
+ throw new Error(`Invalid OpenRouter answer for rule ${rule.ruleId}`);
144
+ }
145
+ const pFlaw = answer.noul as unknown;
146
+ if (
147
+ typeof pFlaw !== "number" ||
148
+ !Number.isFinite(pFlaw) ||
149
+ pFlaw < 0 ||
150
+ pFlaw > 1
151
+ ) {
152
+ throw new Error(`Invalid pFlaw for rule ${rule.ruleId}`);
153
+ }
154
+ return { ruleId: rule.ruleId, pFlaw };
155
+ });
156
+
157
+ const status = applyPolicy(checks, {
158
+ passMax: adapterConfig.passMax,
159
+ blockMin: adapterConfig.blockMin,
160
+ });
161
+
162
+ return {
163
+ status,
164
+ checks,
165
+ model: decision.model,
166
+ backend: OPENROUTER_BACKEND,
167
+ elapsedMs: performance.now() - started,
168
+ ...droppedInfo,
169
+ };
170
+ } catch (error) {
171
+ if (signal?.aborted) throw error;
172
+ return {
173
+ status: "unavailable",
174
+ checks: [],
175
+ elapsedMs: performance.now() - started,
176
+ backend: OPENROUTER_BACKEND,
177
+ errorCode: classifyOpenRouterError(error),
178
+ };
179
+ }
180
+ };
181
+ }
182
+
183
+ function classifyOpenRouterError(error: unknown): string {
184
+ const name =
185
+ typeof error === "object" && error !== null && "name" in error
186
+ ? String((error as { name: unknown }).name)
187
+ : "";
188
+ const message =
189
+ typeof error === "object" && error !== null && "message" in error
190
+ ? String((error as { message: unknown }).message)
191
+ : "";
192
+ const haystack = `${name} ${message}`.toLowerCase();
193
+ if (haystack.includes("timeout")) return "JEV_TIMEOUT";
194
+ if (haystack.includes("auth") || haystack.includes("401") || haystack.includes("403"))
195
+ return "JEV_AUTH";
196
+ if (haystack.includes("429") || haystack.includes("rate")) return "JEV_RATE_LIMIT";
197
+ if (haystack.includes("abort")) return "JEV_ABORTED";
198
+ return "JEV_REQUEST_FAILED";
199
+ }
200
+
201
+ export interface OpenRouterTypedConfig {
202
+ model: string;
203
+ timeoutMs: number;
204
+ retryTransients: TransientRetry;
205
+ apiKey?: string;
206
+ httpReferer?: string;
207
+ appTitle?: string;
208
+ }
209
+
210
+ /** Client tipato generico (noul + choice). Solleva su errore (fail-open a carico del chiamante). */
211
+ export function createOpenRouterTypedClient(tcfg: OpenRouterTypedConfig): TypedAskFn {
212
+ let client: OpenRouter | undefined;
213
+ return async function askTyped(state, questions, signal) {
214
+ const started = performance.now();
215
+ signal?.throwIfAborted();
216
+ if (questions.length === 0) throw new Error("askTyped: no questions");
217
+ const apiKey = tcfg.apiKey ?? process.env.OPENROUTER_API_KEY;
218
+ if (!apiKey) throw new Error("askTyped: missing OPENROUTER_API_KEY");
219
+ client ??= new OpenRouter({
220
+ apiKey,
221
+ ...(tcfg.httpReferer ? { httpReferer: tcfg.httpReferer } : {}),
222
+ ...(tcfg.appTitle ? { appTitle: tcfg.appTitle } : {}),
223
+ });
224
+ const built: Record<string, any> = {};
225
+ for (const q of questions) {
226
+ if (q.kind === "noul") {
227
+ built[q.id] = {
228
+ type: "noul",
229
+ instructions: q.instructions,
230
+ ...(q.criteria ? { criteria: { ...q.criteria } } : {}),
231
+ };
232
+ } else if (q.kind === "choice") {
233
+ built[q.id] = { type: "choice", instructions: q.instructions, criteria: { ...q.options } };
234
+ } else {
235
+ built[q.id] = { type: "score", instructions: q.instructions, criteria: [...q.levels] };
236
+ }
237
+ }
238
+ const decision = await client.alpha.decisions.create(
239
+ { decisionsRequest: { model: tcfg.model, state: state as string, questions: built } },
240
+ {
241
+ signal,
242
+ timeoutMs: tcfg.timeoutMs,
243
+ retries: openRouterRetryOption(tcfg.retryTransients),
244
+ } as any,
245
+ );
246
+ const answers: TypedAnswer[] = questions.map((q: TypedQuestion): TypedAnswer => {
247
+ const a = (decision.answers as Record<string, any>)[q.id];
248
+ if (q.kind === "noul") {
249
+ if (!a || a.type !== "noul" || typeof a.noul !== "number") {
250
+ throw new Error(`Invalid noul answer for ${q.id}`);
251
+ }
252
+ return { id: q.id, type: "noul", p: a.noul };
253
+ }
254
+ if (q.kind === "choice") {
255
+ if (!a || a.type !== "choice" || typeof a.choice !== "string") {
256
+ throw new Error(`Invalid choice answer for ${q.id}`);
257
+ }
258
+ return {
259
+ id: q.id,
260
+ type: "choice",
261
+ choice: a.choice,
262
+ confidence: typeof a.confidence === "number" ? a.confidence : undefined,
263
+ probabilities: { ...(a.probabilities ?? {}) },
264
+ };
265
+ }
266
+ if (!a || a.type !== "score" || typeof a.score !== "number") {
267
+ throw new Error(`Invalid score answer for ${q.id}`);
268
+ }
269
+ return {
270
+ id: q.id,
271
+ type: "score",
272
+ score: a.score,
273
+ confidence: typeof a.confidence === "number" ? a.confidence : undefined,
274
+ probabilities: { ...(a.probabilities ?? {}) },
275
+ };
276
+ });
277
+ const usage = decision.usage as { inputTokens?: unknown; outputTokens?: unknown } | undefined;
278
+ return {
279
+ answers,
280
+ elapsedMs: performance.now() - started,
281
+ model: decision.model,
282
+ backend: OPENROUTER_BACKEND,
283
+ usage:
284
+ usage && typeof usage.inputTokens === "number" && typeof usage.outputTokens === "number"
285
+ ? { input: usage.inputTokens, output: usage.outputTokens }
286
+ : undefined,
287
+ };
288
+ };
289
+ }
@@ -0,0 +1,244 @@
1
+ import { performance } from "node:perf_hooks";
2
+ import { choice, noul, score as scoreQ, TypeSafeClient } from "@typesafe-ai/sdk";
3
+ import { extractCodeBlocks } from "../markdown.ts";
4
+ import { applyPolicy } from "../policy.ts";
5
+ import type {
6
+ CheckResult,
7
+ JevBackend,
8
+ ReviewInput,
9
+ ReviewResult,
10
+ RuleDefinition,
11
+ TypedAnswer,
12
+ TypedAskFn,
13
+ TypedQuestion,
14
+ } from "../reviewer.ts";
15
+ import { DEFAULT_RULES } from "../reviewer.ts";
16
+
17
+ export interface TransientRetry {
18
+ enabled: boolean;
19
+ maxRetries: number;
20
+ }
21
+
22
+ export interface TypesafeAdapterConfig {
23
+ model: string;
24
+ timeoutMs: number;
25
+ retryTransients: TransientRetry;
26
+ maxPayloadBytes: number;
27
+ maxCodeBlocks: number;
28
+ passMax: number;
29
+ blockMin: number;
30
+ rules?: RuleDefinition[];
31
+ /** Chiave risolta (env vince sul file). Fallback env dell'SDK. */
32
+ apiKey?: string;
33
+ }
34
+
35
+ export const TYPESAFE_BACKEND: JevBackend = "typesafe";
36
+
37
+ function buildState(input: ReviewInput, maxCodeBlocks: number): { state: string; dropped: number } {
38
+ const allBlocks = extractCodeBlocks(input.candidate);
39
+ const codeBlocks = allBlocks.slice(0, maxCodeBlocks);
40
+ const dropped = allBlocks.length - codeBlocks.length;
41
+ return {
42
+ state: JSON.stringify({
43
+ requirements: input.requirements,
44
+ candidate: input.candidate,
45
+ codeBlocks,
46
+ truncatedBlocks: dropped,
47
+ }),
48
+ dropped,
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Retry solo su transienti: 429/5xx ed errori di connessione secondo i
54
+ * default dell'SDK. Timeout mai ritentati (`apiTimeoutError: false`);
55
+ * auth (401/403) fuori dai retryable di default. Spento → zero retry.
56
+ */
57
+ export function typesafeRetryOption(retry: TransientRetry): { maxRetries: number; apiTimeoutError: boolean } {
58
+ return retry.enabled
59
+ ? { maxRetries: Math.max(0, Math.floor(retry.maxRetries)), apiTimeoutError: false }
60
+ : { maxRetries: 0, apiTimeoutError: false };
61
+ }
62
+
63
+ export function createTypesafeReviewer(adapterConfig: TypesafeAdapterConfig) {
64
+ let client: TypeSafeClient | undefined;
65
+ const rules = adapterConfig.rules ?? DEFAULT_RULES;
66
+
67
+ return async function reviewTypesafe(
68
+ input: ReviewInput,
69
+ signal?: AbortSignal,
70
+ ): Promise<ReviewResult> {
71
+ const started = performance.now();
72
+
73
+ try {
74
+ signal?.throwIfAborted();
75
+
76
+ const { state, dropped } = buildState(input, adapterConfig.maxCodeBlocks);
77
+ const droppedInfo = dropped > 0 ? { truncated: { codeBlocksDropped: dropped } } : {};
78
+
79
+ if (Buffer.byteLength(state, "utf8") > adapterConfig.maxPayloadBytes) {
80
+ return {
81
+ status: "unavailable",
82
+ checks: [],
83
+ elapsedMs: performance.now() - started,
84
+ backend: TYPESAFE_BACKEND,
85
+ errorCode: "PAYLOAD_LIMIT",
86
+ };
87
+ }
88
+
89
+ client ??= new TypeSafeClient({
90
+ ...(adapterConfig.apiKey ? { apiKey: adapterConfig.apiKey } : {}),
91
+ timeout: adapterConfig.timeoutMs,
92
+ retry: typesafeRetryOption(adapterConfig.retryTransients),
93
+ logLevel: "off",
94
+ });
95
+
96
+ const questions: Record<string, ReturnType<typeof noul>> = {};
97
+ for (const rule of rules) {
98
+ questions[rule.ruleId] = noul(rule.question, rule.criteria ?? undefined);
99
+ }
100
+
101
+ const result = await client.systemOne(
102
+ {
103
+ model: adapterConfig.model,
104
+ state,
105
+ questions,
106
+ },
107
+ { signal },
108
+ );
109
+
110
+ const checks: CheckResult[] = rules.map((rule) => {
111
+ const answer = (result.answers as Record<string, { noul?: unknown }>)[
112
+ rule.ruleId
113
+ ];
114
+ const pFlaw = answer?.noul;
115
+ if (
116
+ typeof pFlaw !== "number" ||
117
+ !Number.isFinite(pFlaw) ||
118
+ pFlaw < 0 ||
119
+ pFlaw > 1
120
+ ) {
121
+ throw new Error(`Invalid Jev response for rule ${rule.ruleId}`);
122
+ }
123
+ return { ruleId: rule.ruleId, pFlaw };
124
+ });
125
+
126
+ const status = applyPolicy(checks, {
127
+ passMax: adapterConfig.passMax,
128
+ blockMin: adapterConfig.blockMin,
129
+ });
130
+
131
+ return {
132
+ status,
133
+ checks,
134
+ model: result.model,
135
+ backend: TYPESAFE_BACKEND,
136
+ elapsedMs: performance.now() - started,
137
+ ...droppedInfo,
138
+ };
139
+ } catch (error) {
140
+ if (signal?.aborted) throw error;
141
+ return {
142
+ status: "unavailable",
143
+ checks: [],
144
+ elapsedMs: performance.now() - started,
145
+ backend: TYPESAFE_BACKEND,
146
+ errorCode: classifyTypesafeError(error),
147
+ };
148
+ }
149
+ };
150
+ }
151
+
152
+ function classifyTypesafeError(error: unknown): string {
153
+ const name =
154
+ typeof error === "object" && error !== null && "name" in error
155
+ ? String((error as { name: unknown }).name)
156
+ : "";
157
+ if (name.includes("Timeout")) return "JEV_TIMEOUT";
158
+ if (name.includes("Auth")) return "JEV_AUTH";
159
+ if (name.includes("RateLimit")) return "JEV_RATE_LIMIT";
160
+ if (name.includes("Abort") || name.includes("UserAbort")) return "JEV_ABORTED";
161
+ return "JEV_REQUEST_FAILED";
162
+ }
163
+
164
+ export interface TypesafeTypedConfig {
165
+ model: string;
166
+ timeoutMs: number;
167
+ retryTransients: TransientRetry;
168
+ apiKey?: string;
169
+ }
170
+
171
+ /**
172
+ * Client tipato generico (noul + choice). A differenza di review(), solleva
173
+ * su errore: il chiamante decide fail-open/closed.
174
+ */
175
+ export function createTypesafeTypedClient(tcfg: TypesafeTypedConfig): TypedAskFn {
176
+ let client: TypeSafeClient | undefined;
177
+ return async function askTyped(state, questions, signal) {
178
+ const started = performance.now();
179
+ signal?.throwIfAborted();
180
+ if (questions.length === 0) throw new Error("askTyped: no questions");
181
+ client ??= new TypeSafeClient({
182
+ ...(tcfg.apiKey ? { apiKey: tcfg.apiKey } : {}),
183
+ timeout: tcfg.timeoutMs,
184
+ retry: typesafeRetryOption(tcfg.retryTransients),
185
+ logLevel: "off",
186
+ });
187
+ const built: Record<string, unknown> = {};
188
+ for (const q of questions) {
189
+ if (q.kind === "noul") {
190
+ built[q.id] = noul(q.instructions, q.criteria ?? undefined);
191
+ } else if (q.kind === "choice") {
192
+ built[q.id] = choice(q.instructions, q.options as Record<string, string | null>);
193
+ } else {
194
+ built[q.id] = scoreQ(q.instructions, q.levels as [string, string, ...string[]]);
195
+ }
196
+ }
197
+ const result = await client.systemOne(
198
+ { model: tcfg.model, state: state as string, questions: built as never },
199
+ { signal },
200
+ );
201
+ const answers: TypedAnswer[] = questions.map((q: TypedQuestion): TypedAnswer => {
202
+ const a = (result.answers as Record<string, any>)[q.id];
203
+ if (q.kind === "noul") {
204
+ if (!a || a.type !== "noul" || typeof a.noul !== "number") {
205
+ throw new Error(`Invalid noul answer for ${q.id}`);
206
+ }
207
+ return { id: q.id, type: "noul", p: a.noul };
208
+ }
209
+ if (q.kind === "choice") {
210
+ if (!a || a.type !== "choice" || typeof a.choice !== "string") {
211
+ throw new Error(`Invalid choice answer for ${q.id}`);
212
+ }
213
+ return {
214
+ id: q.id,
215
+ type: "choice",
216
+ choice: a.choice,
217
+ confidence: typeof a.confidence === "number" ? a.confidence : undefined,
218
+ probabilities: { ...(a.probabilities ?? {}) },
219
+ };
220
+ }
221
+ if (!a || a.type !== "score" || typeof a.score !== "number") {
222
+ throw new Error(`Invalid score answer for ${q.id}`);
223
+ }
224
+ return {
225
+ id: q.id,
226
+ type: "score",
227
+ score: a.score,
228
+ confidence: typeof a.confidence === "number" ? a.confidence : undefined,
229
+ probabilities: { ...(a.probabilities ?? {}) },
230
+ };
231
+ });
232
+ const usage = result.usage as { input_tokens?: unknown; output_tokens?: unknown } | undefined;
233
+ return {
234
+ answers,
235
+ elapsedMs: performance.now() - started,
236
+ model: result.model,
237
+ backend: TYPESAFE_BACKEND,
238
+ usage:
239
+ usage && typeof usage.input_tokens === "number" && typeof usage.output_tokens === "number"
240
+ ? { input: usage.input_tokens, output: usage.output_tokens }
241
+ : undefined,
242
+ };
243
+ };
244
+ }