@tonyclaw/llm-inspector 1.10.0 → 1.11.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.
Files changed (74) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/index-BIZeMR5N.js +105 -0
  3. package/.output/public/assets/index-SmtLx1lM.css +1 -0
  4. package/.output/public/assets/main-QfbQ5oBP.js +17 -0
  5. package/.output/server/_libs/@radix-ui/react-use-controllable-state+[...].mjs +1 -1
  6. package/.output/server/_libs/ajv-formats.mjs +18 -18
  7. package/.output/server/_libs/ajv.mjs +6306 -942
  8. package/.output/server/_libs/cookie-es.mjs +21 -7
  9. package/.output/server/_libs/diff.mjs +320 -0
  10. package/.output/server/_libs/floating-ui__core.mjs +17 -18
  11. package/.output/server/_libs/floating-ui__dom.mjs +20 -24
  12. package/.output/server/_libs/floating-ui__react-dom.mjs +28 -55
  13. package/.output/server/_libs/floating-ui__utils.mjs +36 -36
  14. package/.output/server/_libs/h3-v2.mjs +9 -20
  15. package/.output/server/_libs/isbot.mjs +2 -3
  16. package/.output/server/_libs/json-schema-traverse.mjs +90 -1
  17. package/.output/server/_libs/jszip.mjs +28 -28
  18. package/.output/server/_libs/lucide-react.mjs +103 -80
  19. package/.output/server/_libs/mdast-util-from-markdown.mjs +1 -1
  20. package/.output/server/_libs/pako.mjs +13 -13
  21. package/.output/server/_libs/property-information.mjs +1 -13
  22. package/.output/server/_libs/radix-ui__react-collection.mjs +1 -1
  23. package/.output/server/_libs/radix-ui__react-id.mjs +1 -1
  24. package/.output/server/_libs/react-dom.mjs +10 -10
  25. package/.output/server/_libs/react.mjs +44 -44
  26. package/.output/server/_libs/readable-stream.mjs +15 -15
  27. package/.output/server/_libs/rou3.mjs +7 -1
  28. package/.output/server/_libs/safe-buffer.mjs +3 -3
  29. package/.output/server/_libs/semver.mjs +10 -10
  30. package/.output/server/_libs/seroval-plugins.mjs +5 -5
  31. package/.output/server/_libs/seroval.mjs +596 -606
  32. package/.output/server/_libs/srvx.mjs +245 -10
  33. package/.output/server/_libs/swr.mjs +1 -1
  34. package/.output/server/_libs/tailwind-merge.mjs +41 -286
  35. package/.output/server/_libs/tanstack__history.mjs +44 -31
  36. package/.output/server/_libs/tanstack__react-router.mjs +1089 -780
  37. package/.output/server/_libs/tanstack__react-store.mjs +1 -1
  38. package/.output/server/_libs/tanstack__react-virtual.mjs +7 -82
  39. package/.output/server/_libs/tanstack__router-core.mjs +2331 -2226
  40. package/.output/server/_libs/tanstack__virtual-core.mjs +17 -58
  41. package/.output/server/_libs/tiny-invariant.mjs +12 -0
  42. package/.output/server/_libs/tiny-warning.mjs +5 -0
  43. package/.output/server/_libs/ungap__structured-clone.mjs +2 -14
  44. package/.output/server/_libs/use-sync-external-store.mjs +1 -1
  45. package/.output/server/_libs/zod.mjs +207 -505
  46. package/.output/server/_ssr/{index-XyU4WVWt.mjs → index-I_Nx8QEB.mjs} +532 -79
  47. package/.output/server/_ssr/index.mjs +776 -1098
  48. package/.output/server/_ssr/{router-BR4XO8XJ.mjs → router-BVxfVFsb.mjs} +98 -53
  49. package/.output/server/_tanstack-start-manifest_v-Cm2Owszu.mjs +4 -0
  50. package/.output/server/index.mjs +28 -28
  51. package/README.md +1 -1
  52. package/package.json +2 -1
  53. package/src/components/ProxyViewer.tsx +10 -3
  54. package/src/components/ProxyViewerContainer.tsx +6 -0
  55. package/src/components/proxy-viewer/ConversationGroup.tsx +10 -1
  56. package/src/components/proxy-viewer/LogEntry.tsx +114 -5
  57. package/src/components/proxy-viewer/diff/DiffView.tsx +319 -0
  58. package/src/components/proxy-viewer/diff/computeDiff.ts +178 -0
  59. package/src/components/proxy-viewer/diff/index.ts +3 -0
  60. package/src/components/ui/crab-logo.tsx +45 -0
  61. package/src/proxy/config.ts +3 -17
  62. package/src/proxy/dataDir.ts +56 -0
  63. package/src/proxy/formats/anthropic/stream.ts +5 -2
  64. package/src/proxy/formats/jsonSchema.ts +12 -0
  65. package/src/proxy/formats/openai/stream.ts +21 -16
  66. package/src/proxy/handler.ts +15 -5
  67. package/src/proxy/providers.ts +61 -1
  68. package/src/proxy/schemas.ts +42 -0
  69. package/src/proxy/store.ts +6 -23
  70. package/.output/public/assets/index-BFuUQkWu.js +0 -11
  71. package/.output/public/assets/index-F2ojFPJp.css +0 -1
  72. package/.output/public/assets/index-nKnhyW4Y.js +0 -122
  73. package/.output/server/_ssr/empty-plugin-adapters-BFgPZ6_d.mjs +0 -6
  74. package/.output/server/_tanstack-start-manifest_v-DFoSjBkb.mjs +0 -4
