@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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/index-BIZeMR5N.js +105 -0
- package/.output/public/assets/index-SmtLx1lM.css +1 -0
- package/.output/public/assets/main-QfbQ5oBP.js +17 -0
- package/.output/server/_libs/@radix-ui/react-use-controllable-state+[...].mjs +1 -1
- package/.output/server/_libs/ajv-formats.mjs +18 -18
- package/.output/server/_libs/ajv.mjs +6306 -942
- package/.output/server/_libs/cookie-es.mjs +21 -7
- package/.output/server/_libs/diff.mjs +320 -0
- package/.output/server/_libs/floating-ui__core.mjs +17 -18
- package/.output/server/_libs/floating-ui__dom.mjs +20 -24
- package/.output/server/_libs/floating-ui__react-dom.mjs +28 -55
- package/.output/server/_libs/floating-ui__utils.mjs +36 -36
- package/.output/server/_libs/h3-v2.mjs +9 -20
- package/.output/server/_libs/isbot.mjs +2 -3
- package/.output/server/_libs/json-schema-traverse.mjs +90 -1
- package/.output/server/_libs/jszip.mjs +28 -28
- package/.output/server/_libs/lucide-react.mjs +103 -80
- package/.output/server/_libs/mdast-util-from-markdown.mjs +1 -1
- package/.output/server/_libs/pako.mjs +13 -13
- package/.output/server/_libs/property-information.mjs +1 -13
- package/.output/server/_libs/radix-ui__react-collection.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-id.mjs +1 -1
- package/.output/server/_libs/react-dom.mjs +10 -10
- package/.output/server/_libs/react.mjs +44 -44
- package/.output/server/_libs/readable-stream.mjs +15 -15
- package/.output/server/_libs/rou3.mjs +7 -1
- package/.output/server/_libs/safe-buffer.mjs +3 -3
- package/.output/server/_libs/semver.mjs +10 -10
- package/.output/server/_libs/seroval-plugins.mjs +5 -5
- package/.output/server/_libs/seroval.mjs +596 -606
- package/.output/server/_libs/srvx.mjs +245 -10
- package/.output/server/_libs/swr.mjs +1 -1
- package/.output/server/_libs/tailwind-merge.mjs +41 -286
- package/.output/server/_libs/tanstack__history.mjs +44 -31
- package/.output/server/_libs/tanstack__react-router.mjs +1089 -780
- package/.output/server/_libs/tanstack__react-store.mjs +1 -1
- package/.output/server/_libs/tanstack__react-virtual.mjs +7 -82
- package/.output/server/_libs/tanstack__router-core.mjs +2331 -2226
- package/.output/server/_libs/tanstack__virtual-core.mjs +17 -58
- package/.output/server/_libs/tiny-invariant.mjs +12 -0
- package/.output/server/_libs/tiny-warning.mjs +5 -0
- package/.output/server/_libs/ungap__structured-clone.mjs +2 -14
- package/.output/server/_libs/use-sync-external-store.mjs +1 -1
- package/.output/server/_libs/zod.mjs +207 -505
- package/.output/server/_ssr/{index-XyU4WVWt.mjs → index-I_Nx8QEB.mjs} +532 -79
- package/.output/server/_ssr/index.mjs +776 -1098
- package/.output/server/_ssr/{router-BR4XO8XJ.mjs → router-BVxfVFsb.mjs} +98 -53
- package/.output/server/_tanstack-start-manifest_v-Cm2Owszu.mjs +4 -0
- package/.output/server/index.mjs +28 -28
- package/README.md +1 -1
- package/package.json +2 -1
- package/src/components/ProxyViewer.tsx +10 -3
- package/src/components/ProxyViewerContainer.tsx +6 -0
- package/src/components/proxy-viewer/ConversationGroup.tsx +10 -1
- package/src/components/proxy-viewer/LogEntry.tsx +114 -5
- package/src/components/proxy-viewer/diff/DiffView.tsx +319 -0
- package/src/components/proxy-viewer/diff/computeDiff.ts +178 -0
- package/src/components/proxy-viewer/diff/index.ts +3 -0
- package/src/components/ui/crab-logo.tsx +45 -0
- package/src/proxy/config.ts +3 -17
- package/src/proxy/dataDir.ts +56 -0
- package/src/proxy/formats/anthropic/stream.ts +5 -2
- package/src/proxy/formats/jsonSchema.ts +12 -0
- package/src/proxy/formats/openai/stream.ts +21 -16
- package/src/proxy/handler.ts +15 -5
- package/src/proxy/providers.ts +61 -1
- package/src/proxy/schemas.ts +42 -0
- package/src/proxy/store.ts +6 -23
- package/.output/public/assets/index-BFuUQkWu.js +0 -11
- package/.output/public/assets/index-F2ojFPJp.css +0 -1
- package/.output/public/assets/index-nKnhyW4Y.js +0 -122
- package/.output/server/_ssr/empty-plugin-adapters-BFgPZ6_d.mjs +0 -6
- 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
|
+
}
|
package/src/proxy/config.ts
CHANGED
|
@@ -7,8 +7,9 @@ import {
|
|
|
7
7
|
renameSync,
|
|
8
8
|
copyFileSync,
|
|
9
9
|
} from "node:fs";
|
|
10
|
-
import { join
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
118
|
-
if (
|
|
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.
|
|
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.
|
|
162
|
+
if (toolCalls.size > 0) message.tool_calls = [...toolCalls.values()];
|
|
158
163
|
|
|
159
164
|
return JSON.stringify({
|
|
160
165
|
id,
|
package/src/proxy/handler.ts
CHANGED
|
@@ -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
|
-
//
|
|
336
|
-
|
|
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
|
-
|
|
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;
|
package/src/proxy/providers.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
*/
|
package/src/proxy/schemas.ts
CHANGED
|
@@ -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
|
package/src/proxy/store.ts
CHANGED
|
@@ -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(),
|