@tonyclaw/llm-inspector 1.14.1 → 1.14.2

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.
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import Conf from "conf";
3
3
  import { randomUUID } from "crypto";
4
- import { mkdirSync } from "node:fs";
4
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { logger } from "./logger";
7
7
  import { getDataDir, hasExplicitDataDir } from "./dataDir";
@@ -119,6 +119,79 @@ function migrateProviders(): void {
119
119
  // Run migration on module load
120
120
  migrateProviders();
121
121
 
122
+ /**
123
+ * Migrate: merge providers with identical (apiKey, anthropicBaseUrl, openaiBaseUrl)
124
+ * into multi-model providers. Existing single-model providers get their `model`
125
+ * value promoted to `models: [model]`.
126
+ */
127
+ function migrateMultiModel(): void {
128
+ const providers = store.get("providers", []);
129
+ if (providers.length === 0) return;
130
+ let changed = false;
131
+
132
+ // Step 1: Promote single model to models array for any provider missing models
133
+ const promoted = providers.map((p) => {
134
+ if (p.models !== undefined && p.models.length > 0) return p;
135
+ const models = p.model !== undefined && p.model !== "" ? [p.model] : [];
136
+ changed = true;
137
+ return { ...p, models };
138
+ });
139
+
140
+ // Step 2: Merge providers with same (apiKey, anthropicBaseUrl, openaiBaseUrl)
141
+ const groups = new Map<string, ProviderConfig[]>();
142
+ for (const p of promoted) {
143
+ const key = `${p.apiKey}::${p.anthropicBaseUrl ?? ""}::${p.openaiBaseUrl ?? ""}`;
144
+ const group = groups.get(key);
145
+ if (group !== undefined) {
146
+ group.push(p);
147
+ } else {
148
+ groups.set(key, [p]);
149
+ }
150
+ }
151
+
152
+ const merged: ProviderConfig[] = [];
153
+ for (const group of groups.values()) {
154
+ const first = group[0];
155
+ if (group.length === 1 && first !== undefined) {
156
+ merged.push(first);
157
+ continue;
158
+ }
159
+ changed = true;
160
+ // Merge: keep the earliest provider's metadata, combine all models (deduplicated)
161
+ const sorted = group.toSorted(
162
+ (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
163
+ );
164
+ const earliest = sorted[0];
165
+ if (earliest === undefined) continue;
166
+ const allModels = new Set<string>();
167
+ for (const p of sorted) {
168
+ for (const m of p.models ?? []) {
169
+ if (m !== "") allModels.add(m);
170
+ }
171
+ }
172
+ // Take first non-empty source
173
+ const mergedSource = sorted.find((p) => p.source !== undefined)?.source;
174
+ merged.push({
175
+ ...earliest,
176
+ models: [...allModels],
177
+ source: mergedSource,
178
+ updatedAt: new Date().toISOString(),
179
+ });
180
+ }
181
+
182
+ if (changed && merged.length > 0) {
183
+ // Safety backup before writing
184
+ try {
185
+ writeFileSync(`${store.path}.bak`, readFileSync(store.path, "utf-8"));
186
+ } catch {
187
+ // best-effort, ignore
188
+ }
189
+ store.set("providers", merged);
190
+ }
191
+ }
192
+
193
+ migrateMultiModel();
194
+
122
195
  // Override with JSON env var if provided (for testing)
123
196
  const providersJson = process.env["LLM_INSPECTOR_PROVIDERS_JSON"];
124
197
  if (providersJson !== undefined) {
@@ -155,7 +228,7 @@ export function addProvider(
155
228
  apiKey: string,
156
229
  format?: "anthropic" | "openai",
157
230
  baseUrl?: string,
158
- model?: string,
231
+ models?: string[],
159
232
  authHeader?: "bearer" | "x-api-key",
160
233
  apiDocsUrl?: string,
161
234
  anthropicBaseUrl?: string,
@@ -163,11 +236,37 @@ export function addProvider(
163
236
  source?: "company" | "personal",
164
237
  ): ProviderConfig {
165
238
  const providers = getProviders();
239
+ const normalizedKey = normalizeApiKey(apiKey);
240
+ const resolvedAnthropicUrl =
241
+ anthropicBaseUrl ?? (format === "anthropic" && baseUrl !== undefined ? baseUrl : "");
242
+ const resolvedOpenaiUrl =
243
+ openaiBaseUrl ?? (format === "openai" && baseUrl !== undefined ? baseUrl : "");
166
244
  const now = new Date().toISOString();
245
+
246
+ // If a provider with the same (apiKey, anthropicBaseUrl, openaiBaseUrl) already exists,
247
+ // merge the new models into it instead of creating a duplicate.
248
+ const existing = providers.find(
249
+ (p) =>
250
+ p.apiKey === normalizedKey &&
251
+ (p.anthropicBaseUrl ?? "") === (resolvedAnthropicUrl ?? "") &&
252
+ (p.openaiBaseUrl ?? "") === (resolvedOpenaiUrl ?? ""),
253
+ );
254
+ if (existing) {
255
+ const newModels = (models ?? []).filter((m) => m !== "");
256
+ if (newModels.length > 0) {
257
+ const mergedModels = new Set(existing.models ?? []);
258
+ for (const m of newModels) mergedModels.add(m);
259
+ existing.models = [...mergedModels];
260
+ existing.updatedAt = now;
261
+ store.set("providers", providers);
262
+ }
263
+ return existing;
264
+ }
265
+
167
266
  const newProvider: ProviderConfig = {
168
267
  id: randomUUID(),
169
268
  name,
170
- apiKey: normalizeApiKey(apiKey),
269
+ apiKey: normalizedKey,
171
270
  format:
172
271
  format ??
173
272
  (anthropicBaseUrl !== undefined
@@ -176,14 +275,13 @@ export function addProvider(
176
275
  ? "openai"
177
276
  : undefined),
178
277
  baseUrl,
179
- model,
278
+ models: models ?? [],
180
279
  authHeader: authHeader ?? "bearer",
181
280
  apiDocsUrl,
182
281
  createdAt: now,
183
282
  updatedAt: now,
184
- anthropicBaseUrl:
185
- anthropicBaseUrl ?? (format === "anthropic" && baseUrl !== undefined ? baseUrl : ""),
186
- openaiBaseUrl: openaiBaseUrl ?? (format === "openai" && baseUrl !== undefined ? baseUrl : ""),
283
+ anthropicBaseUrl: resolvedAnthropicUrl,
284
+ openaiBaseUrl: resolvedOpenaiUrl,
187
285
  source,
188
286
  };
189
287
  providers.push(newProvider);
@@ -203,7 +301,7 @@ export function updateProvider(
203
301
  id: existing.id,
204
302
  name: updates.name ?? existing.name,
205
303
  apiKey: updates.apiKey !== undefined ? normalizeApiKey(updates.apiKey) : existing.apiKey,
206
- model: updates.model !== undefined ? updates.model : existing.model,
304
+ models: updates.models !== undefined ? updates.models : existing.models,
207
305
  format: updates.format ?? existing.format,
208
306
  baseUrl: updates.baseUrl !== undefined ? updates.baseUrl : existing.baseUrl,
209
307
  authHeader: updates.authHeader ?? existing.authHeader,
@@ -371,21 +469,21 @@ export function findProviderByModel(model: string): ProviderConfig | null {
371
469
  if (modelLower.startsWith(providerPrefix)) {
372
470
  return provider;
373
471
  }
374
- // Strategy 2: match against provider.model field with normalization
375
- if (
376
- provider.model !== undefined &&
377
- provider.model !== "" &&
378
- modelNormalized === normalizeModelName(provider.model)
379
- ) {
380
- return provider;
472
+ // Strategy 2: match against provider.models array with normalization
473
+ const modelList = provider.models ?? (provider.model !== undefined ? [provider.model] : []);
474
+ for (const m of modelList) {
475
+ if (m !== "" && modelNormalized === normalizeModelName(m)) {
476
+ return provider;
477
+ }
381
478
  }
382
479
  // Strategy 3: fallback - concatenate provider name with model part and compare
383
- // This handles cases like "MiniMax" + "M3" → "MiniMax-M3" when case differs
384
- if (provider.model !== undefined && provider.model !== "") {
385
- const modelPart = modelNormalized.replace(normalizeModelName(provider.name), "");
386
- const concatenated = normalizeModelName(provider.name) + modelPart;
387
- if (modelNormalized === concatenated) {
388
- return provider;
480
+ for (const m of modelList) {
481
+ if (m !== "") {
482
+ const modelPart = modelNormalized.replace(normalizeModelName(provider.name), "");
483
+ const concatenated = normalizeModelName(provider.name) + modelPart;
484
+ if (modelNormalized === concatenated) {
485
+ return provider;
486
+ }
389
487
  }
390
488
  }
391
489
  }
@@ -0,0 +1,293 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { getProvider, getModelUsageName } from "../../proxy/providers";
3
+ import { appendLogEntry } from "../../proxy/logger";
4
+ import { addTestLogEntry } from "../../proxy/store";
5
+ import {
6
+ ProviderTestResultsSchema,
7
+ type ProviderTestResult as TestResult,
8
+ type ProviderTestState,
9
+ } from "../../lib/providerTestContract";
10
+
11
+ function hasSuccessField(result: ProviderTestState): result is TestResult {
12
+ return Object.prototype.hasOwnProperty.call(result, "success");
13
+ }
14
+
15
+ function createTestLogEntry(
16
+ providerName: string,
17
+ path: string,
18
+ body: string,
19
+ upstreamUrl: string,
20
+ result: TestResult,
21
+ isTest: boolean,
22
+ ): Record<string, unknown> {
23
+ return {
24
+ timestamp: new Date().toISOString(),
25
+ id: `test-${Date.now()}`,
26
+ method: "POST",
27
+ path,
28
+ model: isTest ? result.model : undefined,
29
+ sessionId: null,
30
+ rawRequestBody: body,
31
+ responseStatus: result.success ? 200 : 500,
32
+ responseText: result.rawResponse ?? JSON.stringify(result),
33
+ inputTokens: result.inputTokens ?? null,
34
+ outputTokens: result.outputTokens ?? null,
35
+ elapsedMs: result.latencyMs ?? 0,
36
+ streaming: result.streaming ?? false,
37
+ userAgent: "provider-test",
38
+ origin: null,
39
+ upstreamUrl,
40
+ error: result.success ? null : (result.error?.message ?? String(result.error)),
41
+ isTest: true,
42
+ providerName,
43
+ headers: result.requestHeaders ?? {},
44
+ };
45
+ }
46
+
47
+ async function logModelResults(
48
+ displayName: string,
49
+ providerName: string,
50
+ anthropicUrl: string | undefined,
51
+ openaiUrl: string | undefined,
52
+ modelResults: {
53
+ anthropic: { nonStreaming: ProviderTestState; streaming: ProviderTestState };
54
+ openai: { nonStreaming: ProviderTestState; streaming: ProviderTestState };
55
+ },
56
+ ): Promise<void> {
57
+ const usageModel = getModelUsageName(displayName, providerName);
58
+
59
+ if (anthropicUrl !== undefined) {
60
+ const nsResult = modelResults.anthropic.nonStreaming;
61
+ const sResult = modelResults.anthropic.streaming;
62
+ if (hasSuccessField(nsResult) && hasSuccessField(sResult)) {
63
+ const nonStreamingResult = nsResult;
64
+ const streamingResult = sResult;
65
+
66
+ const requestBody = JSON.stringify({
67
+ model: usageModel,
68
+ messages: [{ role: "user", content: "say hello and briefly introduce yourself" }],
69
+ max_tokens: 1024,
70
+ });
71
+ const upstreamUrl = `${anthropicUrl}/v1/messages`;
72
+
73
+ await addTestLogEntry({
74
+ timestamp: new Date().toISOString(),
75
+ method: "POST",
76
+ path: "/v1/messages",
77
+ model: nonStreamingResult.model ?? displayName,
78
+ sessionId: null,
79
+ rawRequestBody: requestBody,
80
+ responseStatus: nonStreamingResult.success ? 200 : 500,
81
+ responseText: nonStreamingResult.rawResponse ?? JSON.stringify(nonStreamingResult),
82
+ inputTokens: nonStreamingResult.inputTokens ?? null,
83
+ outputTokens: nonStreamingResult.outputTokens ?? null,
84
+ cacheCreationInputTokens: nonStreamingResult.cacheCreationInputTokens ?? null,
85
+ cacheReadInputTokens: nonStreamingResult.cacheReadInputTokens ?? null,
86
+ elapsedMs: nonStreamingResult.latencyMs ?? 0,
87
+ streaming: false,
88
+ userAgent: "provider-test",
89
+ origin: null,
90
+ apiFormat: "anthropic",
91
+ isTest: true,
92
+ providerName,
93
+ headers: nonStreamingResult.requestHeaders ?? {},
94
+ });
95
+
96
+ appendLogEntry(
97
+ createTestLogEntry(
98
+ providerName,
99
+ "/v1/messages",
100
+ requestBody,
101
+ upstreamUrl,
102
+ nonStreamingResult,
103
+ true,
104
+ ),
105
+ );
106
+
107
+ const streamingRequestBody = JSON.stringify({
108
+ model: usageModel,
109
+ messages: [{ role: "user", content: "say hello" }],
110
+ max_tokens: 256,
111
+ stream: true,
112
+ });
113
+
114
+ await addTestLogEntry({
115
+ timestamp: new Date().toISOString(),
116
+ method: "POST",
117
+ path: "/v1/messages",
118
+ model: streamingResult.model ?? displayName,
119
+ sessionId: null,
120
+ rawRequestBody: streamingRequestBody,
121
+ responseStatus: streamingResult.success ? 200 : 500,
122
+ responseText: streamingResult.rawResponse ?? JSON.stringify(streamingResult),
123
+ inputTokens: streamingResult.inputTokens ?? null,
124
+ outputTokens: streamingResult.outputTokens ?? null,
125
+ cacheCreationInputTokens: streamingResult.cacheCreationInputTokens ?? null,
126
+ cacheReadInputTokens: streamingResult.cacheReadInputTokens ?? null,
127
+ elapsedMs: streamingResult.latencyMs ?? 0,
128
+ streaming: true,
129
+ streamingChunks: streamingResult.streamingChunks,
130
+ userAgent: "provider-test",
131
+ origin: null,
132
+ apiFormat: "anthropic",
133
+ isTest: true,
134
+ providerName,
135
+ headers: streamingResult.requestHeaders ?? {},
136
+ });
137
+
138
+ appendLogEntry(
139
+ createTestLogEntry(
140
+ providerName,
141
+ "/v1/messages",
142
+ streamingRequestBody,
143
+ upstreamUrl,
144
+ streamingResult,
145
+ true,
146
+ ),
147
+ );
148
+ }
149
+ }
150
+
151
+ if (openaiUrl !== undefined) {
152
+ const nsResult = modelResults.openai.nonStreaming;
153
+ const sResult = modelResults.openai.streaming;
154
+ if (hasSuccessField(nsResult) && hasSuccessField(sResult)) {
155
+ const nonStreamingResult = nsResult;
156
+ const streamingResult = sResult;
157
+
158
+ const requestBody = JSON.stringify({
159
+ model: usageModel,
160
+ messages: [{ role: "user", content: "say hello and briefly introduce yourself" }],
161
+ max_tokens: 1024,
162
+ });
163
+ const upstreamUrl = `${openaiUrl}/v1/chat/completions`;
164
+
165
+ await addTestLogEntry({
166
+ timestamp: new Date().toISOString(),
167
+ method: "POST",
168
+ path: "/v1/chat/completions",
169
+ model: nonStreamingResult.model ?? displayName,
170
+ sessionId: null,
171
+ rawRequestBody: requestBody,
172
+ responseStatus: nonStreamingResult.success ? 200 : 500,
173
+ responseText: nonStreamingResult.rawResponse ?? JSON.stringify(nonStreamingResult),
174
+ inputTokens: nonStreamingResult.inputTokens ?? null,
175
+ outputTokens: nonStreamingResult.outputTokens ?? null,
176
+ cacheCreationInputTokens: null,
177
+ cacheReadInputTokens: null,
178
+ elapsedMs: nonStreamingResult.latencyMs ?? 0,
179
+ streaming: false,
180
+ userAgent: "provider-test",
181
+ origin: null,
182
+ apiFormat: "openai",
183
+ isTest: true,
184
+ providerName,
185
+ headers: nonStreamingResult.requestHeaders ?? {},
186
+ });
187
+
188
+ appendLogEntry(
189
+ createTestLogEntry(
190
+ providerName,
191
+ "/v1/chat/completions",
192
+ requestBody,
193
+ upstreamUrl,
194
+ nonStreamingResult,
195
+ true,
196
+ ),
197
+ );
198
+
199
+ const streamingRequestBody = JSON.stringify({
200
+ model: usageModel,
201
+ messages: [{ role: "user", content: "say hello" }],
202
+ max_tokens: 256,
203
+ stream: true,
204
+ });
205
+
206
+ await addTestLogEntry({
207
+ timestamp: new Date().toISOString(),
208
+ method: "POST",
209
+ path: "/v1/chat/completions",
210
+ model: streamingResult.model ?? displayName,
211
+ sessionId: null,
212
+ rawRequestBody: streamingRequestBody,
213
+ responseStatus: streamingResult.success ? 200 : 500,
214
+ responseText: streamingResult.rawResponse ?? JSON.stringify(streamingResult),
215
+ inputTokens: streamingResult.inputTokens ?? null,
216
+ outputTokens: streamingResult.outputTokens ?? null,
217
+ cacheCreationInputTokens: null,
218
+ cacheReadInputTokens: null,
219
+ elapsedMs: streamingResult.latencyMs ?? 0,
220
+ streaming: true,
221
+ streamingChunks: streamingResult.streamingChunks,
222
+ userAgent: "provider-test",
223
+ origin: null,
224
+ apiFormat: "openai",
225
+ isTest: true,
226
+ providerName,
227
+ headers: streamingResult.requestHeaders ?? {},
228
+ });
229
+
230
+ appendLogEntry(
231
+ createTestLogEntry(
232
+ providerName,
233
+ "/v1/chat/completions",
234
+ streamingRequestBody,
235
+ upstreamUrl,
236
+ streamingResult,
237
+ true,
238
+ ),
239
+ );
240
+ }
241
+ }
242
+ }
243
+
244
+ export const Route = createFileRoute("/api/providers/$providerId/test/log")({
245
+ server: {
246
+ handlers: {
247
+ POST: async ({ params, request }: { params: { providerId: string }; request: Request }) => {
248
+ const provider = getProvider(params.providerId);
249
+ if (!provider) {
250
+ return Response.json({ error: "Provider not found" }, { status: 404 });
251
+ }
252
+
253
+ let body: unknown;
254
+ try {
255
+ body = await request.json();
256
+ } catch {
257
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
258
+ }
259
+
260
+ const parsed = ProviderTestResultsSchema.safeParse(body);
261
+ if (!parsed.success) {
262
+ return Response.json(
263
+ { error: `Invalid test results: ${parsed.error.message}` },
264
+ { status: 400 },
265
+ );
266
+ }
267
+
268
+ const results = parsed.data;
269
+ const anthropicUrl =
270
+ provider.anthropicBaseUrl !== undefined && provider.anthropicBaseUrl.length > 0
271
+ ? provider.anthropicBaseUrl
272
+ : undefined;
273
+ const openaiUrl =
274
+ provider.openaiBaseUrl !== undefined && provider.openaiBaseUrl.length > 0
275
+ ? provider.openaiBaseUrl
276
+ : undefined;
277
+
278
+ const entries: Promise<void>[] = [];
279
+ if (results.models !== undefined) {
280
+ for (const [modelName, modelResult] of Object.entries(results.models)) {
281
+ entries.push(
282
+ logModelResults(modelName, provider.name, anthropicUrl, openaiUrl, modelResult),
283
+ );
284
+ }
285
+ }
286
+
287
+ await Promise.all(entries);
288
+
289
+ return Response.json({ logged: entries.length });
290
+ },
291
+ },
292
+ },
293
+ });
@@ -7,7 +7,8 @@ const ProviderUpdateSchema = z.object({
7
7
  apiKey: z.string().min(1, "API key is required").optional(),
8
8
  format: z.enum(["anthropic", "openai"]).optional(),
9
9
  baseUrl: z.string().min(1, "Base URL is required").optional(),
10
- model: z.string().min(1, "Model is required").optional(),
10
+ model: z.string().optional(),
11
+ models: z.array(z.string()).optional(),
11
12
  authHeader: z.enum(["bearer", "x-api-key"]).optional(),
12
13
  anthropicBaseUrl: z.string().optional(),
13
14
  openaiBaseUrl: z.string().optional(),
@@ -8,7 +8,8 @@ const ProviderInputSchema = z.object({
8
8
  format: z.enum(["anthropic", "openai"]).optional(),
9
9
  anthropicBaseUrl: z.string().optional(),
10
10
  openaiBaseUrl: z.string().optional(),
11
- model: z.string().min(1, "Model is required"),
11
+ models: z.array(z.string()).min(1, "At least one model is required"),
12
+ model: z.string().optional(),
12
13
  authHeader: z.enum(["bearer", "x-api-key"]).optional().default("bearer"),
13
14
  apiDocsUrl: z.string().optional(),
14
15
  source: z.enum(["company", "personal"]).optional(),
@@ -30,7 +31,7 @@ export const Route = createFileRoute("/api/providers")({
30
31
  parsed.data.apiKey,
31
32
  parsed.data.format,
32
33
  undefined, // baseUrl (legacy) — use format-specific URLs instead
33
- parsed.data.model,
34
+ parsed.data.models,
34
35
  parsed.data.authHeader,
35
36
  parsed.data.apiDocsUrl,
36
37
  parsed.data.anthropicBaseUrl,