@@ -0,0 +1,45 @@
1
+ import type { JSX } from "react";
2
+ import { cn } from "../../lib/utils";
3
+
4
+ /**
5
+ * Small inline-SVG crab mark used in the inspector header. Stroke-based so
6
+ * the colour follows `currentColor` — pair with a Tailwind text colour class
7
+ * (e.g. `text-amber-500`) to recolour without editing the SVG. Sized to
8
+ * match the title text by default; pass `className` to override.
9
+ *
10
+ * Plays on the project owner's `TonyClaw` handle and keeps the title bar
11
+ * visually distinct in the same way Claude Code's CLI uses a small
12
+ * block-cursor mark next to its name.
13
+ */
14
+ export function CrabLogo({ className }: { className?: string }): JSX.Element {
15
+ return (
16
+ <svg
17
+ viewBox="0 0 24 24"
18
+ fill="none"
19
+ stroke="currentColor"
20
+ strokeWidth="1.5"
21
+ strokeLinecap="round"
22
+ strokeLinejoin="round"
23
+ aria-hidden="true"
24
+ className={cn("inline-block size-5", className)}
25
+ >
26
+ {/* Body */}
27
+ <path d="M5 13 C5 9 8 7 12 7 C16 7 19 9 19 13 C19 16 16 18 12 18 C8 18 5 16 5 13 Z" />
28
+ {/* Left claw */}
29
+ <path d="M5 11 C3.5 9.5 1.5 10 2 12.5 C2.5 14 4 13.5 5 12.5" />
30
+ {/* Right claw */}
31
+ <path d="M19 11 C20.5 9.5 22.5 10 22 12.5 C21.5 14 20 13.5 19 12.5" />
32
+ {/* Eye stalks */}
33
+ <line x1="10" y1="7" x2="9.5" y2="5" />
34
+ <line x1="14" y1="7" x2="14.5" y2="5" />
35
+ {/* Eyes */}
36
+ <circle cx="9.5" cy="4.5" r="0.9" fill="currentColor" stroke="none" />
37
+ <circle cx="14.5" cy="4.5" r="0.9" fill="currentColor" stroke="none" />
38
+ {/* Legs */}
39
+ <line x1="6.5" y1="16" x2="4.5" y2="19.5" />
40
+ <line x1="9" y1="17.5" x2="8" y2="20.5" />
41
+ <line x1="15" y1="17.5" x2="16" y2="20.5" />
42
+ <line x1="17.5" y1="16" x2="19.5" y2="19.5" />
43
+ </svg>
44
+ );
45
+ }
@@ -7,8 +7,9 @@ import {
7
7
  renameSync,
8
8
  copyFileSync,
9
9
  } from "node:fs";
10
- import { join, isAbsolute } from "node:path";
10
+ import { join } from "node:path";
11
11
  import { logger } from "./logger";
