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.
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/publish-npm.yml +33 -0
- package/README.md +199 -0
- package/app/api/events/route.ts +35 -0
- package/app/api/proxy/route.ts +42 -0
- package/app/api/requests/[id]/route.ts +82 -0
- package/app/api/requests/export/route.ts +124 -0
- package/app/api/requests/route.ts +32 -0
- package/app/dashboard/page.tsx +562 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +26 -0
- package/app/layout.tsx +34 -0
- package/app/page.tsx +5 -0
- package/app/v1/messages/route.ts +30 -0
- package/components/JsonModal.tsx +155 -0
- package/components/JsonViewer.tsx +185 -0
- package/dev.sh +19 -0
- package/eslint.config.mjs +18 -0
- package/lib/env.ts +52 -0
- package/lib/pricing.ts +131 -0
- package/lib/proxy/forwarder.test.ts +171 -0
- package/lib/proxy/forwarder.ts +96 -0
- package/lib/proxy/handlers.test.ts +276 -0
- package/lib/proxy/handlers.ts +340 -0
- package/lib/proxy/ws-server.ts +76 -0
- package/lib/recorder/index.ts +152 -0
- package/lib/recorder/schema.ts +41 -0
- package/lib/recorder/store.ts +141 -0
- package/next.config.ts +59 -0
- package/package.json +42 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/server.ts +64 -0
- package/tsconfig.json +34 -0
- package/tsconfig.server.json +11 -0
- package/vitest.config.ts +14 -0
|
@@ -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
|
+
});
|