@tonyclaw/llm-inspector 1.14.0 → 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.
- package/.output/nitro.json +1 -1
- package/.output/public/assets/index-DDrUlr6L.js +105 -0
- package/.output/public/assets/index-DOG5AdQ9.css +1 -0
- package/.output/public/assets/{main-C1k6vRnH.js → main-BElVT2p3.js} +1 -1
- package/.output/server/_libs/lucide-react.mjs +3 -3
- package/.output/server/_ssr/{index-AxruZp16.mjs → index-DmLit8Ad.mjs} +364 -212
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DtleGqN8.mjs → router-BoeSXWHG.mjs} +555 -293
- package/.output/server/{_tanstack-start-manifest_v-B1WAHWIa.mjs → _tanstack-start-manifest_v-B6idtbmL.mjs} +1 -1
- package/.output/server/index.mjs +27 -27
- package/package.json +1 -1
- package/src/components/providers/ProviderCard.tsx +139 -11
- package/src/components/providers/ProviderForm.tsx +240 -98
- package/src/components/providers/ProvidersPanel.tsx +75 -49
- package/src/lib/mask.ts +4 -0
- package/src/lib/providerContract.ts +2 -0
- package/src/lib/providerTestContract.ts +8 -0
- package/src/proxy/providers.ts +132 -22
- package/src/routes/api/providers.$providerId.test.log.ts +293 -0
- package/src/routes/api/providers.$providerId.ts +3 -1
- package/src/routes/api/providers.ts +9 -6
- package/.output/public/assets/index-B5q3Llgm.css +0 -1
- package/.output/public/assets/index-C6tbslcs.js +0 -105
|
@@ -57,15 +57,23 @@ const ProviderFormatTestResultsSchema = z.object({
|
|
|
57
57
|
streaming: ProviderTestStateSchema,
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
+
const ModelTestResultsSchema = z.object({
|
|
61
|
+
anthropic: ProviderFormatTestResultsSchema,
|
|
62
|
+
openai: ProviderFormatTestResultsSchema,
|
|
63
|
+
});
|
|
64
|
+
|
|
60
65
|
export const ProviderTestResultsSchema = z.object({
|
|
61
66
|
anthropic: ProviderFormatTestResultsSchema,
|
|
62
67
|
openai: ProviderFormatTestResultsSchema,
|
|
68
|
+
models: z.record(z.string(), ModelTestResultsSchema).optional(),
|
|
69
|
+
testedAt: z.string().optional(),
|
|
63
70
|
});
|
|
64
71
|
|
|
65
72
|
export type ProviderTestErrorType = z.infer<typeof ProviderTestErrorTypeSchema>;
|
|
66
73
|
export type ProviderTestResult = z.infer<typeof ProviderTestResultSchema>;
|
|
67
74
|
export type ProviderTestState = z.infer<typeof ProviderTestStateSchema>;
|
|
68
75
|
export type ProviderTestResults = z.infer<typeof ProviderTestResultsSchema>;
|
|
76
|
+
export type ModelTestResults = z.infer<typeof ModelTestResultsSchema>;
|
|
69
77
|
|
|
70
78
|
export function createPendingProviderTestResults(): ProviderTestResults {
|
|
71
79
|
return {
|
package/src/proxy/providers.ts
CHANGED
|
@@ -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) {
|
|
@@ -153,27 +226,63 @@ export function normalizeApiKey(apiKey: string): string {
|
|
|
153
226
|
export function addProvider(
|
|
154
227
|
name: string,
|
|
155
228
|
apiKey: string,
|
|
156
|
-
format
|
|
229
|
+
format?: "anthropic" | "openai",
|
|
157
230
|
baseUrl?: string,
|
|
158
|
-
|
|
231
|
+
models?: string[],
|
|
159
232
|
authHeader?: "bearer" | "x-api-key",
|
|
160
233
|
apiDocsUrl?: string,
|
|
234
|
+
anthropicBaseUrl?: string,
|
|
235
|
+
openaiBaseUrl?: string,
|
|
236
|
+
source?: "company" | "personal",
|
|
161
237
|
): ProviderConfig {
|
|
162
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 : "");
|
|
163
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
|
+
|
|
164
266
|
const newProvider: ProviderConfig = {
|
|
165
267
|
id: randomUUID(),
|
|
166
268
|
name,
|
|
167
|
-
apiKey:
|
|
168
|
-
format
|
|
269
|
+
apiKey: normalizedKey,
|
|
270
|
+
format:
|
|
271
|
+
format ??
|
|
272
|
+
(anthropicBaseUrl !== undefined
|
|
273
|
+
? "anthropic"
|
|
274
|
+
: openaiBaseUrl !== undefined
|
|
275
|
+
? "openai"
|
|
276
|
+
: undefined),
|
|
169
277
|
baseUrl,
|
|
170
|
-
|
|
278
|
+
models: models ?? [],
|
|
171
279
|
authHeader: authHeader ?? "bearer",
|
|
172
280
|
apiDocsUrl,
|
|
173
281
|
createdAt: now,
|
|
174
282
|
updatedAt: now,
|
|
175
|
-
anthropicBaseUrl:
|
|
176
|
-
openaiBaseUrl:
|
|
283
|
+
anthropicBaseUrl: resolvedAnthropicUrl,
|
|
284
|
+
openaiBaseUrl: resolvedOpenaiUrl,
|
|
285
|
+
source,
|
|
177
286
|
};
|
|
178
287
|
providers.push(newProvider);
|
|
179
288
|
store.set("providers", providers);
|
|
@@ -192,7 +301,7 @@ export function updateProvider(
|
|
|
192
301
|
id: existing.id,
|
|
193
302
|
name: updates.name ?? existing.name,
|
|
194
303
|
apiKey: updates.apiKey !== undefined ? normalizeApiKey(updates.apiKey) : existing.apiKey,
|
|
195
|
-
|
|
304
|
+
models: updates.models !== undefined ? updates.models : existing.models,
|
|
196
305
|
format: updates.format ?? existing.format,
|
|
197
306
|
baseUrl: updates.baseUrl !== undefined ? updates.baseUrl : existing.baseUrl,
|
|
198
307
|
authHeader: updates.authHeader ?? existing.authHeader,
|
|
@@ -204,6 +313,7 @@ export function updateProvider(
|
|
|
204
313
|
updates.anthropicBaseUrl !== undefined ? updates.anthropicBaseUrl : existing.anthropicBaseUrl,
|
|
205
314
|
openaiBaseUrl:
|
|
206
315
|
updates.openaiBaseUrl !== undefined ? updates.openaiBaseUrl : existing.openaiBaseUrl,
|
|
316
|
+
source: updates.source !== undefined ? updates.source : existing.source,
|
|
207
317
|
};
|
|
208
318
|
const index = providers.findIndex((p) => p.id === id);
|
|
209
319
|
providers[index] = updated;
|
|
@@ -359,21 +469,21 @@ export function findProviderByModel(model: string): ProviderConfig | null {
|
|
|
359
469
|
if (modelLower.startsWith(providerPrefix)) {
|
|
360
470
|
return provider;
|
|
361
471
|
}
|
|
362
|
-
// Strategy 2: match against provider.
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
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
|
+
}
|
|
369
478
|
}
|
|
370
479
|
// Strategy 3: fallback - concatenate provider name with model part and compare
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
+
}
|
|
377
487
|
}
|
|
378
488
|
}
|
|
379
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,11 +7,13 @@ 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().
|
|
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(),
|
|
14
15
|
apiDocsUrl: z.string().optional(),
|
|
16
|
+
source: z.enum(["company", "personal"]).optional(),
|
|
15
17
|
});
|
|
16
18
|
|
|
17
19
|
export const Route = createFileRoute("/api/providers/$providerId")({
|
|
@@ -5,12 +5,14 @@ import { getProviders, addProvider } from "../../proxy/providers";
|
|
|
5
5
|
const ProviderInputSchema = z.object({
|
|
6
6
|
name: z.string().min(1, "Name is required"),
|
|
7
7
|
apiKey: z.string().min(1, "API key is required"),
|
|
8
|
-
format: z.enum(["anthropic", "openai"]),
|
|
8
|
+
format: z.enum(["anthropic", "openai"]).optional(),
|
|
9
9
|
anthropicBaseUrl: z.string().optional(),
|
|
10
10
|
openaiBaseUrl: z.string().optional(),
|
|
11
|
-
|
|
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(),
|
|
15
|
+
source: z.enum(["company", "personal"]).optional(),
|
|
14
16
|
});
|
|
15
17
|
|
|
16
18
|
export const Route = createFileRoute("/api/providers")({
|
|
@@ -28,12 +30,13 @@ export const Route = createFileRoute("/api/providers")({
|
|
|
28
30
|
parsed.data.name,
|
|
29
31
|
parsed.data.apiKey,
|
|
30
32
|
parsed.data.format,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
: parsed.data.openaiBaseUrl,
|
|
34
|
-
parsed.data.model,
|
|
33
|
+
undefined, // baseUrl (legacy) — use format-specific URLs instead
|
|
34
|
+
parsed.data.models,
|
|
35
35
|
parsed.data.authHeader,
|
|
36
36
|
parsed.data.apiDocsUrl,
|
|
37
|
+
parsed.data.anthropicBaseUrl,
|
|
38
|
+
parsed.data.openaiBaseUrl,
|
|
39
|
+
parsed.data.source,
|
|
37
40
|
);
|
|
38
41
|
return Response.json(newProvider, { status: 201 });
|
|
39
42
|
},
|