12
+ import { getDataDir } from "./dataDir";
12
13
  import { RuntimeConfigSchema, type RuntimeConfig } from "../lib/runtimeConfig";
13
14
 
14
15
  // Re-export so server-side callers (api/config.ts, tests) keep the
@@ -33,22 +34,7 @@ export type { RuntimeConfig };
33
34
  * precedence over the env var for the lifetime of the process.
34
35
  */
35
36
 
36
- // --- Persistence (declared before init so getDataDir() is callable at module load) ---
37
-
38
- function getDataDir(): string {
39
- const isWindows = process.platform === "win32";
40
- const base = isWindows
41
- ? (process.env["APPDATA"] ?? join(process.env["USERPROFILE"] ?? "C:\\", ".llm-inspector"))
42
- : (process.env["HOME"] ?? "/tmp");
43
-
44
- // Read the env var on every call so tests can override the directory
45
- // after the module has been imported.
46
- const configDirEnv = process.env["LLM_INSPECTOR_CONFIG_DIR"];
47
- if (configDirEnv !== undefined) {
48
- return isAbsolute(configDirEnv) ? configDirEnv : join(base, configDirEnv);
49
- }
50
- return join(base, ".llm-inspector");
51
- }
37
+ // --- Persistence ---
52
38
 
53
39
  function getConfigFilePath(): string {
54
40
  return join(getDataDir(), "config.json");
@@ -0,0 +1,56 @@
1
+ import { isAbsolute, join } from "node:path";
2
+
3
+ /**
4
+ * Compute the directory where llm-inspector stores its on-disk state
5
+ * (runtime config, providers config, log chunks, etc.). Centralised here
6
+ * so every store agrees on the same path.
7
+ *
8
+ * Resolution order (first match wins):
9
+ * 1. `LLM_INSPECTOR_DATA_DIR` env var — absolute path used as-is,
10
+ * relative path joined to the OS-specific base below.
11
+ * 2. `LLM_INSPECTOR_CONFIG_DIR` env var — kept for backward compat
12
+ * with the runtime config; treated as a directory *name* (relative
13
+ * to the OS-specific base) when not absolute.
14
+ * 3. Windows: `%APPDATA%\.llm-inspector` (falls back to
15
+ * `%USERPROFILE%\.llm-inspector` if APPDATA is unset, then to
16
+ * `C:\.llm-inspector` as a last resort).
17
+ * macOS / Linux: `$HOME/.llm-inspector` (falls back to `/tmp`).
18
+ *
19
+ * The env var is re-read on every call so tests and the CLI can swap the
20
+ * directory after this module has been imported.
21
+ */
22
+ export function getDataDir(): string {
23
+ const isWindows = process.platform === "win32";
24
+ const base = isWindows
25
+ ? (process.env["APPDATA"] ?? join(process.env["USERPROFILE"] ?? "C:\\", ".llm-inspector"))
26
+ : (process.env["HOME"] ?? "/tmp");
27
+
28
+ const dirEnv = process.env["LLM_INSPECTOR_DATA_DIR"];
29
+ if (dirEnv !== undefined && dirEnv !== "") {
30
+ return isAbsolute(dirEnv) ? dirEnv : join(base, dirEnv);
31
+ }
32
+
33
+ // Backward-compat: the runtime config historically read
34
+ // `LLM_INSPECTOR_CONFIG_DIR` as a directory *name* (relative to base).
35
+ // Honour it so existing CLI scripts keep working.
36
+ const legacyDirEnv = process.env["LLM_INSPECTOR_CONFIG_DIR"];
37
+ if (legacyDirEnv !== undefined && legacyDirEnv !== "") {
38
+ return isAbsolute(legacyDirEnv) ? legacyDirEnv : join(base, legacyDirEnv);
39
+ }
40
+
41
+ return join(base, ".llm-inspector");
42
+ }
43
+
44
+ /**
45
+ * `true` when the user has explicitly asked for a non-default data
46
+ * directory. Used to skip the one-time legacy migration so we don't
47
+ * write to a path the user didn't choose.
48
+ */
49
+ export function hasExplicitDataDir(): boolean {
50
+ return (
51
+ (process.env["LLM_INSPECTOR_DATA_DIR"] !== undefined &&
52
+ process.env["LLM_INSPECTOR_DATA_DIR"] !== "") ||
53
+ (process.env["LLM_INSPECTOR_CONFIG_DIR"] !== undefined &&
54
+ process.env["LLM_INSPECTOR_CONFIG_DIR"] !== "")
55
+ );
56
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CapturedLog, JsonValue } from "../../schemas";
2
- import { JsonValueSchema } from "../jsonSchema";
2
+ import { trustAsJsonValue } from "../jsonSchema";
3
3
  import { SseEventSchema } from "./schemas";
4
4
 
5
5
  type StreamTextBlock = { type: "text"; text: string };
@@ -78,7 +78,10 @@ export function extractAnthropicStream(
78
78
  index: chunkIndex,
79
79
  timestamp: Date.now() - streamStartMs,
80
80
  type: data.type,
81
- data: JsonValueSchema.parse(data),
81
+ // `data` has already been validated by SseEventSchema above; the
82
+ // recursive JsonValue walk is a redundant full-tree zod parse on
83
+ // every SSE event. See trustAsJsonValue for the rationale.
84
+ data: trustAsJsonValue(data),
82
85
  });
