pi-freeflow 1.5.0 → 1.7.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/src/logger.ts CHANGED
@@ -21,6 +21,18 @@ export const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
21
21
  audit: 4,
22
22
  };
23
23
 
24
+ /**
25
+ * Levels that actually have emitters. 'audit' exists in LOG_LEVEL_ORDER as a
26
+ * sink for logAudit() callers but nothing decides the threshold by it — a
27
+ * persisted or env-provided 'audit' level would silently suppress everything
28
+ * below it (the audit-level freeze). Treat it as unknown everywhere.
29
+ */
30
+ const EMITTED_LEVELS: readonly LogLevel[] = ["debug", "info", "warn", "error"];
31
+
32
+ export function isEmittedLogLevel(level: string): level is LogLevel {
33
+ return (EMITTED_LEVELS as readonly string[]).includes(level);
34
+ }
35
+
24
36
  let cachedDebugState: DebugState | null | undefined = undefined;
25
37
  let cachedDebugMtime = 0;
26
38
  let cachedDebugAt = 0;
@@ -50,10 +62,16 @@ export function loadDebugState(): DebugState | null {
50
62
  const raw = fs.readFileSync(DEBUG_STATE_FILE, "utf8");
51
63
  const parsed = JSON.parse(raw) as DebugState;
52
64
  if (typeof parsed?.debug === "boolean") {
53
- cachedDebugState = parsed;
65
+ // A stray 'audit' level (or any unknown level) must not suppress the
66
+ // whole log — keep the debug boolean, drop the unusable level.
67
+ const clean: DebugState = { debug: parsed.debug };
68
+ if (typeof parsed.level === "string" && isEmittedLogLevel(parsed.level)) {
69
+ clean.level = parsed.level;
70
+ }
71
+ cachedDebugState = clean;
54
72
  cachedDebugMtime = stat.mtimeMs;
55
73
  cachedDebugAt = now;
56
- return parsed;
74
+ return clean;
57
75
  }
58
76
  } catch {
59
77
  // Non-fatal if parsing or reading fails
@@ -91,6 +109,13 @@ export function saveDebugState(s: DebugState): void {
91
109
  }
92
110
  }
93
111
 
112
+ /** Test-only: drop the in-memory debug-state cache so a test's disk write is re-read. */
113
+ export function _resetDebugStateCacheForTest(): void {
114
+ cachedDebugState = undefined;
115
+ cachedDebugMtime = 0;
116
+ cachedDebugAt = 0;
117
+ }
118
+
94
119
  /**
95
120
  * Calculate the active minimum log level threshold based on state and env.
96
121
  */
