@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.
Files changed (93) hide show
  1. package/.output/nitro.json +3 -3
  2. package/.output/public/assets/{index-NIg9RFlA.js → index-BFuUQkWu.js} +3 -3
  3. package/.output/public/assets/index-F2ojFPJp.css +1 -0
  4. package/.output/public/assets/index-nKnhyW4Y.js +122 -0
  5. package/.output/server/_libs/@radix-ui/react-accessible-icon+[...].mjs +1 -1
  6. package/.output/server/_libs/@radix-ui/react-navigation-menu+[...].mjs +2 -1
  7. package/.output/server/_libs/@radix-ui/react-one-time-password-field+[...].mjs +2 -1
  8. package/.output/server/_libs/@radix-ui/react-password-toggle-field+[...].mjs +2 -1
  9. package/.output/server/_libs/@radix-ui/react-use-effect-event+[...].mjs +1 -1
  10. package/.output/server/_libs/@radix-ui/react-use-is-hydrated+[...].mjs +1 -1
  11. package/.output/server/_libs/ajv-formats.mjs +3 -3
  12. package/.output/server/_libs/ajv.mjs +819 -6183
  13. package/.output/server/_libs/extend.mjs +1 -1
  14. package/.output/server/_libs/fast-uri.mjs +3 -3
  15. package/.output/server/_libs/floating-ui__core.mjs +18 -17
  16. package/.output/server/_libs/floating-ui__dom.mjs +24 -20
  17. package/.output/server/_libs/floating-ui__react-dom.mjs +55 -28
  18. package/.output/server/_libs/floating-ui__utils.mjs +36 -36
  19. package/.output/server/_libs/inherits.mjs +1 -1
  20. package/.output/server/_libs/isbot.mjs +3 -2
  21. package/.output/server/_libs/json-schema-traverse.mjs +1 -90
  22. package/.output/server/_libs/jszip.mjs +103 -101
  23. package/.output/server/_libs/lie.mjs +1 -1
  24. package/.output/server/_libs/mdast-util-from-markdown.mjs +1 -1
  25. package/.output/server/_libs/pako.mjs +28 -28
  26. package/.output/server/_libs/property-information.mjs +13 -1
  27. package/.output/server/_libs/radix-ui__react-accordion.mjs +1 -1
  28. package/.output/server/_libs/radix-ui__react-alert-dialog.mjs +1 -1
  29. package/.output/server/_libs/radix-ui__react-aspect-ratio.mjs +1 -1
  30. package/.output/server/_libs/radix-ui__react-avatar.mjs +1 -1
  31. package/.output/server/_libs/radix-ui__react-checkbox.mjs +1 -1
  32. package/.output/server/_libs/radix-ui__react-context-menu.mjs +1 -1
  33. package/.output/server/_libs/radix-ui__react-dropdown-menu.mjs +1 -1
  34. package/.output/server/_libs/radix-ui__react-form.mjs +1 -1
  35. package/.output/server/_libs/radix-ui__react-hover-card.mjs +1 -1
  36. package/.output/server/_libs/radix-ui__react-label.mjs +1 -1
  37. package/.output/server/_libs/radix-ui__react-menu.mjs +1 -1
  38. package/.output/server/_libs/radix-ui__react-menubar.mjs +1 -1
  39. package/.output/server/_libs/radix-ui__react-popover.mjs +1 -1
  40. package/.output/server/_libs/radix-ui__react-progress.mjs +1 -1
  41. package/.output/server/_libs/radix-ui__react-radio-group.mjs +1 -1
  42. package/.output/server/_libs/radix-ui__react-slider.mjs +1 -1
  43. package/.output/server/_libs/radix-ui__react-switch.mjs +1 -1
  44. package/.output/server/_libs/radix-ui__react-toast.mjs +2 -1
  45. package/.output/server/_libs/radix-ui__react-toggle-group.mjs +1 -1
  46. package/.output/server/_libs/radix-ui__react-toggle.mjs +1 -1
  47. package/.output/server/_libs/radix-ui__react-toolbar.mjs +1 -1
  48. package/.output/server/_libs/react-dom.mjs +14 -14
  49. package/.output/server/_libs/react-remove-scroll.mjs +1 -1
  50. package/.output/server/_libs/react.mjs +5 -5
  51. package/.output/server/_libs/readable-stream.mjs +36 -36
  52. package/.output/server/_libs/semver.mjs +138 -125
  53. package/.output/server/_libs/setimmediate.mjs +152 -1
  54. package/.output/server/_libs/style-to-js.mjs +3 -3
  55. package/.output/server/_libs/style-to-object.mjs +1 -1
  56. package/.output/server/_libs/swr.mjs +1 -0
  57. package/.output/server/_libs/tailwind-merge.mjs +286 -41
  58. package/.output/server/_libs/tanstack__react-router.mjs +1 -1
  59. package/.output/server/_libs/tanstack__react-store.mjs +1 -1
  60. package/.output/server/_libs/tanstack__react-virtual.mjs +82 -7
  61. package/.output/server/_libs/tanstack__virtual-core.mjs +58 -17
  62. package/.output/server/_libs/tslib.mjs +1 -576
  63. package/.output/server/_libs/ungap__structured-clone.mjs +14 -2
  64. package/.output/server/_libs/use-sidecar.mjs +1 -1
  65. package/.output/server/_libs/use-sync-external-store.mjs +3 -3
  66. package/.output/server/_ssr/{index-BnKtV0Rq.mjs → index-XyU4WVWt.mjs} +270 -83
  67. package/.output/server/_ssr/index.mjs +4 -4
  68. package/.output/server/_ssr/{router-CQ7QyRWm.mjs → router-BR4XO8XJ.mjs} +231 -33
  69. package/.output/server/_tanstack-start-manifest_v-DFoSjBkb.mjs +4 -0
  70. package/.output/server/index.mjs +121 -137
  71. package/.output/server/node_modules/tslib/modules/index.js +70 -0
  72. package/.output/server/node_modules/tslib/modules/package.json +3 -0
  73. package/.output/server/node_modules/tslib/package.json +47 -0
  74. package/.output/server/node_modules/tslib/tslib.js +484 -0
  75. package/.output/server/package.json +9 -0
  76. package/package.json +3 -3
  77. package/src/components/providers/SettingsDialog.tsx +60 -0
  78. package/src/components/proxy-viewer/ConversationGroup.tsx +7 -2
  79. package/src/components/proxy-viewer/ConversationHeader.tsx +53 -0
  80. package/src/components/proxy-viewer/LogEntry.tsx +67 -7
  81. package/src/components/proxy-viewer/LogEntryHeader.tsx +21 -18
  82. package/src/components/proxy-viewer/ReplayDialog.tsx +3 -3
  83. package/src/lib/runtimeConfig.ts +15 -0
  84. package/src/lib/useStripConfig.ts +86 -0
  85. package/src/proxy/claudeCodeStrip.ts +102 -0
  86. package/src/proxy/config.ts +176 -0
  87. package/src/proxy/constants.ts +11 -0
  88. package/src/proxy/handler.ts +23 -1
  89. package/src/routes/api/config.ts +45 -0
  90. package/.output/public/assets/index-Cc1oV0hF.css +0 -1
  91. package/.output/public/assets/index-DuukpBQJ.js +0 -122
  92. package/.output/server/_tanstack-start-manifest_v-CKgCX0Gs.mjs +0 -4
  93. /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 LogEntry({
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 (log.rawRequestBody === null) return;
65
- void window.navigator.clipboard.writeText(log.rawRequestBody).then(() => {
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">Parsed Response</TabsTrigger>
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={log.rawRequestBody}
177
+ text={displayedRequestBody}
118
178
  label="Copy Request"
119
179
  copied={requestCopied}
120
180
  onCopy={handleCopyRequest}
121
181
  />
122
182
  </div>
123
- {log.rawRequestBody !== null ? (
124
- <JsonViewerFromString text={log.rawRequestBody} defaultExpandDepth={1} />
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 LogEntryHeader({
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
- <Badge
97
- variant="outline"
98
- className={cn(
99
- "text-[10px] px-1.5 py-0 h-5 font-mono",
100
- log.apiFormat === "openai" && "border-blue-500/40 text-blue-400",
101
- log.apiFormat === "anthropic" && "border-orange-500/40 text-orange-400",
102
- log.apiFormat === "unknown" && "border-muted text-muted-foreground",
103
- )}
104
- >
105
- {API_FORMAT_LABELS[log.apiFormat] ?? log.apiFormat}
106
- </Badge>
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">Parsed Response</TabsTrigger>
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">Parsed Response</TabsTrigger>
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">Parsed Response</TabsTrigger>
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
+ }