83
86
  }
84
87
  chunkIndex++;
@@ -22,3 +22,15 @@ export const JsonValueSchema: z.ZodType<JsonValue> = z.lazy(() =>
22
22
  z.record(z.string(), JsonValueSchema),
23
23
  ]),
24
24
  );
25
+
26
+ /**
27
+ * Re-types a value as `JsonValue` without re-walking it. Use this ONLY when
28
+ * the value has already been validated (e.g., via `SseEventSchema.safeParse`)
29
+ * AND originated from `JSON.parse`. Skipping the recursive `JsonValueSchema`
30
+ * walk avoids a second full-tree zod parse on every event of every streaming
31
+ * response. Centralized here so the eslint-disable is one site, not two.
32
+ */
33
+ export function trustAsJsonValue<T>(value: T): JsonValue {
34
+ // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
35
+ return value as unknown as JsonValue;
36
+ }
@@ -1,5 +1,5 @@
1
1
  import type { CapturedLog, JsonValue } from "../../schemas";
2
- import { JsonValueSchema } from "../jsonSchema";
2
+ import { trustAsJsonValue } from "../jsonSchema";
3
3
  import { OpenAISSERawChunkSchema } from "./schemas";
4
4
 
5
5
  /**
@@ -21,12 +21,17 @@ export function extractOpenAIStream(
21
21
  let completionTokens = 0;
22
22
  let usageCaptured = false;
23
23
  let started = false;
24
- const toolCalls: Array<{
24
+ type ToolCallEntry = {
25
25
  index: number;
26
26
  id?: string;
27
27
  type: "function";
28
28
  function: { name: string; arguments: string };
29
- }> = [];
29
+ };
30
+ // Map keyed by `index` so delta accumulation is O(1) per delta instead of
31
+ // O(N) (the previous Array#find). Map preserves insertion order, so the
32
+ // final `[...toolCalls.values()]` keeps tool calls in the order they were
33
+ // first seen.
34
+ const toolCalls: Map<number, ToolCallEntry> = new Map();
30
35
 
31
36
  // Chunk collection state
32
37
  const MAX_CHUNKS = 1000;
@@ -66,15 +71,15 @@ export function extractOpenAIStream(
66
71
 
67
72
  // Collect chunks if enabled
68
73
  if (collectChunks === true && chunks.length < MAX_CHUNKS) {
69
- const jsonResult = JsonValueSchema.safeParse(chunk);
70
- if (jsonResult.success) {
71
- chunks.push({
72
- index: chunkIndex,
73
- timestamp: Date.now() - streamStartMs,
74
- type: "chat.completion.chunk",
75
- data: jsonResult.data,
76
- });
77
- }
74
+ // `chunk` has already been validated by OpenAISSERawChunkSchema above;
75
+ // skipping the recursive JsonValue walk avoids a second full-tree zod
76
+ // parse on every SSE event. See trustAsJsonValue for the rationale.
77
+ chunks.push({
78
+ index: chunkIndex,
79
+ timestamp: Date.now() - streamStartMs,
80
+ type: "chat.completion.chunk",
81
+ data: trustAsJsonValue(chunk),
82
+ });
78
83
  }
79
84
  chunkIndex++;
80
85
 
@@ -114,14 +119,14 @@ export function extractOpenAIStream(
114
119
  if (delta.tool_calls !== undefined && delta.tool_calls !== null) {
115
120
  for (const tc of delta.tool_calls) {
116
121
  // Find or create tool call entry by index
117
- let existing = toolCalls.find((t) => t.index === tc.index);
118
- if (!existing) {
122
+ let existing = toolCalls.get(tc.index);
123
+ if (existing === undefined) {
119
124
  existing = {
120
125
  index: tc.index,
121
126
  type: "function",
122
127
  function: { name: "", arguments: "" },
123
128
  };
124
- toolCalls.push(existing);
129
+ toolCalls.set(tc.index, existing);
125
130
  }
126
131
  if (tc.id !== undefined) existing.id = tc.id;
127
132
  if (tc.function?.name !== undefined) existing.function.name += tc.function.name;
@@ -154,7 +159,7 @@ export function extractOpenAIStream(
154
159
  content: completionContent,
155
160
  };
156
161
  if (reasoningContent) message.reasoning_content = reasoningContent;
157
- if (toolCalls.length > 0) message.tool_calls = toolCalls;
162
+ if (toolCalls.size > 0) message.tool_calls = [...toolCalls.values()];
158
163
 
159
164
  return JSON.stringify({
160
165
  id,
@@ -1,7 +1,7 @@
1
- import { createLog, emitLogUpdate, type CapturedLog } from "./store";
1
+ import { createLog, emitLogUpdate, getNextLogId, type CapturedLog } from "./store";
2
2
  import { appendLogEntry, logger } from "./logger";
3
3
  import { writeChunks } from "./chunkStorage";
4
- import { extractModelFromBody, type RequestFormat } from "./schemas";
4
+ import { extractModelFromBody, extractRequestMetadata, type RequestFormat } from "./schemas";
5
5
  import { registry } from "./formats";
6
6
  import { findProviderByModel } from "./providers";
7
7
  import { getClientInfo } from "./socketTracker";
@@ -332,8 +332,10 @@ export async function handleProxy(req: Request): Promise<Response> {
332
332
  }
333
333
  }
334
334
 
335
- // Extract model once and reuse - avoid duplicate parsing
336
- const model = requestBody !== null ? extractModelFromBody(requestBody) : null;
335
+ // Parse the request body exactly once. The handler needs the model for
336
+ // provider routing and the session id for the log; `createLog` would
337
+ // otherwise re-parse the same body to extract the same fields.
338
+ const { model, sessionId } = extractRequestMetadata(requestBody, req.headers);
337
339
 
338
340
  // Find provider config using already-extracted model (not calling extractModelFromBody again)
339
341
  const matchedProviderConfig = model !== null ? findProviderByModel(model) : null;
@@ -369,7 +371,12 @@ export async function handleProxy(req: Request): Promise<Response> {
369
371
  return new Response("Forbidden: unsupported format", { status: STATUS_FORBIDDEN });
370
372
  }
371
373
 
372
- const clientInfo = await getClientInfo(req);
374
+ // Acquire client info and the next log id in parallel. On Windows
375
+ // `getClientInfo` shells out to PowerShell and is the single slowest
376
+ // thing on the hot path; serializing it before `createLog` paid the
377
+ // full PowerShell round-trip on top of everything else before
378
+ // `fetch` is even issued.
379
+ const [clientInfo, preAcquiredId] = await Promise.all([getClientInfo(req), getNextLogId()]);
373
380
  const upstreamHeadersObj: Record<string, string> = {};
374
381
  upstreamHeaders.forEach((value, key) => {
375
382
  upstreamHeadersObj[key.toLowerCase()] = value;
@@ -383,6 +390,9 @@ export async function handleProxy(req: Request): Promise<Response> {
383
390
  rawHeaders,
384
391
  upstreamHeadersObj,
385
392
  formatHandler.format,
393
+ model,
394
+ sessionId,
395
+ preAcquiredId,
386
396
  );
387
397
 
388
398
  let upstreamRes: Response;
@@ -1,6 +1,10 @@
1
1
  import { z } from "zod";
2
2
  import Conf from "conf";
3
3
  import { randomUUID } from "crypto";
4
+ import { mkdirSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { logger } from "./logger";
7
+ import { getDataDir, hasExplicitDataDir } from "./dataDir";
4
8
 
5
9
  export const ProviderConfigSchema = z.object({
6
10
  id: z.string(),
@@ -31,17 +35,73 @@ const ProvidersStoreSchema = z.object({
31
35
 
32
36
  type ProvidersStore = z.infer<typeof ProvidersStoreSchema>;
33
37
 
38
+ /**
39
+ * One-time migration from the legacy conf v15 default location
40
+ * (`%APPDATA%\llm-inspector-nodejs\Config\config.json` on Windows,
41
+ * `$XDG_CONFIG_HOME/llm-inspector-nodejs/Config/config.json` on Linux).
42
+ * Older versions of llm-inspector let the `conf` library pick the path,
43
+ * which produced a project-suffixed directory under the OS user-config
44
+ * root. Users who upgrade now have their providers copied to the new
45
+ * `<dataDir>/providers.json` location; the legacy file is left in
46
+ * place for safety and can be deleted manually.
47
+ *
48
+ * The check is "new store is still empty" rather than "new file doesn't
49
+ * exist" because `Conf` materialises the defaults file on construction
50
+ * — so the file is always present immediately after the `new Conf()`
51
+ * call above, even on a brand-new install.
52
+ */
53
+ function migrateFromLegacyConfLocation(targetStore: Conf<ProvidersStore>): void {
54
+ if (process.env["LLM_INSPECTOR_CONFIG_PATH"] !== undefined) return;
55
+ if (hasExplicitDataDir()) return;
56
+ if (targetStore.get("providers", []).length > 0) return;
57
+ let legacyStore: Conf<ProvidersStore> | undefined;
58
+ try {
59
+ legacyStore = new Conf<ProvidersStore>({
60
+ projectName: "llm-inspector",
61
+ defaults: { providers: [] },
62
+ });
63
+ const legacyProviders = legacyStore.get("providers", []);
64
+ if (legacyProviders.length === 0) return;
65
+ targetStore.set("providers", legacyProviders);
66
+ logger.info(
67
+ `[providers] Migrated ${legacyProviders.length} provider(s) from ${legacyStore.path} to ${targetStore.path}`,
68
+ );
69
+ } catch (err) {
70
+ logger.warn(`[providers] Legacy migration failed: ${String(err)}`);
71
+ } finally {
72
+ legacyStore?._closeWatcher();
73
+ }
74
+ }
75
+
34
76
  // Using conf for storage - works in any Node.js environment without Electron