@@ -99,13 +124,13 @@ export function getMinLogLevel(): number {
99
124
  if (dbg?.debug) {
100
125
  return LOG_LEVEL_ORDER.debug;
101
126
  }
102
- if (dbg?.level && dbg.level in LOG_LEVEL_ORDER) {
127
+ if (dbg?.level && isEmittedLogLevel(dbg.level)) {
103
128
  return LOG_LEVEL_ORDER[dbg.level];
104
129
  }
105
130
 
106
131
  const raw = (process.env.FREEFLOW_LOG_LEVEL || "info").toLowerCase();
107
132
 
108
- if (raw in LOG_LEVEL_ORDER) {
133
+ if (isEmittedLogLevel(raw)) {
109
134
  return LOG_LEVEL_ORDER[raw as LogLevel];
110
135
  }
111
136
 
@@ -143,9 +168,7 @@ export function rotateLogsIfNeeded(targetFile: string = LOG_FILE): void {
143
168
  const dst = `${targetFile}.${i}`;
144
169
  try {
145
170
  if (fs.existsSync(src)) {
146
- if (i === LOG_MAX_FILES && fs.existsSync(dst)) {
147
- fs.unlinkSync(dst);
148
- } else if (fs.existsSync(dst)) {
171
+ if (fs.existsSync(dst)) {
149
172
  fs.unlinkSync(dst);
150
173
  }
151
174
  fs.renameSync(src, dst);
@@ -252,14 +275,6 @@ export function logError(
252
275
  log("error", message, meta, reqId);
253
276
  }
254
277
 
255
- export function logAudit(
256
- message: string,
257
- meta?: Record<string, unknown>,
258
- reqId?: string,
259
- ): void {
260
- log("audit", message, meta, reqId);
261
- }
262
-
263
278
  export interface ReadRecentLogsResult {
264
279
  lines: string[];
265
280
  totalMatched: number;
@@ -269,20 +284,28 @@ export interface ReadRecentLogsResult {
269
284
 
270
285
  /**
271
286
  * Read recent log entries from log file and its rotated archives.
287
+ * @param filterLevel Optional log-level filter (case-insensitive label match).
288
+ * @param filterReqId Optional request-ID substring filter.
289
+ * @param count Max lines to return (clamped 1-200).
290
+ * @param filterText Optional case-insensitive text substring filter.
291
+ * @param files Optional log files to read (newest-last order); defaults to
292
+ * LOG_FILE and its rotated archives (.1-<LOG_MAX_FILES>).
272
293
  */
273
294
  export function readRecentLogs(
274
295
  filterLevel?: LogLevel | null,
275
296
  filterReqId?: string | null,
276
297
  count = 25,
298
+ filterText?: string | null,
299
+ files?: string[],
277
300
  ): ReadRecentLogsResult {
278
- const files: string[] = [
279
- LOG_FILE,
280
- `${LOG_FILE}.1`,
281
- `${LOG_FILE}.2`,
282
- `${LOG_FILE}.3`,
283
- ].filter((f) => fs.existsSync(f));
284
-
285
- if (files.length === 0) {
301
+ const filesList: string[] = (
302
+ files ?? [
303
+ LOG_FILE,
304
+ ...Array.from({ length: LOG_MAX_FILES }, (_, i) => `${LOG_FILE}.${i + 1}`),
305
+ ]
306
+ ).filter((f) => fs.existsSync(f));
307
+
308
+ if (filesList.length === 0) {
286
309
  return {
287
310
  lines: [],
288
311
  totalMatched: 0,
@@ -292,7 +315,7 @@ export function readRecentLogs(
292
315
  }
293
316
 
294
317
  let allLines: string[] = [];
295
- for (const f of files) {
318
+ for (const f of filesList) {
296
319
  try {
297
320
  const content = fs.readFileSync(f, "utf8");
298
321
  const lines = content.trim().split("\n").filter(Boolean);
@@ -315,6 +338,11 @@ export function readRecentLogs(
315
338
  );
316
339
  }
317
340
 
341
+ if (filterText) {
342
+ const lower = filterText.toLowerCase();
343
+ filtered = filtered.filter((l) => l.toLowerCase().includes(lower));
344
+ }
345
+
318
346
  const clampedCount = Math.min(200, Math.max(1, count));
319
347
  const resultLines = filtered.slice(-clampedCount);
320
348
 
package/src/models.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 21 verified free models:
4
+ * Defines the 25 verified free models:
5
5
  * - 7 OpenCode Zen models (1 Responses API + 6 Chat Completions)
6
- * - 14 KiloCode Keyless Gateway models (10 OpenRouter format + 4 Standard format)
6
+ * - 18 KiloCode Keyless Gateway models (17 OpenRouter format + 1 Standard format)
7
7
  */
8
8
 
9
- import type { ModelDef, Upstream } from "./types.ts";
9
+ import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
10
10
 
11
11
  /**
12
12
  * OpenCode Zen free models verified against the live catalog and inference APIs.
@@ -21,13 +21,13 @@ export const OPENCODE_MODELS: ModelDef[] = [
21
21
  maxTokens: 131_072,
22
22
  api: "openai-responses",
23
23
  input: ["text", "image"],
24
- thinkingLevelMap: {
25
- off: null,
26
- minimal: "minimal",
27
- low: "low",
28
- medium: "medium",
29
- high: "high",
30
- xhigh: "xhigh",
24
+ thinkingLevelMap: {
25
+ off: null,
26
+ minimal: "minimal",
27
+ low: "low",
28
+ medium: "medium",
29
+ high: "high",
30
+ xhigh: "xhigh",
31
31
  max: null,},
32
32
  },
33
33
  {
@@ -37,13 +37,13 @@ export const OPENCODE_MODELS: ModelDef[] = [
37
37
  contextWindow: 1_048_576,
38
38
  maxTokens: 131_072,
39
39
  input: ["text", "image"],
40
- thinkingLevelMap: {
41
- off: null,
42
- minimal: "low",
43
- low: "low",
44
- medium: "medium",
45
- high: "high",
46
- xhigh: "high",
40
+ thinkingLevelMap: {
41
+ off: null,
42
+ minimal: "low",
43
+ low: "low",
44
+ medium: "medium",
45
+ high: "high",
46
+ xhigh: "high",
47
47
  max: null,},
48
48
  },
49
49
  {
@@ -51,15 +51,15 @@ export const OPENCODE_MODELS: ModelDef[] = [
51
51
  name: "Hy3 (262K)",
52
52
  reasoning: true,
53
53
  contextWindow: 262_144,
54
- maxTokens: 262_144,
54
+ maxTokens: 128_000,
55
55
  input: ["text"],
56
- thinkingLevelMap: {
57
- off: null,
58
- minimal: "minimal",
59
- low: "low",
60
- medium: "medium",
61
- high: "high",
62
- xhigh: "xhigh",
56
+ thinkingLevelMap: {
57
+ off: null,
58
+ minimal: "minimal",
59
+ low: "low",
60
+ medium: "medium",
61
+ high: "high",
62
+ xhigh: "xhigh",
63
63
  max: null,},
64
64
  },
65
65
  {
@@ -69,13 +69,13 @@ export const OPENCODE_MODELS: ModelDef[] = [
69
69
  contextWindow: 1_000_000,
70
70
  maxTokens: 128_000,
71
71
  input: ["text"],
72
- thinkingLevelMap: {
73
- off: null,
74
- minimal: "minimal",
75
- low: "low",
76
- medium: "medium",
77
- high: "high",
78
- xhigh: "xhigh",
72
+ thinkingLevelMap: {
73
+ off: null,
74
+ minimal: "minimal",
75
+ low: "low",
76
+ medium: "medium",
77
+ high: "high",
78
+ xhigh: "xhigh",
79
79
  max: null,},
80
80
  },
81
81
  {
@@ -85,13 +85,13 @@ export const OPENCODE_MODELS: ModelDef[] = [
85
85
  contextWindow: 1_000_000,
86
86
  maxTokens: 262_144,
87
87
  input: ["text"],
88
- thinkingLevelMap: {
89
- off: null,
90
- minimal: "minimal",
91
- low: "low",
92
- medium: "medium",
93
- high: "high",
94
- xhigh: "xhigh",
88
+ thinkingLevelMap: {
89
+ off: null,
90
+ minimal: "minimal",
91
+ low: "low",
92
+ medium: "medium",
93
+ high: "high",
94
+ xhigh: "xhigh",
95
95
  max: null,},
96
96
  },
97
97
  {
@@ -101,37 +101,49 @@ export const OPENCODE_MODELS: ModelDef[] = [
101
101
  contextWindow: 200_000,
102
102
  maxTokens: 32_000,
103
103
  input: ["text"],
104
- thinkingLevelMap: {
105
- off: null,
106
- minimal: null,
107
- low: null,
108
- medium: null,
109
- high: "high",
110
- xhigh: null,
104
+ thinkingLevelMap: {
105
+ off: null,
106
+ minimal: null,
107
+ low: null,
108
+ medium: null,
109
+ high: "high",
110
+ xhigh: null,
111
111
  max: "max",},
112
112
  },
113
113
  {
114
114
  id: "laguna-s-2.1-free",
115
- name: "Laguna S 2.1 (1M)",
115
+ name: "Laguna S 2.1 (256K)",
116
116
  reasoning: true,
117
- contextWindow: 1_048_576,
118
- maxTokens: 131_072,
117
+ contextWindow: 262_144,
118
+ maxTokens: 32_768,
119
119
  input: ["text"],
120
- thinkingLevelMap: {
121
- off: null,
122
- minimal: "minimal",
123
- low: "low",
124
- medium: "medium",
125
- high: "high",
126
- xhigh: "xhigh",
120
+ thinkingLevelMap: {
121
+ off: null,
122
+ minimal: "minimal",
123
+ low: "low",
124
+ medium: "medium",
125
+ high: "high",
126
+ xhigh: "xhigh",
127
127
  max: null,},
128
128
  },
129
129
  ];
130
130
 
131
131
  /**
132
- * Backward compatibility alias for OPENCODE_MODELS
132
+ * Shared effort map for Kilo reasoning models — verified live 2026-08-29:
133
+ * gateway accepts flat reasoning_effort minimal..xhigh for every reasoning
134
+ * model; stepfun/step-3.7-flash measured monotonic 77→313 thinking chars
135
+ * across minimal→xhigh. Declaring the map locks the picker (instead of
136
+ * host guessing) and matches the OpenCode-model pattern.
133
137
  */
134
- export const KNOWN_MODELS = OPENCODE_MODELS;
138
+ const KILO_REASONING_MAP: ThinkingLevelMap = {
139
+ off: null,
140
+ minimal: "minimal",
141
+ low: "low",
142
+ medium: "medium",
143
+ high: "high",
144
+ xhigh: "xhigh",
145
+ max: null,
146
+ };
135
147
 
136
148
  /**
137
149
  * KiloCode Gateway free models (keyless — https://kilo.ai/docs/gateway).
@@ -146,6 +158,7 @@ export const KILO_MODELS: ModelDef[] = [
146
158
  maxTokens: 512_000,
147
159
  input: ["text", "image"],
148
160
  thinkingFormat: "openrouter",
161
+ thinkingLevelMap: KILO_REASONING_MAP,
149
162
  },
150
163
  {
151
164
  id: "stepfun/step-3.7-flash:free",
@@ -155,24 +168,27 @@ export const KILO_MODELS: ModelDef[] = [
155
168
  maxTokens: 262_144,
156
169
  input: ["text", "image"],
157
170
  thinkingFormat: "openrouter",
171
+ thinkingLevelMap: KILO_REASONING_MAP,
158
172
  },
159
173
  {
160
174
  id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
161
175
  name: "Nemotron 3 Nano Omni",
162
176
  reasoning: true,
163
177
  contextWindow: 256_000,
164
- maxTokens: 65_536,
178
+ maxTokens: 131_072,
165
179
  input: ["text", "image"],
166
180
  thinkingFormat: "openrouter",
181
+ thinkingLevelMap: KILO_REASONING_MAP,
167
182
  },
168
183
  {
169
184
  id: "nvidia/nemotron-3-ultra-550b-a55b:free",
170
185
  name: "Nemotron 3 Ultra 550B (1M)",
171
186
  reasoning: true,
172
187
  contextWindow: 1_000_000,
173
- maxTokens: 65_536,
188
+ maxTokens: 128_000,
174
189
  input: ["text"],
175
190
  thinkingFormat: "openrouter",
191
+ thinkingLevelMap: KILO_REASONING_MAP,
176
192
  },
177
193
  {
178
194
  id: "nvidia/nemotron-3.5-lightning:free",
@@ -182,6 +198,7 @@ export const KILO_MODELS: ModelDef[] = [
182
198
  maxTokens: 262_144,
183
199
  input: ["text"],
184
200
  thinkingFormat: "openrouter",
201
+ thinkingLevelMap: KILO_REASONING_MAP,
185
202
  },
186
203
  {
187
204
  id: "nvidia/nemotron-3-super-120b-a12b:free",
@@ -191,15 +208,17 @@ export const KILO_MODELS: ModelDef[] = [
191
208
  maxTokens: 262_144,
192
209
  input: ["text"],
193
210
  thinkingFormat: "openrouter",
211
+ thinkingLevelMap: KILO_REASONING_MAP,
194
212
  },
195
213
  {
196
214
  id: "tencent/hy3:free",
197
215
  name: "Tencent Hy3 (Kilo)",
198
216
  reasoning: true,
199
217
  contextWindow: 262_144,
200
- maxTokens: 262_144,
218
+ maxTokens: 128_000,
201
219
  input: ["text"],
202
220
  thinkingFormat: "openrouter",
221
+ thinkingLevelMap: KILO_REASONING_MAP,
203
222
  },
204
223
  {
205
224
  id: "cohere/north-mini-code:free",
@@ -209,15 +228,17 @@ export const KILO_MODELS: ModelDef[] = [
209
228
  maxTokens: 64_000,
210
229
  input: ["text"],
211
230
  thinkingFormat: "openrouter",
231
+ thinkingLevelMap: KILO_REASONING_MAP,
212
232
  },
213
233
  {
214
234
  id: "poolside/laguna-s-2.1:free",
215
235
  name: "Laguna S 2.1 (Kilo)",
216
236
  reasoning: true,
217
- contextWindow: 1_048_576,
218
- maxTokens: 131_072,
237
+ contextWindow: 262_144,
238
+ maxTokens: 32_768,
219
239
  input: ["text"],
220
240
  thinkingFormat: "openrouter",
241
+ thinkingLevelMap: KILO_REASONING_MAP,
221
242
  },
222
243
  {
223
244
  id: "poolside/laguna-xs-2.1:free",
@@ -227,31 +248,37 @@ export const KILO_MODELS: ModelDef[] = [
227
248
  maxTokens: 32_768,
228
249
  input: ["text"],
229
250
  thinkingFormat: "openrouter",
251
+ thinkingLevelMap: KILO_REASONING_MAP,
230
252
  },
231
253
  {
232
254
  id: "liquid/lfm-2.5-2.6b:free",
233
255
  name: "Liquid LFM 2.5",
234
256
  reasoning: true,
235
- contextWindow: 128_000,
257
+ contextWindow: 65_536,
236
258
  maxTokens: 32_768,
237
259
  input: ["text"],
238
260
  thinkingFormat: "openrouter",
261
+ thinkingLevelMap: KILO_REASONING_MAP,
239
262
  },
240
263
  {
241
264
  id: "kilo-auto/free",
242
265
  name: "Kilo Auto",
243
- reasoning: false,
266
+ reasoning: true,
244
267
  contextWindow: 256_000,
245
268
  maxTokens: 10_000,
246
269
  input: ["text"],
270
+ thinkingFormat: "openrouter",
271
+ thinkingLevelMap: KILO_REASONING_MAP,
247
272
  },
248
273
  {
249
274
  id: "openrouter/free",
250
275
  name: "OpenRouter Auto",
251
- reasoning: false,
276
+ reasoning: true,
252
277
  contextWindow: 200_000,
253
278
  maxTokens: 65_536,
254
279
  input: ["text", "image"],
280
+ thinkingFormat: "openrouter",
281
+ thinkingLevelMap: KILO_REASONING_MAP,
255
282
  },
256
283
  {
257
284
  id: "nvidia/nemotron-3.5-content-safety:free",
@@ -261,6 +288,46 @@ export const KILO_MODELS: ModelDef[] = [
261
288
  maxTokens: 8_192,
262
289
  input: ["text", "image"],
263
290
  },
291
+ {
292
+ id: "meituan/longcat-2.0-free",
293
+ name: "LongCat 2.0 (1M)",
294
+ reasoning: true,
295
+ contextWindow: 1_048_756,
296
+ maxTokens: 262_144,
297
+ input: ["text"],
298
+ thinkingFormat: "openrouter",
299
+ thinkingLevelMap: KILO_REASONING_MAP,
300
+ },
301
+ {
302
+ id: "minimax/minimax-m2.7:free",
303
+ name: "MiniMax M2.7 (free)",
304
+ reasoning: true,
305
+ contextWindow: 196_608,
306
+ maxTokens: 196_608,
307
+ input: ["text"],
308
+ thinkingFormat: "openrouter",
309
+ thinkingLevelMap: KILO_REASONING_MAP,
310
+ },
311
+ {
312
+ id: "minimax/minimax-m3:free",
313
+ name: "MiniMax M3 (free)",
314
+ reasoning: true,
315
+ contextWindow: 1_048_576,
316
+ maxTokens: 524_288,
317
+ input: ["text"],
318
+ thinkingFormat: "openrouter",
319
+ thinkingLevelMap: KILO_REASONING_MAP,
320
+ },
321
+ {
322
+ id: "inclusionai/ling-3.0-flash-fin:free",
323
+ name: "Ling 3.0 Flash Fin",
324
+ reasoning: true,
325
+ contextWindow: 262_144,
326
+ maxTokens: 32_768,
327
+ input: ["text"],
328
+ thinkingFormat: "openrouter",
329
+ thinkingLevelMap: KILO_REASONING_MAP,
330
+ },
264
331
  ];
265
332
 
266
333
  /**
@@ -276,10 +343,15 @@ export const MODEL_ALIASES: Record<string, string> = {
276
343
  "step-3.7-flash": "stepfun/step-3.7-flash:free",
277
344
  "nemotron-3-nano-omni": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
278
345
  "nemotron-3-ultra-550b": "nvidia/nemotron-3-ultra-550b-a55b:free",
346
+ "nemotron-3.5-lightning": "nvidia/nemotron-3.5-lightning:free",
279
347
  "nemotron-3-super": "nvidia/nemotron-3-super-120b-a12b:free",
280
348
  "north-mini-code": "cohere/north-mini-code:free",
281
349
  "lfm-2.5": "liquid/lfm-2.5-2.6b:free",
282
350
  "content-safety": "nvidia/nemotron-3.5-content-safety:free",
351
+ "longcat-2.0": "meituan/longcat-2.0-free",
352
+ "minimax-m2.7": "minimax/minimax-m2.7:free",
353
+ "minimax-m3": "minimax/minimax-m3:free",
354
+ "ling-3.0-flash-fin": "inclusionai/ling-3.0-flash-fin:free",
283
355
  // provider-prefixed short aliases (slash-normalized)
284
356
  "hy3:free": "tencent/hy3:free",
285
357
  "laguna-s-2.1:free": "poolside/laguna-s-2.1:free",
@@ -307,7 +379,7 @@ export const KILO_MODEL_IDS = new Set<string>([
307
379
  ]);
308
380
 
309
381
  /**
310
- * Combined list of all 21 static free models (canonical)
382
+ * Combined list of all 25 static free models (canonical)
311
383
  */
312
384
  export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS];
313
385
 
package/src/probe.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Relay reachability probe: verifies a deployed relay answers requests
3
+ * by issuing a minimal round trip through it and timing the response.
4
+ */
5
+
6
+ export interface RelayProbeResult {
7
+ ok: boolean;
8
+ status: number;
9
+ latencyMs: number;
10
+ error?: string;
11
+ }
12
+
13
+ /**
14
+ * Probe a relay endpoint to confirm it is reachable.
15
+ *
16
+ * Sends a minimal request to `<url>/v1/models` carrying the x-relay-target /
17
+ * x-relay-path headers the relay expects, timing the round trip. Never
18
+ * throws: network failures, timeouts, and non-2xx statuses are all reported
19
+ * on the returned result instead.
20
+ *
21
+ * The path mirrors the proxy's real relay contract (`relayFetch` in relay.ts):
22
+ * `x-relay-target` is the upstream origin and `x-relay-path` the full path
23
+ * including the `/zen` prefix — the workers only allow the exact origins
24
+ * `https://opencode.ai` / `https://api.kilo.ai` and forward `target + path`.
25
+ * @param auth Optional per-relay shared secret; sent as x-relay-auth so
26
+ * deployed relays (which enforce it) answer the probe instead of 401.
27
+ */
28
+ export async function probeRelay(url: string, auth?: string): Promise<RelayProbeResult> {
29
+ const cleanUrl = url.trim().replace(/\/+$/, "");
30
+ const start = Date.now();
31
+ try {
32
+ const res = await fetch(`${cleanUrl}/v1/models`, {
33
+ headers: {
34
+ "x-relay-target": "https://opencode.ai",
35
+ "x-relay-path": "/zen/v1/models",
36
+ ...(auth ? { "x-relay-auth": auth } : {}),
37
+ },
38
+ signal: AbortSignal.timeout(5_000),
39
+ });
40
+ return { ok: res.ok, status: res.status, latencyMs: Date.now() - start };
41
+ } catch (e) {
42
+ return {
43
+ ok: false,
44
+ status: 0,
45
+ latencyMs: Date.now() - start,
46
+ error: (e as Error)?.message || String(e),
47
+ };
48
+ }
49
+ }