@tonyclaw/llm-inspector 1.9.8 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.output/nitro.json +3 -3
- package/.output/public/assets/{index-NIg9RFlA.js → index-BFuUQkWu.js} +3 -3
- package/.output/public/assets/index-F2ojFPJp.css +1 -0
- package/.output/public/assets/index-nKnhyW4Y.js +122 -0
- package/.output/server/_libs/@radix-ui/react-accessible-icon+[...].mjs +1 -1
- package/.output/server/_libs/@radix-ui/react-navigation-menu+[...].mjs +2 -1
- package/.output/server/_libs/@radix-ui/react-one-time-password-field+[...].mjs +2 -1
- package/.output/server/_libs/@radix-ui/react-password-toggle-field+[...].mjs +2 -1
- package/.output/server/_libs/@radix-ui/react-use-effect-event+[...].mjs +1 -1
- package/.output/server/_libs/@radix-ui/react-use-is-hydrated+[...].mjs +1 -1
- package/.output/server/_libs/ajv-formats.mjs +3 -3
- package/.output/server/_libs/ajv.mjs +819 -6183
- package/.output/server/_libs/extend.mjs +1 -1
- package/.output/server/_libs/fast-uri.mjs +3 -3
- package/.output/server/_libs/floating-ui__core.mjs +18 -17
- package/.output/server/_libs/floating-ui__dom.mjs +24 -20
- package/.output/server/_libs/floating-ui__react-dom.mjs +55 -28
- package/.output/server/_libs/floating-ui__utils.mjs +36 -36
- package/.output/server/_libs/inherits.mjs +1 -1
- package/.output/server/_libs/isbot.mjs +3 -2
- package/.output/server/_libs/json-schema-traverse.mjs +1 -90
- package/.output/server/_libs/jszip.mjs +103 -101
- package/.output/server/_libs/lie.mjs +1 -1
- package/.output/server/_libs/mdast-util-from-markdown.mjs +1 -1
- package/.output/server/_libs/pako.mjs +28 -28
- package/.output/server/_libs/property-information.mjs +13 -1
- package/.output/server/_libs/radix-ui__react-accordion.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-alert-dialog.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-aspect-ratio.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-avatar.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-checkbox.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-context-menu.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-dropdown-menu.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-form.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-hover-card.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-label.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-menu.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-menubar.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-popover.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-progress.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-radio-group.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-slider.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-switch.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-toast.mjs +2 -1
- package/.output/server/_libs/radix-ui__react-toggle-group.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-toggle.mjs +1 -1
- package/.output/server/_libs/radix-ui__react-toolbar.mjs +1 -1
- package/.output/server/_libs/react-dom.mjs +14 -14
- package/.output/server/_libs/react-remove-scroll.mjs +1 -1
- package/.output/server/_libs/react.mjs +5 -5
- package/.output/server/_libs/readable-stream.mjs +36 -36
- package/.output/server/_libs/semver.mjs +138 -125
- package/.output/server/_libs/setimmediate.mjs +152 -1
- package/.output/server/_libs/style-to-js.mjs +3 -3
- package/.output/server/_libs/style-to-object.mjs +1 -1
- package/.output/server/_libs/swr.mjs +1 -0
- package/.output/server/_libs/tailwind-merge.mjs +286 -41
- package/.output/server/_libs/tanstack__react-router.mjs +1 -1
- package/.output/server/_libs/tanstack__react-store.mjs +1 -1
- package/.output/server/_libs/tanstack__react-virtual.mjs +82 -7
- package/.output/server/_libs/tanstack__virtual-core.mjs +58 -17
- package/.output/server/_libs/tslib.mjs +1 -576
- package/.output/server/_libs/ungap__structured-clone.mjs +14 -2
- package/.output/server/_libs/use-sidecar.mjs +1 -1
- package/.output/server/_libs/use-sync-external-store.mjs +3 -3
- package/.output/server/_ssr/{index-BnKtV0Rq.mjs → index-XyU4WVWt.mjs} +270 -83
- package/.output/server/_ssr/index.mjs +4 -4
- package/.output/server/_ssr/{router-CQ7QyRWm.mjs → router-BR4XO8XJ.mjs} +231 -33
- package/.output/server/_tanstack-start-manifest_v-DFoSjBkb.mjs +4 -0
- package/.output/server/index.mjs +121 -137
- package/.output/server/node_modules/tslib/modules/index.js +70 -0
- package/.output/server/node_modules/tslib/modules/package.json +3 -0
- package/.output/server/node_modules/tslib/package.json +47 -0
- package/.output/server/node_modules/tslib/tslib.js +484 -0
- package/.output/server/package.json +9 -0
- package/package.json +3 -3
- package/src/components/providers/SettingsDialog.tsx +60 -0
- package/src/components/proxy-viewer/ConversationGroup.tsx +7 -2
- package/src/components/proxy-viewer/ConversationHeader.tsx +53 -0
- package/src/components/proxy-viewer/LogEntry.tsx +67 -7
- package/src/components/proxy-viewer/LogEntryHeader.tsx +21 -18
- package/src/components/proxy-viewer/ReplayDialog.tsx +3 -3
- package/src/lib/runtimeConfig.ts +15 -0
- package/src/lib/useStripConfig.ts +86 -0
- package/src/proxy/claudeCodeStrip.ts +102 -0
- package/src/proxy/config.ts +176 -0
- package/src/proxy/constants.ts +11 -0
- package/src/proxy/handler.ts +23 -1
- package/src/routes/api/config.ts +45 -0
- package/.output/public/assets/index-Cc1oV0hF.css +0 -1
- package/.output/public/assets/index-DuukpBQJ.js +0 -122
- package/.output/server/_tanstack-start-manifest_v-CKgCX0Gs.mjs +0 -4
- /package/.output/server/_libs/{unctx.mjs → ocache.mjs} +0 -0
|
@@ -2,6 +2,13 @@ import { ChevronDown, ChevronRight, Clock, MessageSquare, Zap } from "lucide-rea
|
|
|
2
2
|
import type { JSX } from "react";
|
|
3
3
|
import { cn, formatTokens } from "../../lib/utils";
|
|
4
4
|
import type { CapturedLog } from "../../proxy/schemas";
|
|
5
|
+
import { Badge } from "../ui/badge";
|
|
6
|
+
|
|
7
|
+
const API_FORMAT_LABELS: Record<"anthropic" | "openai" | "unknown", string> = {
|
|
8
|
+
anthropic: "Anthropic",
|
|
9
|
+
openai: "OpenAI",
|
|
10
|
+
unknown: "Unknown",
|
|
11
|
+
};
|
|
5
12
|
|
|
6
13
|
export type ConversationHeaderProps = {
|
|
7
14
|
conversationId: string;
|
|
@@ -10,8 +17,12 @@ export type ConversationHeaderProps = {
|
|
|
10
17
|
totalCalls: number;
|
|
11
18
|
totalInputTokens: number;
|
|
12
19
|
totalOutputTokens: number;
|
|
20
|
+
apiFormat: "anthropic" | "openai" | "unknown";
|
|
13
21
|
expanded: boolean;
|
|
14
22
|
onToggle: () => void;
|
|
23
|
+
/** Hide the API format badge on the header (used when the group contains
|
|
24
|
+
* mixed formats — the per-log badges are shown instead). */
|
|
25
|
+
hideApiFormat?: boolean;
|
|
15
26
|
};
|
|
16
27
|
|
|
17
28
|
function formatTimestamp(iso: string): string {
|
|
@@ -26,8 +37,10 @@ export function ConversationHeader({
|
|
|
26
37
|
totalCalls,
|
|
27
38
|
totalInputTokens,
|
|
28
39
|
totalOutputTokens,
|
|
40
|
+
apiFormat,
|
|
29
41
|
expanded,
|
|
30
42
|
onToggle,
|
|
43
|
+
hideApiFormat = false,
|
|
31
44
|
}: ConversationHeaderProps): JSX.Element {
|
|
32
45
|
return (
|
|
33
46
|
<div
|
|
@@ -64,6 +77,21 @@ export function ConversationHeader({
|
|
|
64
77
|
: conversationId}
|
|
65
78
|
</span>
|
|
66
79
|
|
|
80
|
+
{/* API Format Badge */}
|
|
81
|
+
{!hideApiFormat && (
|
|
82
|
+
<Badge
|
|
83
|
+
variant="outline"
|
|
84
|
+
className={cn(
|
|
85
|
+
"text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
|
|
86
|
+
apiFormat === "openai" && "border-blue-500/40 text-blue-400",
|
|
87
|
+
apiFormat === "anthropic" && "border-orange-500/40 text-orange-400",
|
|
88
|
+
apiFormat === "unknown" && "border-muted text-muted-foreground",
|
|
89
|
+
)}
|
|
90
|
+
>
|
|
91
|
+
{API_FORMAT_LABELS[apiFormat]}
|
|
92
|
+
</Badge>
|
|
93
|
+
)}
|
|
94
|
+
|
|
67
95
|
{/* Time range */}
|
|
68
96
|
<span className="flex items-center gap-1 text-muted-foreground text-xs shrink-0">
|
|
69
97
|
<Clock className="size-3" />
|
|
@@ -102,6 +130,31 @@ export type ConversationGroupData = {
|
|
|
102
130
|
logs: CapturedLog[];
|
|
103
131
|
};
|
|
104
132
|
|
|
133
|
+
export function getGroupApiFormat(logs: CapturedLog[]): "anthropic" | "openai" | "unknown" {
|
|
134
|
+
const firstNonUnknown = logs.find((l) => l.apiFormat !== "unknown");
|
|
135
|
+
if (firstNonUnknown !== undefined) {
|
|
136
|
+
return firstNonUnknown.apiFormat;
|
|
137
|
+
}
|
|
138
|
+
return logs[0]?.apiFormat ?? "unknown";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Returns true when a group contains logs of BOTH Anthropic and OpenAI
|
|
143
|
+
* formats. Used to decide whether to render the per-row API format badge
|
|
144
|
+
* inside a group (the badge is normally hidden because the group header
|
|
145
|
+
* already shows the format).
|
|
146
|
+
*/
|
|
147
|
+
export function hasMixedApiFormat(logs: CapturedLog[]): boolean {
|
|
148
|
+
let hasAnthropic = false;
|
|
149
|
+
let hasOpenai = false;
|
|
150
|
+
for (const log of logs) {
|
|
151
|
+
if (log.apiFormat === "anthropic") hasAnthropic = true;
|
|
152
|
+
else if (log.apiFormat === "openai") hasOpenai = true;
|
|
153
|
+
if (hasAnthropic && hasOpenai) return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
105
158
|
export function getConversationId(log: CapturedLog): string {
|
|
106
159
|
if (log.sessionId !== null && log.sessionId !== "") {
|
|
107
160
|
return log.sessionId;
|
|
@@ -3,6 +3,8 @@ import type { JSX } from "react";
|
|
|
3
3
|
import { useMemo, useState, memo } from "react";
|
|
4
4
|
import { cn } from "../../lib/utils";
|
|
5
5
|
import { type CapturedLog, parseRequest } from "../../proxy/schemas";
|
|
6
|
+
import { stripClaudeCodeBillingHeader } from "../../proxy/claudeCodeStrip";
|
|
7
|
+
import { useStripConfig } from "../../lib/useStripConfig";
|
|
6
8
|
import { Button } from "../ui/button";
|
|
7
9
|
import { JsonViewerFromString } from "../ui/json-viewer";
|
|
8
10
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs";
|
|
@@ -14,8 +16,23 @@ import { StreamingChunkSequence } from "./StreamingChunkSequence";
|
|
|
14
16
|
export type LogEntryProps = {
|
|
15
17
|
log: CapturedLog;
|
|
16
18
|
viewMode?: "simple" | "full";
|
|
19
|
+
/** Suppress the API format badge when log is displayed within a group */
|
|
20
|
+
suppressApiFormatBadge?: boolean;
|
|
17
21
|
};
|
|
18
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Pure visibility rule for the "Raw Request" tab. Extracted so it can be
|
|
25
|
+
* unit-tested without rendering React. The tab appears only when all three
|
|
26
|
+
* are true: anthropic format, full view mode, strip toggle enabled.
|
|
27
|
+
*/
|
|
28
|
+
export function shouldShowRawRequestTab(
|
|
29
|
+
apiFormat: string,
|
|
30
|
+
viewMode: "simple" | "full",
|
|
31
|
+
strip: boolean,
|
|
32
|
+
): boolean {
|
|
33
|
+
return apiFormat === "anthropic" && viewMode === "full" && strip;
|
|
34
|
+
}
|
|
35
|
+
|
|
19
36
|
function CopyButton({
|
|
20
37
|
text,
|
|
21
38
|
label,
|
|
@@ -49,25 +66,44 @@ function CopyButton({
|
|
|
49
66
|
);
|
|
50
67
|
}
|
|
51
68
|
|
|
52
|
-
export const LogEntry = memo(function
|
|
69
|
+
export const LogEntry = memo(function ({
|
|
53
70
|
log,
|
|
54
71
|
viewMode = "simple",
|
|
72
|
+
suppressApiFormatBadge = false,
|
|
55
73
|
}: LogEntryProps): JSX.Element {
|
|
56
74
|
const [expanded, setExpanded] = useState<boolean>(false);
|
|
57
75
|
const [requestCopied, setRequestCopied] = useState<boolean>(false);
|
|
76
|
+
const [rawRequestCopied, setRawRequestCopied] = useState<boolean>(false);
|
|
58
77
|
const [responseCopied, setResponseCopied] = useState<boolean>(false);
|
|
59
78
|
const [replayOpen, setReplayOpen] = useState<boolean>(false);
|
|
79
|
+
const { strip } = useStripConfig();
|
|
60
80
|
const parsedRequest = useMemo(() => parseRequest(log.rawRequestBody), [log.rawRequestBody]);
|
|
81
|
+
const strippedRequestBody = useMemo(() => {
|
|
82
|
+
if (!strip || log.apiFormat !== "anthropic" || log.rawRequestBody === null) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
return stripClaudeCodeBillingHeader(log.rawRequestBody).body;
|
|
86
|
+
}, [log.rawRequestBody, log.apiFormat, strip]);
|
|
87
|
+
const displayedRequestBody = strippedRequestBody ?? log.rawRequestBody;
|
|
61
88
|
|
|
62
89
|
function handleCopyRequest(e: React.MouseEvent): void {
|
|
63
90
|
e.stopPropagation();
|
|
64
|
-
if (
|
|
65
|
-
void window.navigator.clipboard.writeText(
|
|
91
|
+
if (displayedRequestBody === null) return;
|
|
92
|
+
void window.navigator.clipboard.writeText(displayedRequestBody).then(() => {
|
|
66
93
|
setRequestCopied(true);
|
|
67
94
|
setTimeout(() => setRequestCopied(false), 2000);
|
|
68
95
|
});
|
|
69
96
|
}
|
|
70
97
|
|
|
98
|
+
function handleCopyRawRequest(e: React.MouseEvent): void {
|
|
99
|
+
e.stopPropagation();
|
|
100
|
+
if (log.rawRequestBody === null) return;
|
|
101
|
+
void window.navigator.clipboard.writeText(log.rawRequestBody).then(() => {
|
|
102
|
+
setRawRequestCopied(true);
|
|
103
|
+
setTimeout(() => setRawRequestCopied(false), 2000);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
71
107
|
function handleCopyResponse(e: React.MouseEvent): void {
|
|
72
108
|
e.stopPropagation();
|
|
73
109
|
if (log.responseText === null) return;
|
|
@@ -85,6 +121,7 @@ export const LogEntry = memo(function LogEntry({
|
|
|
85
121
|
parsedRequest={parsedRequest}
|
|
86
122
|
expanded={expanded}
|
|
87
123
|
onToggle={() => setExpanded(!expanded)}
|
|
124
|
+
suppressApiFormatBadge={suppressApiFormatBadge}
|
|
88
125
|
/>
|
|
89
126
|
|
|
90
127
|
{expanded && (
|
|
@@ -93,11 +130,34 @@ export const LogEntry = memo(function LogEntry({
|
|
|
93
130
|
<TabsList className="mx-4 mt-2">
|
|
94
131
|
{viewMode === "full" && <TabsTrigger value="raw-headers">Raw Headers</TabsTrigger>}
|
|
95
132
|
{viewMode === "full" && <TabsTrigger value="headers">Headers</TabsTrigger>}
|
|
133
|
+
{shouldShowRawRequestTab(log.apiFormat, viewMode, strip) && (
|
|
134
|
+
<TabsTrigger value="raw-request">Raw Request</TabsTrigger>
|
|
135
|
+
)}
|
|
96
136
|
<TabsTrigger value="request">Request</TabsTrigger>
|
|
97
137
|
{viewMode === "full" && <TabsTrigger value="raw">Raw Response</TabsTrigger>}
|
|
98
|
-
<TabsTrigger value="parsed">
|
|
138
|
+
<TabsTrigger value="parsed">Response</TabsTrigger>
|
|
99
139
|
</TabsList>
|
|
100
140
|
|
|
141
|
+
{shouldShowRawRequestTab(log.apiFormat, viewMode, strip) && (
|
|
142
|
+
<TabsContent value="raw-request">
|
|
143
|
+
<div className="px-4 py-3">
|
|
144
|
+
<div className="flex justify-end mb-2">
|
|
145
|
+
<CopyButton
|
|
146
|
+
text={log.rawRequestBody}
|
|
147
|
+
label="Copy Raw Request"
|
|
148
|
+
copied={rawRequestCopied}
|
|
149
|
+
onCopy={handleCopyRawRequest}
|
|
150
|
+
/>
|
|
151
|
+
</div>
|
|
152
|
+
{log.rawRequestBody !== null ? (
|
|
153
|
+
<JsonViewerFromString text={log.rawRequestBody} defaultExpandDepth={1} />
|
|
154
|
+
) : (
|
|
155
|
+
<p className="text-xs text-muted-foreground italic">No request body</p>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</TabsContent>
|
|
159
|
+
)}
|
|
160
|
+
|
|
101
161
|
<TabsContent value="request">
|
|
102
162
|
<div className="px-4 py-3">
|
|
103
163
|
<div className="flex justify-end gap-2 mb-2">
|
|
@@ -114,14 +174,14 @@ export const LogEntry = memo(function LogEntry({
|
|
|
114
174
|
Replay
|
|
115
175
|
</Button>
|
|
116
176
|
<CopyButton
|
|
117
|
-
text={
|
|
177
|
+
text={displayedRequestBody}
|
|
118
178
|
label="Copy Request"
|
|
119
179
|
copied={requestCopied}
|
|
120
180
|
onCopy={handleCopyRequest}
|
|
121
181
|
/>
|
|
122
182
|
</div>
|
|
123
|
-
{
|
|
124
|
-
<JsonViewerFromString text={
|
|
183
|
+
{displayedRequestBody !== null ? (
|
|
184
|
+
<JsonViewerFromString text={displayedRequestBody} defaultExpandDepth={1} />
|
|
125
185
|
) : (
|
|
126
186
|
<p className="text-xs text-muted-foreground italic">No request body</p>
|
|
127
187
|
)}
|
|
@@ -18,12 +18,6 @@ import type { CapturedLog, InspectorRequest } from "../../proxy/schemas";
|
|
|
18
18
|
import { Badge } from "../ui/badge";
|
|
19
19
|
import { ProviderLogo, detectProvider } from "../providers/ProviderLogo";
|
|
20
20
|
|
|
21
|
-
const API_FORMAT_LABELS: Record<"anthropic" | "openai" | "unknown", string> = {
|
|
22
|
-
anthropic: "Anthropic",
|
|
23
|
-
openai: "OpenAI",
|
|
24
|
-
unknown: "Unknown",
|
|
25
|
-
};
|
|
26
|
-
|
|
27
21
|
function formatElapsed(ms: number): string {
|
|
28
22
|
if (ms < 1000) return `${ms}ms`;
|
|
29
23
|
return `${(ms / 1000).toFixed(1)}s`;
|
|
@@ -41,13 +35,16 @@ export type LogEntryHeaderProps = {
|
|
|
41
35
|
parsedRequest: InspectorRequest | null;
|
|
42
36
|
expanded: boolean;
|
|
43
37
|
onToggle: () => void;
|
|
38
|
+
/** Suppress the API format badge when log is displayed within a group */
|
|
39
|
+
suppressApiFormatBadge?: boolean;
|
|
44
40
|
};
|
|
45
41
|
|
|
46
|
-
export const LogEntryHeader = memo(function
|
|
42
|
+
export const LogEntryHeader = memo(function ({
|
|
47
43
|
log,
|
|
48
44
|
parsedRequest,
|
|
49
45
|
expanded,
|
|
50
46
|
onToggle,
|
|
47
|
+
suppressApiFormatBadge = false,
|
|
51
48
|
}: LogEntryHeaderProps): JSX.Element {
|
|
52
49
|
const statusCategory = getStatusCategory(log.responseStatus);
|
|
53
50
|
|
|
@@ -93,17 +90,23 @@ export const LogEntryHeader = memo(function LogEntryHeader({
|
|
|
93
90
|
)}
|
|
94
91
|
|
|
95
92
|
{/* API Format Badge */}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
93
|
+
{!suppressApiFormatBadge && (
|
|
94
|
+
<Badge
|
|
95
|
+
variant="outline"
|
|
96
|
+
className={cn(
|
|
97
|
+
"text-[10px] px-1.5 py-0 h-5 font-mono shrink-0",
|
|
98
|
+
log.apiFormat === "openai" && "border-blue-500/40 text-blue-400",
|
|
99
|
+
log.apiFormat === "anthropic" && "border-orange-500/40 text-orange-400",
|
|
100
|
+
log.apiFormat === "unknown" && "border-muted text-muted-foreground",
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{log.apiFormat === "anthropic"
|
|
104
|
+
? "Anthropic"
|
|
105
|
+
: log.apiFormat === "openai"
|
|
106
|
+
? "OpenAI"
|
|
107
|
+
: "Unknown"}
|
|
108
|
+
</Badge>
|
|
109
|
+
)}
|
|
107
110
|
|
|
108
111
|
{/* Response Status */}
|
|
109
112
|
{statusCategory === "server_error" ? (
|
|
@@ -120,7 +120,7 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
|
|
|
120
120
|
{replayResult && replayResult.success && (
|
|
121
121
|
<Tabs defaultValue="parsed">
|
|
122
122
|
<TabsList>
|
|
123
|
-
<TabsTrigger value="parsed">
|
|
123
|
+
<TabsTrigger value="parsed">Response</TabsTrigger>
|
|
124
124
|
<TabsTrigger value="raw">Raw Response</TabsTrigger>
|
|
125
125
|
</TabsList>
|
|
126
126
|
<TabsContent value="parsed">
|
|
@@ -146,7 +146,7 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
|
|
|
146
146
|
{log.responseText !== null ? (
|
|
147
147
|
<Tabs defaultValue="parsed">
|
|
148
148
|
<TabsList>
|
|
149
|
-
<TabsTrigger value="parsed">
|
|
149
|
+
<TabsTrigger value="parsed">Response</TabsTrigger>
|
|
150
150
|
<TabsTrigger value="raw">Raw Response</TabsTrigger>
|
|
151
151
|
</TabsList>
|
|
152
152
|
<TabsContent value="parsed">
|
|
@@ -177,7 +177,7 @@ export function ReplayDialog({ log, open, onOpenChange }: ReplayDialogProps): JS
|
|
|
177
177
|
{replayResult.responseText !== null ? (
|
|
178
178
|
<Tabs defaultValue="parsed">
|
|
179
179
|
<TabsList>
|
|
180
|
-
<TabsTrigger value="parsed">
|
|
180
|
+
<TabsTrigger value="parsed">Response</TabsTrigger>
|
|
181
181
|
<TabsTrigger value="raw">Raw Response</TabsTrigger>
|
|
182
182
|
</TabsList>
|
|
183
183
|
<TabsContent value="parsed">
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Schema for the runtime proxy config. Shared between server
|
|
5
|
+
* (src/proxy/config.ts) and client (src/lib/useStripConfig.ts) so that
|
|
6
|
+
* the wire format is validated end-to-end with a single source of truth.
|
|
7
|
+
*
|
|
8
|
+
* Keep this file free of side effects and Node-only imports so it is
|
|
9
|
+
* safe to bundle into the client.
|
|
10
|
+
*/
|
|
11
|
+
export const RuntimeConfigSchema = z.object({
|
|
12
|
+
stripClaudeCodeBillingHeader: z.boolean(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export type RuntimeConfig = z.infer<typeof RuntimeConfigSchema>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/consistent-type-assertions, functional/no-throw-statements */
|
|
2
|
+
import useSWR, { type SWRResponse, useSWRConfig } from "swr";
|
|
3
|
+
import { RuntimeConfigSchema, type RuntimeConfig } from "./runtimeConfig";
|
|
4
|
+
|
|
5
|
+
export const STRIP_CONFIG_SWR_KEY = "/api/config";
|
|
6
|
+
|
|
7
|
+
async function fetcher(url: string): Promise<RuntimeConfig> {
|
|
8
|
+
const response = await fetch(url);
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
throw new Error(`Failed to fetch ${url}: ${response.status}`);
|
|
11
|
+
}
|
|
12
|
+
// Validate at the trust boundary; if the server shape ever changes, this
|
|
13
|
+
// throws and the SWR cache stays empty rather than silently lying about types.
|
|
14
|
+
const data: unknown = await response.json();
|
|
15
|
+
return RuntimeConfigSchema.parse(data);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function extractErrorMessage(errorBody: unknown, fallback: string): string {
|
|
19
|
+
if (typeof errorBody !== "object" || errorBody === null) return fallback;
|
|
20
|
+
const candidate = (errorBody as { error?: unknown }).error;
|
|
21
|
+
return typeof candidate === "string" ? candidate : fallback;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* PATCH the runtime config. Exported separately for testability.
|
|
26
|
+
*/
|
|
27
|
+
export async function setRuntimeConfig(patch: Partial<RuntimeConfig>): Promise<RuntimeConfig> {
|
|
28
|
+
const response = await fetch(STRIP_CONFIG_SWR_KEY, {
|
|
29
|
+
method: "PATCH",
|
|
30
|
+
headers: { "content-type": "application/json" },
|
|
31
|
+
body: JSON.stringify(patch),
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
const errorBody: unknown = await response.json().catch(() => ({}));
|
|
35
|
+
throw new Error(extractErrorMessage(errorBody, `PATCH failed with status ${response.status}`));
|
|
36
|
+
}
|
|
37
|
+
const data: unknown = await response.json();
|
|
38
|
+
return RuntimeConfigSchema.parse(data);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type UseStripConfig = {
|
|
42
|
+
strip: boolean;
|
|
43
|
+
isLoading: boolean;
|
|
44
|
+
isError: boolean;
|
|
45
|
+
setStrip: (next: boolean) => Promise<void>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Hook reading the runtime config from /api/config.
|
|
50
|
+
*
|
|
51
|
+
* - `strip` is the live value from the server (or the optimistic value mid-PATCH).
|
|
52
|
+
* - `setStrip(next)` PATCHes the server with optimistic update; on failure
|
|
53
|
+
* it throws and SWR rolls back the optimistic value.
|
|
54
|
+
*/
|
|
55
|
+
export function useStripConfig(): UseStripConfig {
|
|
56
|
+
const response: SWRResponse<RuntimeConfig, Error> = useSWR<RuntimeConfig, Error>(
|
|
57
|
+
STRIP_CONFIG_SWR_KEY,
|
|
58
|
+
fetcher,
|
|
59
|
+
{
|
|
60
|
+
revalidateOnFocus: false,
|
|
61
|
+
revalidateIfStale: false,
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
const { mutate: globalMutate } = useSWRConfig();
|
|
65
|
+
|
|
66
|
+
const strip = response.data?.stripClaudeCodeBillingHeader ?? false;
|
|
67
|
+
|
|
68
|
+
const setStrip = async (next: boolean): Promise<void> => {
|
|
69
|
+
await globalMutate(
|
|
70
|
+
STRIP_CONFIG_SWR_KEY,
|
|
71
|
+
setRuntimeConfig({ stripClaudeCodeBillingHeader: next }),
|
|
72
|
+
{
|
|
73
|
+
optimisticData: { stripClaudeCodeBillingHeader: next },
|
|
74
|
+
rollbackOnError: true,
|
|
75
|
+
revalidate: false,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
strip,
|
|
82
|
+
isLoading: response.isLoading,
|
|
83
|
+
isError: response.error !== undefined,
|
|
84
|
+
setStrip,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code (and similar Anthropic SDK-based clients) prepend a synthetic
|
|
3
|
+
* text block to the `system` array that looks like an HTTP header:
|
|
4
|
+
*
|
|
5
|
+
* x-anthropic-billing-header: cc_version=2.1.92.61c; cc_entrypoint=cli; cch=00000;
|
|
6
|
+
*
|
|
7
|
+
* The `cch` field is a per-request cache-hint value. When this block is
|
|
8
|
+
* forwarded verbatim to a third-party model, the system prompt prefix
|
|
9
|
+
* changes on every request, which kills prefix-cache hit rates and wastes
|
|
10
|
+
* input tokens.
|
|
11
|
+
*
|
|
12
|
+
* This module detects the marker and returns a rewritten body with the
|
|
13
|
+
* block removed. It is opt-in (see constants.ts) so the proxy remains a
|
|
14
|
+
* transparent pass-through by default.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const BILLING_HEADER_PREFIX = "x-anthropic-billing-header:";
|
|
18
|
+
|
|
19
|
+
type StripResult = {
|
|
20
|
+
/** The new request body. Equal to input when no change was made. */
|
|
21
|
+
body: string;
|
|
22
|
+
/** Number of billing-header text blocks removed. */
|
|
23
|
+
removed: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns true if the given text looks like the synthetic billing-header
|
|
28
|
+
* block that Claude Code injects. Whitespace at the start is tolerated and
|
|
29
|
+
* the match is case-insensitive on the header name.
|
|
30
|
+
*/
|
|
31
|
+
export function isClaudeCodeBillingHeaderBlock(text: string): boolean {
|
|
32
|
+
const trimmed = text.trimStart();
|
|
33
|
+
return trimmed.toLowerCase().startsWith(BILLING_HEADER_PREFIX);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getOwnProperty(obj: object, key: string): unknown {
|
|
37
|
+
const desc = Object.getOwnPropertyDescriptor(obj, key);
|
|
38
|
+
return desc?.value;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isObjectWithSystem(value: unknown): value is { system?: unknown[] } {
|
|
42
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
43
|
+
return Object.prototype.hasOwnProperty.call(value, "system");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isBillingHeaderTextBlock(block: unknown): boolean {
|
|
47
|
+
if (block === null || typeof block !== "object" || Array.isArray(block)) return false;
|
|
48
|
+
const typeVal = getOwnProperty(block, "type");
|
|
49
|
+
const textVal = getOwnProperty(block, "text");
|
|
50
|
+
if (typeof typeVal !== "string" || typeVal !== "text") return false;
|
|
51
|
+
if (typeof textVal !== "string") return false;
|
|
52
|
+
return isClaudeCodeBillingHeaderBlock(textVal);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Inspects the request body and, if it is an Anthropic-format request whose
|
|
57
|
+
* `system` array contains one or more Claude Code billing-header text blocks,
|
|
58
|
+
* returns a new body with those blocks removed. The original string is
|
|
59
|
+
* returned unchanged (by reference equality on the result) when no edit is
|
|
60
|
+
* needed so callers can avoid re-serializing.
|
|
61
|
+
*/
|
|
62
|
+
export function stripClaudeCodeBillingHeader(rawBody: string): StripResult {
|
|
63
|
+
let parsed: unknown;
|
|
64
|
+
try {
|
|
65
|
+
parsed = JSON.parse(rawBody);
|
|
66
|
+
} catch {
|
|
67
|
+
return { body: rawBody, removed: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!isObjectWithSystem(parsed)) {
|
|
71
|
+
return { body: rawBody, removed: 0 };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const system = parsed.system;
|
|
75
|
+
|
|
76
|
+
if (!Array.isArray(system) || system.length === 0) {
|
|
77
|
+
return { body: rawBody, removed: 0 };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const kept: unknown[] = [];
|
|
81
|
+
let removed = 0;
|
|
82
|
+
|
|
83
|
+
for (const block of system) {
|
|
84
|
+
if (isBillingHeaderTextBlock(block)) {
|
|
85
|
+
removed += 1;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
kept.push(block);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (removed === 0) {
|
|
92
|
+
return { body: rawBody, removed: 0 };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (kept.length === 0) {
|
|
96
|
+
delete parsed.system;
|
|
97
|
+
} else {
|
|
98
|
+
parsed.system = kept;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { body: JSON.stringify(parsed), removed };
|
|
102
|
+
}
|