@tonyclaw/llm-inspector 1.11.4 → 1.11.6
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-BGxRftQy.css +1 -0
- package/.output/public/assets/{index-CjjXIYIt.js → index-BHjxAEHw.js} +14 -14
- package/.output/public/assets/{index-Do7wdaYZ.js → index-BLz26_QE.js} +1 -1
- package/.output/server/_ssr/{index-BWMVJy33.mjs → index-BzG8P6B8.mjs} +52 -9
- package/.output/server/_ssr/index.mjs +2 -2
- package/.output/server/_ssr/{router-DRA0j7Zv.mjs → router-BOZDxiEC.mjs} +99 -159
- package/.output/server/{_tanstack-start-manifest_v-DKfW4gRz.mjs → _tanstack-start-manifest_v-C_jssHYZ.mjs} +1 -1
- package/.output/server/index.mjs +24 -24
- package/package.json +1 -1
- package/src/components/ProxyViewer.tsx +5 -3
- package/src/components/providers/ProviderCard.tsx +57 -1
- package/src/components/providers/ProvidersPanel.tsx +29 -0
- package/src/proxy/formats/registry.ts +2 -6
- package/src/proxy/handler.ts +25 -119
- package/src/proxy/providers.ts +22 -39
- package/src/proxy/upstream.ts +90 -0
- package/src/routes/api/logs.$id.replay.ts +17 -90
- package/.output/public/assets/index-BpKPXEcb.css +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type JSX, useState } from "react";
|
|
1
|
+
import { type JSX, type ReactNode, useState } from "react";
|
|
2
2
|
import { Button } from "../ui/button";
|
|
3
3
|
import {
|
|
4
4
|
Eye,
|
|
@@ -45,6 +45,35 @@ type EnhancedError = {
|
|
|
45
45
|
type TestResult = {
|
|
46
46
|
success: boolean;
|
|
47
47
|
error?: EnhancedError;
|
|
48
|
+
model?: string;
|
|
49
|
+
inputTokens?: number;
|
|
50
|
+
outputTokens?: number;
|
|
51
|
+
cacheCreationInputTokens?: number;
|
|
52
|
+
cacheReadInputTokens?: number;
|
|
53
|
+
latencyMs?: number;
|
|
54
|
+
content?: ResponseContent[];
|
|
55
|
+
rawResponse?: string;
|
|
56
|
+
streaming?: boolean;
|
|
57
|
+
streamingChunks?: StreamingChunksData;
|
|
58
|
+
requestHeaders?: Record<string, string>;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
type ResponseContent = {
|
|
62
|
+
type: "text" | "thinking";
|
|
63
|
+
text?: string;
|
|
64
|
+
thinking?: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type StreamingChunksData = {
|
|
68
|
+
chunks: StreamingChunk[];
|
|
69
|
+
truncated?: boolean;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type StreamingChunk = {
|
|
73
|
+
index: number;
|
|
74
|
+
timestamp: number;
|
|
75
|
+
type: string;
|
|
76
|
+
data: unknown;
|
|
48
77
|
};
|
|
49
78
|
|
|
50
79
|
type NotConfigured = { notConfigured: true };
|
|
@@ -133,10 +162,37 @@ function TestStatus({ result }: { result: TestResult | NotConfigured | Testing }
|
|
|
133
162
|
);
|
|
134
163
|
}
|
|
135
164
|
if (result.success) {
|
|
165
|
+
const tokenParts: ReactNode[] = [];
|
|
166
|
+
if (result.inputTokens !== undefined) {
|
|
167
|
+
tokenParts.push(<span key="in">{result.inputTokens} in</span>);
|
|
168
|
+
}
|
|
169
|
+
if (result.outputTokens !== undefined) {
|
|
170
|
+
tokenParts.push(<span key="out">{result.outputTokens} out</span>);
|
|
171
|
+
}
|
|
172
|
+
if (result.cacheCreationInputTokens !== undefined && result.cacheCreationInputTokens > 0) {
|
|
173
|
+
tokenParts.push(
|
|
174
|
+
<span key="cache-create" className="font-mono tabular-nums text-emerald-400">
|
|
175
|
+
+{result.cacheCreationInputTokens} cache
|
|
176
|
+
</span>,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (result.cacheReadInputTokens !== undefined && result.cacheReadInputTokens > 0) {
|
|
180
|
+
tokenParts.push(
|
|
181
|
+
<span key="cache-read" className="font-mono tabular-nums text-purple-400">
|
|
182
|
+
~{result.cacheReadInputTokens} cached
|
|
183
|
+
</span>,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const displayTokens: ReactNode[] = [];
|
|
187
|
+
for (let i = 0; i < tokenParts.length; i++) {
|
|
188
|
+
if (i > 0) displayTokens.push(", ");
|
|
189
|
+
displayTokens.push(tokenParts[i]);
|
|
190
|
+
}
|
|
136
191
|
return (
|
|
137
192
|
<div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
|
|
138
193
|
<CheckCircle className="size-3" />
|
|
139
194
|
<span>Connected</span>
|
|
195
|
+
{tokenParts.length > 0 && <span className="text-muted-foreground">({displayTokens})</span>}
|
|
140
196
|
</div>
|
|
141
197
|
);
|
|
142
198
|
}
|
|
@@ -28,6 +28,35 @@ type EnhancedError = {
|
|
|
28
28
|
type TestResult = {
|
|
29
29
|
success: boolean;
|
|
30
30
|
error?: EnhancedError;
|
|
31
|
+
model?: string;
|
|
32
|
+
inputTokens?: number;
|
|
33
|
+
outputTokens?: number;
|
|
34
|
+
cacheCreationInputTokens?: number;
|
|
35
|
+
cacheReadInputTokens?: number;
|
|
36
|
+
latencyMs?: number;
|
|
37
|
+
content?: ResponseContent[];
|
|
38
|
+
rawResponse?: string;
|
|
39
|
+
streaming?: boolean;
|
|
40
|
+
streamingChunks?: StreamingChunksData;
|
|
41
|
+
requestHeaders?: Record<string, string>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type ResponseContent = {
|
|
45
|
+
type: "text" | "thinking";
|
|
46
|
+
text?: string;
|
|
47
|
+
thinking?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type StreamingChunksData = {
|
|
51
|
+
chunks: StreamingChunk[];
|
|
52
|
+
truncated?: boolean;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
type StreamingChunk = {
|
|
56
|
+
index: number;
|
|
57
|
+
timestamp: number;
|
|
58
|
+
type: string;
|
|
59
|
+
data: unknown;
|
|
31
60
|
};
|
|
32
61
|
|
|
33
62
|
type NotConfigured = { notConfigured: true };
|
|
@@ -27,12 +27,8 @@ class FormatRegistryImpl {
|
|
|
27
27
|
/** Get handler matching a request path */
|
|
28
28
|
getByPath(path: string): FormatHandler | undefined {
|
|
29
29
|
const messagesPath = path.split("?")[0] ?? "";
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (messagesPath === PATH_V1_CHAT_COMPLETIONS || messagesPath === PATH_CHAT_COMPLETIONS) {
|
|
33
|
-
return this.handlers.get("openai");
|
|
34
|
-
}
|
|
35
|
-
return undefined;
|
|
30
|
+
const format = this.pathMap.get(messagesPath);
|
|
31
|
+
return format === undefined ? undefined : this.handlers.get(format);
|
|
36
32
|
}
|
|
37
33
|
|
|
38
34
|
/** Detect format from request body content */
|
package/src/proxy/handler.ts
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
1
|
import { createLog, emitLogUpdate, getNextLogId, type CapturedLog } from "./store";
|
|
2
2
|
import { appendLogEntry, logger } from "./logger";
|
|
3
3
|
import { writeChunks } from "./chunkStorage";
|
|
4
|
-
import {
|
|
4
|
+
import { extractRequestMetadata } from "./schemas";
|
|
5
5
|
import { registry } from "./formats";
|
|
6
6
|
import { findProviderByModel } from "./providers";
|
|
7
7
|
import { getClientInfo } from "./socketTracker";
|
|
8
|
-
import { formatForPath,
|
|
8
|
+
import { formatForPath, type FormatHandler } from "./formats";
|
|
9
9
|
import {
|
|
10
|
-
DEFAULT_UPSTREAM,
|
|
11
|
-
DEFAULT_OPENAI_UPSTREAM,
|
|
12
10
|
PROXY_IDENTITY,
|
|
13
11
|
PRESERVE_HEADERS,
|
|
14
|
-
PATH_V1_CHAT_COMPLETIONS,
|
|
15
|
-
PATH_CHAT_COMPLETIONS,
|
|
16
12
|
PATH_V1_MESSAGES,
|
|
17
13
|
HEADER_CONTENT_TYPE,
|
|
18
14
|
HEADER_USER_AGENT,
|
|
19
15
|
HEADER_X_PROXY_IDENTITY,
|
|
20
|
-
HEADER_AUTHORIZATION,
|
|
21
|
-
HEADER_X_API_KEY,
|
|
22
16
|
HEADER_CONTENT_ENCODING,
|
|
23
17
|
HEADER_CONTENT_LENGTH,
|
|
24
|
-
HEADER_HOST,
|
|
25
|
-
AUTH_HEADER_X_API_KEY,
|
|
26
18
|
CONTENT_TYPE_EVENT_STREAM,
|
|
27
19
|
STATUS_FORBIDDEN,
|
|
28
20
|
STATUS_BAD_GATEWAY,
|
|
29
21
|
} from "./constants";
|
|
30
22
|
import { getConfig } from "./config";
|
|
31
23
|
import { stripClaudeCodeBillingHeader } from "./claudeCodeStrip";
|
|
24
|
+
import {
|
|
25
|
+
buildUpstreamUrl,
|
|
26
|
+
describeApiRoute,
|
|
27
|
+
getProxyApiPath,
|
|
28
|
+
injectProviderAuth,
|
|
29
|
+
selectUpstreamBase,
|
|
30
|
+
setUpstreamHost,
|
|
31
|
+
} from "./upstream";
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Strips all custom/non-standard headers from the request and replaces with
|
|
@@ -58,15 +58,6 @@ function buildProxyHeaders(originalHeaders: Headers): {
|
|
|
58
58
|
return { headers, rawHeaders };
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
function getHostFromUrl(urlStr: string): string {
|
|
62
|
-
try {
|
|
63
|
-
const url = new URL(urlStr);
|
|
64
|
-
return url.host;
|
|
65
|
-
} catch {
|
|
66
|
-
return "api.anthropic.com";
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
61
|
function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string, unknown> {
|
|
71
62
|
return {
|
|
72
63
|
timestamp: log.timestamp,
|
|
@@ -97,103 +88,22 @@ function buildFileLogEntry(log: CapturedLog, upstreamUrl: string): Record<string
|
|
|
97
88
|
|
|
98
89
|
type ParsedRequestPath = {
|
|
99
90
|
apiPath: string;
|
|
100
|
-
messagesPath: string;
|
|
101
|
-
isChatCompletionsV1: boolean;
|
|
102
|
-
isChatCompletions: boolean;
|
|
103
91
|
isMessages: boolean;
|
|
104
92
|
normalizedPath: string;
|
|
105
93
|
};
|
|
106
94
|
|
|
107
95
|
function parseRequestPath(req: Request, url: URL): ParsedRequestPath {
|
|
108
|
-
const
|
|
109
|
-
const messagesPath = apiPath.split("?")[0] ?? "";
|
|
110
|
-
|
|
111
|
-
const isChatCompletionsV1 = messagesPath === PATH_V1_CHAT_COMPLETIONS;
|
|
112
|
-
const isChatCompletions = messagesPath === PATH_CHAT_COMPLETIONS || isChatCompletionsV1;
|
|
96
|
+
const route = describeApiRoute(getProxyApiPath(url));
|
|
113
97
|
const isMessages =
|
|
114
|
-
req.method === "POST" &&
|
|
115
|
-
(messagesPath === PATH_V1_MESSAGES ||
|
|
116
|
-
messagesPath === PATH_V1_CHAT_COMPLETIONS ||
|
|
117
|
-
messagesPath === PATH_CHAT_COMPLETIONS);
|
|
118
|
-
|
|
119
|
-
const normalizedPath =
|
|
120
|
-
isChatCompletions && !apiPath.startsWith("/v1/") ? "/v1" + apiPath : apiPath;
|
|
98
|
+
req.method === "POST" && (route.endpointPath === PATH_V1_MESSAGES || route.isChatCompletions);
|
|
121
99
|
|
|
122
100
|
return {
|
|
123
|
-
apiPath,
|
|
124
|
-
messagesPath,
|
|
125
|
-
isChatCompletionsV1,
|
|
126
|
-
isChatCompletions,
|
|
101
|
+
apiPath: route.apiPath,
|
|
127
102
|
isMessages,
|
|
128
|
-
normalizedPath,
|
|
103
|
+
normalizedPath: route.normalizedPath,
|
|
129
104
|
};
|
|
130
105
|
}
|
|
131
106
|
|
|
132
|
-
function buildUpstreamUrl(upstreamBase: string, normalizedPath: string): string {
|
|
133
|
-
// Remove trailing slash from upstreamBase to avoid double slashes
|
|
134
|
-
const base = upstreamBase.endsWith("/") ? upstreamBase.slice(0, -1) : upstreamBase;
|
|
135
|
-
// Handle case where upstreamBase already ends with /v1 and normalizedPath starts with /v1/
|
|
136
|
-
// to avoid double /v1/v1/ duplication
|
|
137
|
-
if (base.endsWith("/v1") && normalizedPath.startsWith("/v1/")) {
|
|
138
|
-
return base + normalizedPath.slice(3); // Remove leading /v1 from path
|
|
139
|
-
}
|
|
140
|
-
return base + normalizedPath;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function selectUpstreamBase(
|
|
144
|
-
isChatCompletions: boolean,
|
|
145
|
-
matchedProviderConfig: ReturnType<typeof findProviderByModel>,
|
|
146
|
-
): string {
|
|
147
|
-
let upstreamBase: string;
|
|
148
|
-
|
|
149
|
-
if (matchedProviderConfig) {
|
|
150
|
-
// Choose URL based on request format: ChatCompletions uses OpenAI URL, otherwise Anthropic URL
|
|
151
|
-
if (
|
|
152
|
-
isChatCompletions &&
|
|
153
|
-
matchedProviderConfig.openaiBaseUrl !== undefined &&
|
|
154
|
-
matchedProviderConfig.openaiBaseUrl !== ""
|
|
155
|
-
) {
|
|
156
|
-
upstreamBase = matchedProviderConfig.openaiBaseUrl;
|
|
157
|
-
} else if (
|
|
158
|
-
!isChatCompletions &&
|
|
159
|
-
matchedProviderConfig.anthropicBaseUrl !== undefined &&
|
|
160
|
-
matchedProviderConfig.anthropicBaseUrl !== ""
|
|
161
|
-
) {
|
|
162
|
-
upstreamBase = matchedProviderConfig.anthropicBaseUrl;
|
|
163
|
-
} else if (
|
|
164
|
-
matchedProviderConfig.baseUrl !== undefined &&
|
|
165
|
-
matchedProviderConfig.baseUrl !== ""
|
|
166
|
-
) {
|
|
167
|
-
// Fallback to baseUrl for backward compatibility
|
|
168
|
-
upstreamBase = matchedProviderConfig.baseUrl;
|
|
169
|
-
} else {
|
|
170
|
-
// Fall back to defaults based on format
|
|
171
|
-
upstreamBase =
|
|
172
|
-
matchedProviderConfig.format === "openai" ? DEFAULT_OPENAI_UPSTREAM : DEFAULT_UPSTREAM;
|
|
173
|
-
}
|
|
174
|
-
} else {
|
|
175
|
-
// No provider config, use defaults
|
|
176
|
-
upstreamBase = isChatCompletions ? DEFAULT_OPENAI_UPSTREAM : DEFAULT_UPSTREAM;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return upstreamBase;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function injectAuthHeaders(
|
|
183
|
-
upstreamHeaders: Headers,
|
|
184
|
-
matchedProviderConfig: ReturnType<typeof findProviderByModel>,
|
|
185
|
-
): void {
|
|
186
|
-
if (!matchedProviderConfig) return;
|
|
187
|
-
|
|
188
|
-
const apiKey = matchedProviderConfig.apiKey.replace(/^Bearer\s+/i, "").trim();
|
|
189
|
-
if (matchedProviderConfig.authHeader === AUTH_HEADER_X_API_KEY) {
|
|
190
|
-
upstreamHeaders.set(HEADER_X_API_KEY, apiKey);
|
|
191
|
-
upstreamHeaders.delete(HEADER_AUTHORIZATION);
|
|
192
|
-
} else {
|
|
193
|
-
upstreamHeaders.set(HEADER_AUTHORIZATION, `Bearer ${apiKey}`);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
107
|
function handleNonStreamingResponse(
|
|
198
108
|
upstreamRes: Response,
|
|
199
109
|
responseBody: string,
|
|
@@ -339,14 +249,14 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
339
249
|
|
|
340
250
|
// Find provider config using already-extracted model (not calling extractModelFromBody again)
|
|
341
251
|
const matchedProviderConfig = model !== null ? findProviderByModel(model) : null;
|
|
342
|
-
const
|
|
252
|
+
const route = describeApiRoute(parsed.apiPath);
|
|
253
|
+
const upstreamBase = selectUpstreamBase(route, matchedProviderConfig);
|
|
343
254
|
const upstreamUrl = buildUpstreamUrl(upstreamBase, parsed.normalizedPath);
|
|
344
|
-
const upstreamHost = getHostFromUrl(upstreamBase);
|
|
345
255
|
const startTime = Date.now();
|
|
346
256
|
|
|
347
257
|
const { headers: upstreamHeaders, rawHeaders } = buildProxyHeaders(req.headers);
|
|
348
|
-
upstreamHeaders
|
|
349
|
-
|
|
258
|
+
setUpstreamHost(upstreamHeaders, upstreamBase);
|
|
259
|
+
injectProviderAuth(upstreamHeaders, matchedProviderConfig);
|
|
350
260
|
|
|
351
261
|
const provider = model !== null ? registry.findProvider(model) : null;
|
|
352
262
|
|
|
@@ -356,17 +266,13 @@ export async function handleProxy(req: Request): Promise<Response> {
|
|
|
356
266
|
return new Response("Forbidden: unsupported provider", { status: STATUS_FORBIDDEN });
|
|
357
267
|
}
|
|
358
268
|
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
formatHandler = formatRegistry.get(matchedProviderConfig.format) ?? null;
|
|
367
|
-
} else {
|
|
368
|
-
formatHandler = formatForPath(parsed.apiPath);
|
|
369
|
-
}
|
|
269
|
+
// Pick the format handler from the request path. provider.format is only a
|
|
270
|
+
// hint used as a fallback when no anthropicBaseUrl/openaiBaseUrl is set, and
|
|
271
|
+
// does NOT describe what the upstream actually returns: a provider configured
|
|
272
|
+
// with format="openai" may still expose an Anthropic-compatible endpoint
|
|
273
|
+
// (e.g. MiniMax's /anthropic path), and the response shape always follows
|
|
274
|
+
// the URL path the client hit, not the provider.format label.
|
|
275
|
+
const formatHandler: FormatHandler | null = formatForPath(parsed.apiPath);
|
|
370
276
|
if (formatHandler === null) {
|
|
371
277
|
return new Response("Forbidden: unsupported format", { status: STATUS_FORBIDDEN });
|
|
372
278
|
}
|
package/src/proxy/providers.ts
CHANGED
|
@@ -103,50 +103,22 @@ export const store = new Conf<ProvidersStore>({
|
|
|
103
103
|
migrateFromLegacyConfLocation(store);
|
|
104
104
|
|
|
105
105
|
/**
|
|
106
|
-
*
|
|
106
|
+
* Expands the old `{ format, baseUrl }` representation into the matching
|
|
107
|
+
* format-specific URL. Once either format-specific field exists, the record is
|
|
108
|
+
* already on the current schema and must be preserved exactly: an empty or
|
|
109
|
+
* missing counterpart means that protocol was intentionally not configured.
|
|
107
110
|
*/
|
|
108
111
|
export function migrateProvider(p: ProviderConfig): ProviderConfig {
|
|
109
|
-
|
|
110
|
-
const currentOpenaiUrl = p.openaiBaseUrl ?? "";
|
|
111
|
-
|
|
112
|
-
// If already migrated (has at least one URL set), skip
|
|
113
|
-
// format may be undefined but if URL is set, it's already migrated
|
|
114
|
-
if (currentAnthropicUrl !== "" || currentOpenaiUrl !== "") {
|
|
112
|
+
if (p.anthropicBaseUrl !== undefined || p.openaiBaseUrl !== undefined) {
|
|
115
113
|
return p;
|
|
116
114
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
let format: "anthropic" | "openai" | undefined;
|
|
120
|
-
let baseUrl: string | undefined;
|
|
121
|
-
|
|
122
|
-
if (currentAnthropicUrl !== "" && currentOpenaiUrl !== "") {
|
|
123
|
-
// Both URLs set - prefer anthropic as default, use baseUrl for backward compat
|
|
124
|
-
format = p.format ?? "anthropic";
|
|
125
|
-
baseUrl = p.baseUrl !== undefined && p.baseUrl !== "" ? p.baseUrl : currentAnthropicUrl;
|
|
126
|
-
} else if (currentOpenaiUrl !== "") {
|
|
127
|
-
format = "openai";
|
|
128
|
-
baseUrl = currentOpenaiUrl;
|
|
129
|
-
} else if (currentAnthropicUrl !== "") {
|
|
130
|
-
format = "anthropic";
|
|
131
|
-
baseUrl = currentAnthropicUrl;
|
|
132
|
-
} else if (p.format !== undefined && p.baseUrl !== undefined && p.baseUrl !== "") {
|
|
133
|
-
// Only baseUrl is set (legacy config) - migrate based on format
|
|
134
|
-
format = p.format;
|
|
135
|
-
baseUrl = p.baseUrl;
|
|
136
|
-
if (format === "openai") {
|
|
137
|
-
return { ...p, format, baseUrl, anthropicBaseUrl: "", openaiBaseUrl: p.baseUrl };
|
|
138
|
-
} else {
|
|
139
|
-
return { ...p, format, baseUrl, anthropicBaseUrl: p.baseUrl, openaiBaseUrl: "" };
|
|
140
|
-
}
|
|
115
|
+
if (p.format === undefined || p.baseUrl === undefined || p.baseUrl === "") {
|
|
116
|
+
return p;
|
|
141
117
|
}
|
|
142
118
|
|
|
143
|
-
return
|
|
144
|
-
...p,
|
|
145
|
-
|
|
146
|
-
baseUrl,
|
|
147
|
-
anthropicBaseUrl: currentAnthropicUrl,
|
|
148
|
-
openaiBaseUrl: currentOpenaiUrl,
|
|
149
|
-
};
|
|
119
|
+
return p.format === "openai"
|
|
120
|
+
? { ...p, anthropicBaseUrl: "", openaiBaseUrl: p.baseUrl }
|
|
121
|
+
: { ...p, anthropicBaseUrl: p.baseUrl, openaiBaseUrl: "" };
|
|
150
122
|
}
|
|
151
123
|
|
|
152
124
|
/**
|
|
@@ -389,9 +361,11 @@ function normalizeModelName(name: string): string {
|
|
|
389
361
|
}
|
|
390
362
|
|
|
391
363
|
/**
|
|
392
|
-
* Finds a provider by model name using
|
|
364
|
+
* Finds a provider by model name using three strategies:
|
|
393
365
|
* 1. Case-insensitive prefix match against "{provider.name}-" (e.g., "deepseek-" matches "deepseek-*")
|
|
394
366
|
* 2. Case-insensitive match against provider.model field (with whitespace/hyphen normalization)
|
|
367
|
+
* 3. Fallback: concatenate provider name with provider's model and check if the request model matches
|
|
368
|
+
* this concatenation (handles "MiniMax" + "M3" → "MiniMax-M3" pattern when case differs)
|
|
395
369
|
*/
|
|
396
370
|
export function findProviderByModel(model: string): ProviderConfig | null {
|
|
397
371
|
const providers = getProviders();
|
|
@@ -412,6 +386,15 @@ export function findProviderByModel(model: string): ProviderConfig | null {
|
|
|
412
386
|
) {
|
|
413
387
|
return provider;
|
|
414
388
|
}
|
|
389
|
+
// Strategy 3: fallback - concatenate provider name with model part and compare
|
|
390
|
+
// This handles cases like "MiniMax" + "M3" → "MiniMax-M3" when case differs
|
|
391
|
+
if (provider.model !== undefined && provider.model !== "") {
|
|
392
|
+
const modelPart = modelNormalized.replace(normalizeModelName(provider.name), "");
|
|
393
|
+
const concatenated = normalizeModelName(provider.name) + modelPart;
|
|
394
|
+
if (modelNormalized === concatenated) {
|
|
395
|
+
return provider;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
415
398
|
}
|
|
416
399
|
return null;
|
|
417
400
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ProviderConfig } from "./providers";
|
|
2
|
+
import {
|
|
3
|
+
AUTH_HEADER_X_API_KEY,
|
|
4
|
+
DEFAULT_OPENAI_UPSTREAM,
|
|
5
|
+
DEFAULT_UPSTREAM,
|
|
6
|
+
HEADER_AUTHORIZATION,
|
|
7
|
+
HEADER_HOST,
|
|
8
|
+
HEADER_X_API_KEY,
|
|
9
|
+
PATH_CHAT_COMPLETIONS,
|
|
10
|
+
PATH_V1_CHAT_COMPLETIONS,
|
|
11
|
+
} from "./constants";
|
|
12
|
+
|
|
13
|
+
export type ApiRoute = {
|
|
14
|
+
apiPath: string;
|
|
15
|
+
endpointPath: string;
|
|
16
|
+
isChatCompletions: boolean;
|
|
17
|
+
normalizedPath: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Describes the protocol implied by the client-facing path.
|
|
22
|
+
*
|
|
23
|
+
* The path is the source of truth for request/response format. ProviderConfig.format
|
|
24
|
+
* is only a fallback for old provider records that do not have a format-specific URL.
|
|
25
|
+
*/
|
|
26
|
+
export function describeApiRoute(apiPath: string): ApiRoute {
|
|
27
|
+
const endpointPath = apiPath.split("?")[0] ?? "";
|
|
28
|
+
const isChatCompletions =
|
|
29
|
+
endpointPath === PATH_CHAT_COMPLETIONS || endpointPath === PATH_V1_CHAT_COMPLETIONS;
|
|
30
|
+
const normalizedPath =
|
|
31
|
+
isChatCompletions && !apiPath.startsWith("/v1/") ? `/v1${apiPath}` : apiPath;
|
|
32
|
+
|
|
33
|
+
return { apiPath, endpointPath, isChatCompletions, normalizedPath };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getProxyApiPath(url: URL): string {
|
|
37
|
+
return url.pathname.replace(/^\/proxy/, "") + url.search;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function selectUpstreamBase(route: ApiRoute, provider: ProviderConfig | null): string {
|
|
41
|
+
if (provider === null) {
|
|
42
|
+
return route.isChatCompletions ? DEFAULT_OPENAI_UPSTREAM : DEFAULT_UPSTREAM;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const formatSpecificUrl = route.isChatCompletions
|
|
46
|
+
? provider.openaiBaseUrl
|
|
47
|
+
: provider.anthropicBaseUrl;
|
|
48
|
+
if (formatSpecificUrl !== undefined && formatSpecificUrl !== "") {
|
|
49
|
+
return formatSpecificUrl;
|
|
50
|
+
}
|
|
51
|
+
if (provider.baseUrl !== undefined && provider.baseUrl !== "") {
|
|
52
|
+
return provider.baseUrl;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Legacy records may only contain format. Keep this fallback for compatibility,
|
|
56
|
+
// but never use it to infer the response parser; that decision comes from the path.
|
|
57
|
+
return provider.format === "openai" ? DEFAULT_OPENAI_UPSTREAM : DEFAULT_UPSTREAM;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildUpstreamUrl(upstreamBase: string, normalizedPath: string): string {
|
|
61
|
+
const base = upstreamBase.endsWith("/") ? upstreamBase.slice(0, -1) : upstreamBase;
|
|
62
|
+
|
|
63
|
+
// Many OpenAI-compatible base URLs already include /v1. Avoid producing /v1/v1.
|
|
64
|
+
if (base.endsWith("/v1") && normalizedPath.startsWith("/v1/")) {
|
|
65
|
+
return base + normalizedPath.slice(3);
|
|
66
|
+
}
|
|
67
|
+
return base + normalizedPath;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function setUpstreamHost(headers: Headers, upstreamBase: string): void {
|
|
71
|
+
try {
|
|
72
|
+
headers.set(HEADER_HOST, new URL(upstreamBase).host);
|
|
73
|
+
} catch {
|
|
74
|
+
// Invalid provider URLs are rejected by the UI. Retain the historical default
|
|
75
|
+
// for imported or manually edited config that bypassed that validation.
|
|
76
|
+
headers.set(HEADER_HOST, "api.anthropic.com");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function injectProviderAuth(headers: Headers, provider: ProviderConfig | null): void {
|
|
81
|
+
if (provider === null) return;
|
|
82
|
+
|
|
83
|
+
const apiKey = provider.apiKey.replace(/^Bearer\s+/i, "").trim();
|
|
84
|
+
if (provider.authHeader === AUTH_HEADER_X_API_KEY) {
|
|
85
|
+
headers.set(HEADER_X_API_KEY, apiKey);
|
|
86
|
+
headers.delete(HEADER_AUTHORIZATION);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
headers.set(HEADER_AUTHORIZATION, `Bearer ${apiKey}`);
|
|
90
|
+
}
|
|
@@ -3,28 +3,24 @@ import { z } from "zod";
|
|
|
3
3
|
import { getLogById } from "../../proxy/store";
|
|
4
4
|
import { findProviderByModel } from "../../proxy/providers";
|
|
5
5
|
import { registry } from "../../proxy/formats";
|
|
6
|
-
import { formatForPath
|
|
6
|
+
import { formatForPath } from "../../proxy/formats";
|
|
7
7
|
import { extractModelFromBody } from "../../proxy/schemas";
|
|
8
8
|
import {
|
|
9
|
-
DEFAULT_UPSTREAM,
|
|
10
|
-
DEFAULT_OPENAI_UPSTREAM,
|
|
11
9
|
PROXY_IDENTITY,
|
|
12
10
|
PRESERVE_HEADERS,
|
|
13
|
-
PATH_V1_CHAT_COMPLETIONS,
|
|
14
|
-
PATH_CHAT_COMPLETIONS,
|
|
15
|
-
PATH_V1_MESSAGES,
|
|
16
11
|
HEADER_CONTENT_TYPE,
|
|
17
12
|
HEADER_USER_AGENT,
|
|
18
13
|
HEADER_X_PROXY_IDENTITY,
|
|
19
|
-
|
|
20
|
-
HEADER_X_API_KEY,
|
|
21
|
-
HEADER_CONTENT_ENCODING,
|
|
22
|
-
HEADER_CONTENT_LENGTH,
|
|
23
|
-
HEADER_HOST,
|
|
24
|
-
AUTH_HEADER_X_API_KEY,
|
|
14
|
+
CONTENT_TYPE_EVENT_STREAM,
|
|
25
15
|
STATUS_FORBIDDEN,
|
|
26
|
-
STATUS_BAD_GATEWAY,
|
|
27
16
|
} from "../../proxy/constants";
|
|
17
|
+
import {
|
|
18
|
+
buildUpstreamUrl,
|
|
19
|
+
describeApiRoute,
|
|
20
|
+
injectProviderAuth,
|
|
21
|
+
selectUpstreamBase,
|
|
22
|
+
setUpstreamHost,
|
|
23
|
+
} from "../../proxy/upstream";
|
|
28
24
|
|
|
29
25
|
type ReplayRequest = {
|
|
30
26
|
modifiedBody: string;
|
|
@@ -45,71 +41,6 @@ type ReplayResponse = {
|
|
|
45
41
|
streaming?: boolean;
|
|
46
42
|
};
|
|
47
43
|
|
|
48
|
-
function getHostFromUrl(urlStr: string): string {
|
|
49
|
-
try {
|
|
50
|
-
const url = new URL(urlStr);
|
|
51
|
-
return url.host;
|
|
52
|
-
} catch {
|
|
53
|
-
return "api.anthropic.com";
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function buildUpstreamUrl(upstreamBase: string, apiPath: string): string {
|
|
58
|
-
// Remove trailing slash from upstreamBase to avoid double slashes
|
|
59
|
-
const base = upstreamBase.endsWith("/") ? upstreamBase.slice(0, -1) : upstreamBase;
|
|
60
|
-
return base + apiPath;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function selectUpstreamBase(
|
|
64
|
-
isChatCompletions: boolean,
|
|
65
|
-
matchedProviderConfig: ReturnType<typeof findProviderByModel>,
|
|
66
|
-
): string {
|
|
67
|
-
let upstreamBase: string;
|
|
68
|
-
|
|
69
|
-
if (matchedProviderConfig) {
|
|
70
|
-
if (
|
|
71
|
-
isChatCompletions &&
|
|
72
|
-
matchedProviderConfig.openaiBaseUrl !== undefined &&
|
|
73
|
-
matchedProviderConfig.openaiBaseUrl !== ""
|
|
74
|
-
) {
|
|
75
|
-
upstreamBase = matchedProviderConfig.openaiBaseUrl;
|
|
76
|
-
} else if (
|
|
77
|
-
!isChatCompletions &&
|
|
78
|
-
matchedProviderConfig.anthropicBaseUrl !== undefined &&
|
|
79
|
-
matchedProviderConfig.anthropicBaseUrl !== ""
|
|
80
|
-
) {
|
|
81
|
-
upstreamBase = matchedProviderConfig.anthropicBaseUrl;
|
|
82
|
-
} else if (
|
|
83
|
-
matchedProviderConfig.baseUrl !== undefined &&
|
|
84
|
-
matchedProviderConfig.baseUrl !== ""
|
|
85
|
-
) {
|
|
86
|
-
upstreamBase = matchedProviderConfig.baseUrl;
|
|
87
|
-
} else {
|
|
88
|
-
upstreamBase =
|
|
89
|
-
matchedProviderConfig.format === "openai" ? DEFAULT_OPENAI_UPSTREAM : DEFAULT_UPSTREAM;
|
|
90
|
-
}
|
|
91
|
-
} else {
|
|
92
|
-
upstreamBase = isChatCompletions ? DEFAULT_OPENAI_UPSTREAM : DEFAULT_UPSTREAM;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
return upstreamBase;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function injectAuthHeaders(
|
|
99
|
-
upstreamHeaders: Headers,
|
|
100
|
-
matchedProviderConfig: ReturnType<typeof findProviderByModel>,
|
|
101
|
-
): void {
|
|
102
|
-
if (!matchedProviderConfig) return;
|
|
103
|
-
|
|
104
|
-
const apiKey = matchedProviderConfig.apiKey.replace(/^Bearer\s+/i, "").trim();
|
|
105
|
-
if (matchedProviderConfig.authHeader === AUTH_HEADER_X_API_KEY) {
|
|
106
|
-
upstreamHeaders.set(HEADER_X_API_KEY, apiKey);
|
|
107
|
-
upstreamHeaders.delete(HEADER_AUTHORIZATION);
|
|
108
|
-
} else {
|
|
109
|
-
upstreamHeaders.set(HEADER_AUTHORIZATION, `Bearer ${apiKey}`);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
44
|
export const Route = createFileRoute("/api/logs/$id/replay")({
|
|
114
45
|
server: {
|
|
115
46
|
handlers: {
|
|
@@ -159,22 +90,17 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
|
|
|
159
90
|
}
|
|
160
91
|
|
|
161
92
|
// Determine API path from original log
|
|
162
|
-
const
|
|
163
|
-
const isChatCompletions =
|
|
164
|
-
apiPath === PATH_V1_CHAT_COMPLETIONS || apiPath === PATH_CHAT_COMPLETIONS;
|
|
165
|
-
const normalizedPath =
|
|
166
|
-
isChatCompletions && !apiPath.startsWith("/v1/") ? "/v1" + apiPath : apiPath;
|
|
93
|
+
const route = describeApiRoute(log.path);
|
|
167
94
|
|
|
168
95
|
// Get format handler
|
|
169
|
-
const formatHandler = formatForPath(apiPath);
|
|
96
|
+
const formatHandler = formatForPath(route.apiPath);
|
|
170
97
|
if (formatHandler === null) {
|
|
171
98
|
return Response.json({ error: "Unsupported path" }, { status: STATUS_FORBIDDEN });
|
|
172
99
|
}
|
|
173
100
|
|
|
174
101
|
// Build upstream URL
|
|
175
|
-
const upstreamBase = selectUpstreamBase(
|
|
176
|
-
const upstreamUrl = buildUpstreamUrl(upstreamBase, normalizedPath);
|
|
177
|
-
const upstreamHost = getHostFromUrl(upstreamBase);
|
|
102
|
+
const upstreamBase = selectUpstreamBase(route, matchedProviderConfig);
|
|
103
|
+
const upstreamUrl = buildUpstreamUrl(upstreamBase, route.normalizedPath);
|
|
178
104
|
|
|
179
105
|
// Build headers
|
|
180
106
|
const headers = new Headers();
|
|
@@ -188,8 +114,8 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
|
|
|
188
114
|
headers.set(name, value);
|
|
189
115
|
}
|
|
190
116
|
}
|
|
191
|
-
headers
|
|
192
|
-
|
|
117
|
+
setUpstreamHost(headers, upstreamBase);
|
|
118
|
+
injectProviderAuth(headers, matchedProviderConfig);
|
|
193
119
|
|
|
194
120
|
const startTime = Date.now();
|
|
195
121
|
|
|
@@ -210,7 +136,8 @@ export const Route = createFileRoute("/api/logs/$id/replay")({
|
|
|
210
136
|
|
|
211
137
|
const elapsedMs = Date.now() - startTime;
|
|
212
138
|
const isStream =
|
|
213
|
-
upstreamRes.headers.get(HEADER_CONTENT_TYPE)?.includes(
|
|
139
|
+
upstreamRes.headers.get(HEADER_CONTENT_TYPE)?.includes(CONTENT_TYPE_EVENT_STREAM) ??
|
|
140
|
+
false;
|
|
214
141
|
|
|
215
142
|
if (isStream) {
|
|
216
143
|
// Accumulate streaming response
|