claude-code-inspector 0.1.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.
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ extractSessionId,
4
+ extractUsage,
5
+ buildForwardHeaders,
6
+ isSseResponse,
7
+ } from './forwarder';
8
+
9
+ describe('extractSessionId', () => {
10
+ it('should extract session id from headers (lowercase)', () => {
11
+ const headers = { 'x-session-id': 'session-123' };
12
+ const body = {};
13
+ expect(extractSessionId(headers, body)).toBe('session-123');
14
+ });
15
+
16
+ it('should extract session id from headers (uppercase)', () => {
17
+ const headers = { 'X-Session-Id': 'session-456' };
18
+ const body = {};
19
+ expect(extractSessionId(headers, body)).toBe('session-456');
20
+ });
21
+
22
+ it('should extract session id from body metadata', () => {
23
+ const headers = {};
24
+ const body = { metadata: { session_id: 'session-789' } };
25
+ expect(extractSessionId(headers, body)).toBe('session-789');
26
+ });
27
+
28
+ it('should prioritize headers over body', () => {
29
+ const headers = { 'x-session-id': 'header-session' };
30
+ const body = { metadata: { session_id: 'body-session' } };
31
+ expect(extractSessionId(headers, body)).toBe('header-session');
32
+ });
33
+
34
+ it('should return null when no session id found', () => {
35
+ const headers = {};
36
+ const body = {};
37
+ expect(extractSessionId(headers, body)).toBeNull();
38
+ });
39
+ });
40
+
41
+ describe('extractUsage', () => {
42
+ it('should extract usage from response', () => {
43
+ const response = {
44
+ usage: {
45
+ input_tokens: 100,
46
+ output_tokens: 50,
47
+ cache_read_input_tokens: 20,
48
+ cache_creation_input_tokens: 10,
49
+ },
50
+ };
51
+ const result = extractUsage(response);
52
+ expect(result).toEqual({
53
+ input_tokens: 100,
54
+ output_tokens: 50,
55
+ cache_read_tokens: 20,
56
+ cache_creation_tokens: 10,
57
+ });
58
+ });
59
+
60
+ it('should return zeros when no usage', () => {
61
+ const response = {};
62
+ const result = extractUsage(response);
63
+ expect(result).toEqual({
64
+ input_tokens: 0,
65
+ output_tokens: 0,
66
+ cache_read_tokens: 0,
67
+ cache_creation_tokens: 0,
68
+ });
69
+ });
70
+
71
+ it('should handle partial usage', () => {
72
+ const response = {
73
+ usage: {
74
+ input_tokens: 100,
75
+ output_tokens: 50,
76
+ },
77
+ };
78
+ const result = extractUsage(response);
79
+ expect(result).toEqual({
80
+ input_tokens: 100,
81
+ output_tokens: 50,
82
+ cache_read_tokens: 0,
83
+ cache_creation_tokens: 0,
84
+ });
85
+ });
86
+
87
+ it('should handle null response', () => {
88
+ const result = extractUsage(null);
89
+ expect(result).toEqual({
90
+ input_tokens: 0,
91
+ output_tokens: 0,
92
+ cache_read_tokens: 0,
93
+ cache_creation_tokens: 0,
94
+ });
95
+ });
96
+ });
97
+
98
+ describe('buildForwardHeaders', () => {
99
+ it('should build headers with api key', () => {
100
+ const originalHeaders = {};
101
+ const config = { baseUrl: 'https://api.example.com', apiKey: 'test-key' };
102
+ const result = buildForwardHeaders(originalHeaders, config);
103
+ expect(result).toEqual({
104
+ 'Content-Type': 'application/json',
105
+ 'Authorization': 'Bearer test-key',
106
+ });
107
+ });
108
+
109
+ it('should preserve specific headers', () => {
110
+ const originalHeaders = {
111
+ 'x-api-key': 'original-key',
112
+ 'x-request-id': 'req-123',
113
+ 'user-agent': 'test-agent',
114
+ 'other-header': 'should-not-preserve',
115
+ };
116
+ const config = { baseUrl: 'https://api.example.com', apiKey: 'test-key' };
117
+ const result = buildForwardHeaders(originalHeaders, config);
118
+ expect(result['x-api-key']).toBe('original-key');
119
+ expect(result['x-request-id']).toBe('req-123');
120
+ expect(result['user-agent']).toBe('test-agent');
121
+ expect(result['other-header']).toBeUndefined();
122
+ });
123
+
124
+ it('should not preserve headers that are not in the list', () => {
125
+ const originalHeaders = {
126
+ 'some-random-header': 'value',
127
+ };
128
+ const config = { baseUrl: 'https://api.example.com', apiKey: 'test-key' };
129
+ const result = buildForwardHeaders(originalHeaders, config);
130
+ expect(result['some-random-header']).toBeUndefined();
131
+ });
132
+ });
133
+
134
+ describe('isSseResponse', () => {
135
+ it('should return true for text/event-stream content type', () => {
136
+ const response = {
137
+ headers: {
138
+ 'content-type': 'text/event-stream',
139
+ },
140
+ };
141
+ expect(isSseResponse(response)).toBe(true);
142
+ });
143
+
144
+ it('should return true for text/event-stream with charset', () => {
145
+ const response = {
146
+ headers: {
147
+ 'content-type': 'text/event-stream; charset=utf-8',
148
+ },
149
+ };
150
+ expect(isSseResponse(response)).toBe(true);
151
+ });
152
+
153
+ it('should return false for application/json', () => {
154
+ const response = {
155
+ headers: {
156
+ 'content-type': 'application/json',
157
+ },
158
+ };
159
+ expect(isSseResponse(response)).toBe(false);
160
+ });
161
+
162
+ it('should return false when no content-type', () => {
163
+ const response = { headers: {} };
164
+ expect(isSseResponse(response)).toBe(false);
165
+ });
166
+
167
+ it('should return false when no headers', () => {
168
+ const response = {};
169
+ expect(isSseResponse(response)).toBe(false);
170
+ });
171
+ });
@@ -0,0 +1,96 @@
1
+ import axios from 'axios';
2
+
3
+ export interface UpstreamConfig {
4
+ baseUrl: string;
5
+ apiKey: string;
6
+ }
7
+
8
+ /**
9
+ * 提取 Session ID
10
+ */
11
+ export function extractSessionId(headers: Record<string, string>, body: any): string | null {
12
+ return (
13
+ headers['x-session-id'] ||
14
+ headers['X-Session-Id'] ||
15
+ body?.metadata?.session_id ||
16
+ null
17
+ );
18
+ }
19
+
20
+ /**
21
+ * 提取 Token 使用量
22
+ */
23
+ export function extractUsage(response: any): {
24
+ input_tokens: number;
25
+ output_tokens: number;
26
+ cache_read_tokens: number;
27
+ cache_creation_tokens: number;
28
+ } {
29
+ const usage = response?.usage;
30
+ if (!usage) {
31
+ return {
32
+ input_tokens: 0,
33
+ output_tokens: 0,
34
+ cache_read_tokens: 0,
35
+ cache_creation_tokens: 0,
36
+ };
37
+ }
38
+
39
+ return {
40
+ input_tokens: usage.input_tokens || 0,
41
+ output_tokens: usage.output_tokens || 0,
42
+ cache_read_tokens: usage.cache_read_input_tokens || 0,
43
+ cache_creation_tokens: usage.cache_creation_input_tokens || 0,
44
+ };
45
+ }
46
+
47
+ /**
48
+ * 构建转发 Headers
49
+ */
50
+ export function buildForwardHeaders(
51
+ originalHeaders: Record<string, string>,
52
+ config: UpstreamConfig
53
+ ): Record<string, string> {
54
+ const headers: Record<string, string> = {
55
+ 'Content-Type': 'application/json',
56
+ 'Authorization': `Bearer ${config.apiKey}`,
57
+ };
58
+
59
+ // 保留部分原始 headers
60
+ const preserveHeaders = ['x-api-key', 'x-request-id', 'user-agent'];
61
+ for (const key of preserveHeaders) {
62
+ if (originalHeaders[key]) {
63
+ headers[key] = originalHeaders[key];
64
+ }
65
+ }
66
+
67
+ return headers;
68
+ }
69
+
70
+ /**
71
+ * 判断是否为 SSE 响应
72
+ */
73
+ export function isSseResponse(response: any): boolean {
74
+ const contentType = response?.headers?.['content-type'] || '';
75
+ return contentType.includes('text/event-stream');
76
+ }
77
+
78
+ /**
79
+ * 转发请求到上游 API
80
+ */
81
+ export async function forwardRequest(
82
+ endpoint: string,
83
+ body: any,
84
+ headers: Record<string, string>,
85
+ config: UpstreamConfig
86
+ ): Promise<any> {
87
+ const url = `${config.baseUrl}${endpoint}`;
88
+
89
+
90
+ const forwardHeaders = buildForwardHeaders(headers, config);
91
+
92
+ return axios.post(url, body, {
93
+ headers: forwardHeaders,
94
+ responseType: 'stream',
95
+ });
96
+ }
@@ -0,0 +1,276 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ // Mock dependencies before importing
4
+ vi.mock('../recorder', () => ({
5
+ recordRequest: vi.fn(),
6
+ updateRequestResponse: vi.fn(),
7
+ recordSseEvent: vi.fn(),
8
+ }));
9
+
10
+ vi.mock('./ws-server', () => ({
11
+ broadcastNewRequest: vi.fn(),
12
+ broadcastRequestUpdate: vi.fn(),
13
+ broadcastSseEvent: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('uuid', () => ({
17
+ v4: () => 'test-uuid-1234',
18
+ }));
19
+
20
+ vi.mock('../../lib/env', () => ({
21
+ initEnv: vi.fn(),
22
+ }));
23
+
24
+ // Mock axios to avoid actual network requests
25
+ vi.mock('axios', () => ({
26
+ default: {
27
+ post: vi.fn(),
28
+ },
29
+ }));
30
+
31
+ import axios from 'axios';
32
+ import { recordRequest, updateRequestResponse } from '../recorder';
33
+ import { broadcastNewRequest, broadcastRequestUpdate } from './ws-server';
34
+
35
+ // Import after mocking
36
+ const { handleMessages } = await import('./handlers');
37
+
38
+ const mockAxios = vi.mocked(axios);
39
+ const mockRecordRequest = vi.mocked(recordRequest);
40
+ const mockUpdateRequestResponse = vi.mocked(updateRequestResponse);
41
+ const mockBroadcastNewRequest = vi.mocked(broadcastNewRequest);
42
+ const mockBroadcastRequestUpdate = vi.mocked(broadcastRequestUpdate);
43
+
44
+ describe('handlers', () => {
45
+ beforeEach(() => {
46
+ vi.clearAllMocks();
47
+ // Set environment variables for testing
48
+ process.env.UPSTREAM_BASE_URL = 'https://api.anthropic.com';
49
+ process.env.UPSTREAM_API_KEY = 'test-api-key';
50
+ process.env.ANTHROPIC_API_KEY = 'test-api-key';
51
+ });
52
+
53
+ afterEach(() => {
54
+ vi.clearAllMocks();
55
+ });
56
+
57
+ describe('handleMessages - non-streaming response', () => {
58
+ it('should handle non-streaming response correctly', async () => {
59
+ // Create a simple mock response (not axios-like with circular refs)
60
+ const mockResponse = {
61
+ status: 200,
62
+ headers: {
63
+ 'content-type': 'application/json',
64
+ 'x-request-id': 'req-123',
65
+ },
66
+ data: {
67
+ id: 'msg-123',
68
+ type: 'message',
69
+ role: 'assistant',
70
+ content: [{ type: 'text', text: 'Hello!' }],
71
+ usage: {
72
+ input_tokens: 10,
73
+ output_tokens: 5,
74
+ },
75
+ },
76
+ };
77
+
78
+ // Mock axios.post to return a stream-like response that we convert
79
+ const mockStream = {
80
+ async *[Symbol.asyncIterator]() {
81
+ yield Buffer.from(JSON.stringify(mockResponse.data));
82
+ },
83
+ };
84
+
85
+ mockAxios.post.mockResolvedValueOnce({
86
+ ...mockResponse,
87
+ data: mockStream,
88
+ headers: mockResponse.headers,
89
+ });
90
+
91
+ const request = new Request('http://localhost:3000/v1/messages', {
92
+ method: 'POST',
93
+ headers: { 'content-type': 'application/json' },
94
+ body: JSON.stringify({ model: 'claude-3-opus', messages: [] }),
95
+ });
96
+
97
+ const body = { model: 'claude-3-opus', messages: [] };
98
+ const headers = { 'content-type': 'application/json' };
99
+
100
+ const result = await handleMessages(request, body, headers);
101
+
102
+ // Verify request was recorded
103
+ expect(mockRecordRequest).toHaveBeenCalled();
104
+ expect(mockBroadcastNewRequest).toHaveBeenCalled();
105
+ });
106
+
107
+ it('should serialize headers without circular reference error', async () => {
108
+ // This test specifically addresses the bug we fixed:
109
+ // "TypeError: Converting circular structure to JSON"
110
+ // caused by axios response headers having circular references
111
+
112
+ // Simulate axios response with headers that would fail JSON.stringify
113
+ const mockResponse = {
114
+ status: 200,
115
+ // These headers simulate what axios returns (could have circular refs)
116
+ headers: {
117
+ 'content-type': 'application/json',
118
+ 'x-request-id': 'req-123',
119
+ },
120
+ data: {
121
+ usage: { input_tokens: 10, output_tokens: 5 },
122
+ },
123
+ };
124
+
125
+ const mockStream = {
126
+ async *[Symbol.asyncIterator]() {
127
+ yield Buffer.from(JSON.stringify(mockResponse.data));
128
+ },
129
+ };
130
+
131
+ mockAxios.post.mockResolvedValueOnce({
132
+ ...mockResponse,
133
+ data: mockStream,
134
+ headers: mockResponse.headers,
135
+ });
136
+
137
+ const body = { model: 'claude-3-opus' };
138
+ const headers = {};
139
+
140
+ await handleMessages(new Request('http://localhost:3000/v1/messages'), body, headers);
141
+
142
+ // The key test: updateRequestResponse should be called with serializable headers
143
+ expect(mockUpdateRequestResponse).toHaveBeenCalled();
144
+
145
+ // Get the headers passed to updateRequestResponse
146
+ const callArgs = mockUpdateRequestResponse.mock.calls[0][0];
147
+
148
+ // This should not throw "Converting circular structure to JSON"
149
+ expect(() => JSON.stringify(callArgs.headers)).not.toThrow();
150
+
151
+ // Headers should be a simple object
152
+ expect(typeof callArgs.headers).toBe('object');
153
+ });
154
+ });
155
+
156
+ describe('handleMessages - error handling', () => {
157
+ it('should handle request errors', async () => {
158
+ const error = new Error('Network error');
159
+ mockAxios.post.mockRejectedValueOnce(error);
160
+
161
+ const body = { model: 'claude-3-opus' };
162
+ const headers = {};
163
+
164
+ const result = await handleMessages(
165
+ new Request('http://localhost:3000/v1/messages'),
166
+ body,
167
+ headers
168
+ );
169
+
170
+ expect(result.status).toBe(500);
171
+ const responseBody = await result.json();
172
+ expect(responseBody.error).toBe('Network error');
173
+ });
174
+
175
+ it('should handle API errors with status code', async () => {
176
+ const apiError = {
177
+ response: {
178
+ status: 429,
179
+ data: { error: { message: 'Rate limit exceeded' } },
180
+ },
181
+ };
182
+ mockAxios.post.mockRejectedValueOnce(apiError);
183
+
184
+ const body = { model: 'claude-3-opus' };
185
+ const headers = {};
186
+
187
+ const result = await handleMessages(
188
+ new Request('http://localhost:3000/v1/messages'),
189
+ body,
190
+ headers
191
+ );
192
+
193
+ expect(result.status).toBe(429);
194
+ const responseBody = await result.json();
195
+ expect(responseBody.error).toBe('Rate limit exceeded');
196
+ });
197
+ });
198
+
199
+ describe('environment configuration', () => {
200
+ it('should use UPSTREAM_* variables for forwarding', async () => {
201
+ process.env.UPSTREAM_BASE_URL = 'https://custom-api.example.com';
202
+ process.env.UPSTREAM_API_KEY = 'custom-key';
203
+ process.env.ANTHROPIC_BASE_URL = 'http://localhost:3000'; // Should NOT be used
204
+
205
+ const mockResponse = {
206
+ status: 200,
207
+ headers: { 'content-type': 'application/json' },
208
+ data: { usage: { input_tokens: 10, output_tokens: 5 } },
209
+ };
210
+
211
+ const mockStream = {
212
+ async *[Symbol.asyncIterator]() {
213
+ yield Buffer.from(JSON.stringify(mockResponse.data));
214
+ },
215
+ };
216
+
217
+ mockAxios.post.mockResolvedValueOnce({
218
+ ...mockResponse,
219
+ data: mockStream,
220
+ });
221
+
222
+ const body = { model: 'claude-3-opus' };
223
+ const headers = {};
224
+
225
+ await handleMessages(new Request('http://localhost:3000/v1/messages'), body, headers);
226
+
227
+ // Verify axios was called with the correct URL
228
+ expect(mockAxios.post).toHaveBeenCalledWith(
229
+ 'https://custom-api.example.com/v1/messages',
230
+ expect.anything(),
231
+ expect.objectContaining({
232
+ headers: expect.objectContaining({
233
+ Authorization: 'Bearer custom-key',
234
+ }),
235
+ })
236
+ );
237
+ });
238
+
239
+ it('should fallback to ANTHROPIC_API_KEY if UPSTREAM_API_KEY not set', async () => {
240
+ delete process.env.UPSTREAM_API_KEY;
241
+ process.env.ANTHROPIC_API_KEY = 'anthropic-key';
242
+
243
+ const mockResponse = {
244
+ status: 200,
245
+ headers: { 'content-type': 'application/json' },
246
+ data: { usage: { input_tokens: 10, output_tokens: 5 } },
247
+ };
248
+
249
+ const mockStream = {
250
+ async *[Symbol.asyncIterator]() {
251
+ yield Buffer.from(JSON.stringify(mockResponse.data));
252
+ },
253
+ };
254
+
255
+ mockAxios.post.mockResolvedValueOnce({
256
+ ...mockResponse,
257
+ data: mockStream,
258
+ });
259
+
260
+ const body = { model: 'claude-3-opus' };
261
+ const headers = {};
262
+
263
+ await handleMessages(new Request('http://localhost:3000/v1/messages'), body, headers);
264
+
265
+ expect(mockAxios.post).toHaveBeenCalledWith(
266
+ expect.any(String),
267
+ expect.anything(),
268
+ expect.objectContaining({
269
+ headers: expect.objectContaining({
270
+ Authorization: 'Bearer anthropic-key',
271
+ }),
272
+ })
273
+ );
274
+ });
275
+ });
276
+ });