35
77
  // Note: conf stores data in plain JSON. For production, consider additional encryption.
36
78
  const configPath = process.env["LLM_INSPECTOR_CONFIG_PATH"];
79
+
80
+ // The data directory is shared with the runtime config (`config.ts`).
81
+ // We pass `cwd` so conf writes the file as `<cwd>/<configName>.<ext>`,
82
+ // i.e. `<dataDir>/providers.json`. The runtime config lives at
83
+ // `<dataDir>/config.json`; the different filenames keep the two stores
84
+ // from overwriting each other. `mkdirSync(..., { recursive: true })` is
85
+ // a no-op when the directory already exists.
86
+ const dataDir = getDataDir();
87
+ if (configPath === undefined) {
88
+ mkdirSync(dataDir, { recursive: true });
89
+ }
90
+
37
91
  export const store = new Conf<ProvidersStore>({
38
- projectName: "llm-inspector",
92
+ cwd: dataDir,
93
+ configName: "providers",
39
94
  defaults: {
40
95
  providers: [],
41
96
  },
42
97
  ...(configPath !== undefined ? { path: configPath } : {}),
43
98
  });
44
99
 
100
+ // Run the legacy migration once on module load. No-op when the new file
101
+ // already has data, when the user has set LLM_INSPECTOR_CONFIG_PATH or
102
+ // an explicit data dir, or when the legacy file is empty.
103
+ migrateFromLegacyConfLocation(store);
104
+
45
105
  /**
46
106
  * Migrates a single provider config to preserve both Anthropic and OpenAI URLs.
47
107
  */
