@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.
@@ -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
- // DeepSeek /v1/messages is chat completions replay
31
- if (messagesPath === PATH_V1_MESSAGES) return this.handlers.get("anthropic");
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 */
@@ -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 { extractModelFromBody, extractRequestMetadata, type RequestFormat } from "./schemas";
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, formatRegistry, type FormatHandler } from "./formats";
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 apiPath = url.pathname.replace(/^\/proxy/, "") + url.search;
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 upstreamBase = selectUpstreamBase(parsed.isChatCompletions, matchedProviderConfig);
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.set(HEADER_HOST, upstreamHost);
349
- injectAuthHeaders(upstreamHeaders, matchedProviderConfig);
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
- // Get the format handler based on the actual upstream URL being used.
360
- // For Chat Completions, the upstream always returns OpenAI SSE format regardless
361
- // of provider config (which may have format=anthropic for backward compat).
362
- let formatHandler: FormatHandler | null;
363
- if (parsed.isChatCompletions) {
364
- formatHandler = formatRegistry.get("openai") ?? null;
365
- } else if (matchedProviderConfig?.format) {
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
  }
@@ -103,50 +103,22 @@ export const store = new Conf<ProvidersStore>({
103
103
  migrateFromLegacyConfLocation(store);
104
104
 
105
105
  /**
106
- * Migrates a single provider config to preserve both Anthropic and OpenAI URLs.
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
- const currentAnthropicUrl = p.anthropicBaseUrl ?? "";
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
- // Determine primary format based on which URL is set
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
- format,
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 two strategies:
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, type FormatHandler } from "../../proxy/formats";
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
- HEADER_AUTHORIZATION,
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 apiPath = log.path;
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(isChatCompletions, matchedProviderConfig);
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.set(HEADER_HOST, upstreamHost);
192
- injectAuthHeaders(headers, matchedProviderConfig);
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("text/event-stream") ?? false;
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