@@ -145,6 +145,48 @@ export function extractModelFromBody(body: string): string | null {
145
145
  }
146
146
  }
147
147
 
148
+ /**
149
+ * Loose request schema used for routing + log metadata. Tolerant of missing
150
+ * fields so it can extract what it can from a partially-valid or unparseable
151
+ * body without throwing.
152
+ */
153
+ export const LooseRequestSchema = z.object({
154
+ model: z.string().optional(),
155
+ metadata: z.object({ user_id: z.string().optional() }).passthrough().optional(),
156
+ });
157
+
158
+ export type RequestMetadata = {
159
+ model: string | null;
160
+ sessionId: string | null;
161
+ };
162
+
163
+ /**
164
+ * Parse a request body exactly once and pull out everything the handler needs
165
+ * for routing + log creation: the model (used for provider selection) and the
166
+ * session id (from `metadata.user_id` or the `x-session-affinity` header).
167
+ *
168
+ * Returning `null` for either field is a valid outcome — callers must handle
169
+ * "unknown" gracefully. Callers that already have the body string should use
170
+ * this instead of re-parsing with `extractModelFromBody` and a second pass
171
+ * for the session id.
172
+ */
173
+ export function extractRequestMetadata(body: string | null, headers: Headers): RequestMetadata {
174
+ if (body === null) return { model: null, sessionId: null };
175
+ try {
176
+ const json: unknown = JSON.parse(body);
177
+ const loose = LooseRequestSchema.safeParse(json);
178
+ if (loose.success) {
179
+ return {
180
+ model: loose.data.model ?? null,
181
+ sessionId: loose.data.metadata?.user_id ?? headers.get("x-session-affinity") ?? null,
182
+ };
183
+ }
184
+ } catch {
185
+ // body not valid JSON
186
+ }
187
+ return { model: null, sessionId: null };
188
+ }
189
+
148
190
  /**
149
191
  * Parse a raw request body into an InspectorRequest.
150
192
  * @deprecated Use FormatHandler.parseRequest() instead
@@ -1,4 +1,3 @@
1
- import { z } from "zod";
2
1
  import { readFileSync, existsSync, createReadStream } from "node:fs";
3
2
  import { readdir as readdirCallback } from "node:fs/promises";
4
3
  import { createInterface } from "node:readline";
@@ -17,11 +16,7 @@ import type { CapturedLog } from "./schemas";
17
16
  import { CapturedLogSchema } from "./schemas";
18
17
 
19
18
  export type { CapturedLog };
20
-
21
- const LooseRequestSchema = z.object({
22
- model: z.string().optional(),
23
- metadata: z.object({ user_id: z.string().optional() }).passthrough().optional(),
24
- });
19
+ export { getNextLogId };
25
20
 
26
21
  const MAX_MEMORY_CACHE = 100;
27
22
 
@@ -142,27 +137,15 @@ export async function createLog(
142
137
  rawHeaders?: Record<string, string>,
143
138
  upstreamHeaders?: Record<string, string>,
144
139
  apiFormat: "anthropic" | "openai" | "unknown" = "unknown",
140
+ model: string | null = null,
141
+ sessionId: string | null = null,
142
+ /** Pre-acquired log id; if omitted, `getNextLogId` is awaited internally. */
143
+ preAcquiredId?: number,
145
144
  ): Promise<CapturedLog> {
146
- let model: string | null = null;
147
- let sessionId: string | null = null;
148
-
149
- if (requestBody !== null) {
150
- try {
151
- const json: unknown = JSON.parse(requestBody);
152
- const loose = LooseRequestSchema.safeParse(json);
153
- if (loose.success) {
154
- model = loose.data.model ?? null;
155
- sessionId = loose.data.metadata?.user_id ?? headers.get("x-session-affinity") ?? null;
156
- }
157
- } catch {
158
- // request body not valid JSON, skip parsing
159
- }
160
- }
161
-
162
145
  const userAgent = headers.get("user-agent");
163
146
  const origin = headers.get("origin");
164
147
 
165
- const id = await getNextLogId();
148
+ const id = preAcquiredId ?? (await getNextLogId());
166
149
  const log: CapturedLog = {
167
150
  id,
168
151
  timestamp: new Date().toISOString(),