@antseed/provider-core 0.1.0 → 0.1.2
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/LICENSE +674 -0
- package/dist/base-provider.d.ts +4 -1
- package/dist/base-provider.d.ts.map +1 -1
- package/dist/base-provider.js +97 -8
- package/dist/base-provider.js.map +1 -1
- package/dist/http-relay.d.ts +4 -0
- package/dist/http-relay.d.ts.map +1 -1
- package/dist/http-relay.js +53 -21
- package/dist/http-relay.js.map +1 -1
- package/package.json +12 -9
- package/src/auth-swap.ts +0 -89
- package/src/base-provider.ts +0 -84
- package/src/http-relay.test.ts +0 -269
- package/src/http-relay.ts +0 -187
- package/src/index.ts +0 -5
- package/src/token-providers.test.ts +0 -196
- package/src/token-providers.ts +0 -211
- package/tsconfig.json +0 -9
package/src/http-relay.test.ts
DELETED
|
@@ -1,269 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { HttpRelay, type RelayConfig, type RelayCallbacks } from './http-relay.js';
|
|
3
|
-
import type { SerializedHttpRequest, SerializedHttpResponse } from '@antseed/node';
|
|
4
|
-
|
|
5
|
-
function makeRequest(overrides?: Partial<SerializedHttpRequest>): SerializedHttpRequest {
|
|
6
|
-
return {
|
|
7
|
-
requestId: 'req-1',
|
|
8
|
-
method: 'POST',
|
|
9
|
-
path: '/v1/messages',
|
|
10
|
-
headers: { 'content-type': 'application/json' },
|
|
11
|
-
body: new TextEncoder().encode(JSON.stringify({ model: 'claude-sonnet-4-20250514', messages: [] })),
|
|
12
|
-
...overrides,
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function makeConfig(overrides?: Partial<RelayConfig>): RelayConfig {
|
|
17
|
-
return {
|
|
18
|
-
baseUrl: 'https://api.example.com',
|
|
19
|
-
authHeaderName: 'x-api-key',
|
|
20
|
-
authHeaderValue: 'sk-test-key',
|
|
21
|
-
maxConcurrency: 2,
|
|
22
|
-
allowedModels: ['claude-sonnet-4-20250514'],
|
|
23
|
-
...overrides,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
describe('HttpRelay', () => {
|
|
28
|
-
let fetchMock: ReturnType<typeof vi.fn>;
|
|
29
|
-
const originalFetch = globalThis.fetch;
|
|
30
|
-
|
|
31
|
-
beforeEach(() => {
|
|
32
|
-
fetchMock = vi.fn();
|
|
33
|
-
globalThis.fetch = fetchMock;
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
afterEach(() => {
|
|
37
|
-
globalThis.fetch = originalFetch;
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('relays a successful non-streaming response', async () => {
|
|
41
|
-
const responseBody = JSON.stringify({ id: 'msg_1', content: [{ text: 'Hello' }] });
|
|
42
|
-
fetchMock.mockResolvedValueOnce(new Response(responseBody, {
|
|
43
|
-
status: 200,
|
|
44
|
-
headers: { 'content-type': 'application/json', 'request-id': 'upstream-1' },
|
|
45
|
-
}));
|
|
46
|
-
|
|
47
|
-
const responses: SerializedHttpResponse[] = [];
|
|
48
|
-
const callbacks: RelayCallbacks = {
|
|
49
|
-
onResponse: (res) => responses.push(res),
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const relay = new HttpRelay(makeConfig(), callbacks);
|
|
53
|
-
await relay.handleRequest(makeRequest());
|
|
54
|
-
|
|
55
|
-
expect(responses).toHaveLength(1);
|
|
56
|
-
expect(responses[0]!.statusCode).toBe(200);
|
|
57
|
-
expect(responses[0]!.requestId).toBe('req-1');
|
|
58
|
-
expect(responses[0]!.headers['content-type']).toBe('application/json');
|
|
59
|
-
|
|
60
|
-
// Verify fetch was called with the right URL and auth
|
|
61
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
62
|
-
const [url, opts] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
63
|
-
expect(url).toBe('https://api.example.com/v1/messages');
|
|
64
|
-
expect((opts.headers as Record<string, string>)['x-api-key']).toBe('sk-test-key');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('rejects disallowed model', async () => {
|
|
68
|
-
const responses: SerializedHttpResponse[] = [];
|
|
69
|
-
const callbacks: RelayCallbacks = {
|
|
70
|
-
onResponse: (res) => responses.push(res),
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const relay = new HttpRelay(makeConfig(), callbacks);
|
|
74
|
-
const req = makeRequest({
|
|
75
|
-
body: new TextEncoder().encode(JSON.stringify({ model: 'gpt-4', messages: [] })),
|
|
76
|
-
});
|
|
77
|
-
await relay.handleRequest(req);
|
|
78
|
-
|
|
79
|
-
expect(responses).toHaveLength(1);
|
|
80
|
-
expect(responses[0]!.statusCode).toBe(403);
|
|
81
|
-
const body = JSON.parse(new TextDecoder().decode(responses[0]!.body)) as { error: string };
|
|
82
|
-
expect(body.error).toContain('not in the allowed list');
|
|
83
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it('allows any model when allowedModels is empty', async () => {
|
|
87
|
-
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
|
88
|
-
|
|
89
|
-
const responses: SerializedHttpResponse[] = [];
|
|
90
|
-
const callbacks: RelayCallbacks = {
|
|
91
|
-
onResponse: (res) => responses.push(res),
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const relay = new HttpRelay(makeConfig({ allowedModels: [] }), callbacks);
|
|
95
|
-
const req = makeRequest({
|
|
96
|
-
body: new TextEncoder().encode(JSON.stringify({ model: 'any-model', messages: [] })),
|
|
97
|
-
});
|
|
98
|
-
await relay.handleRequest(req);
|
|
99
|
-
|
|
100
|
-
expect(responses).toHaveLength(1);
|
|
101
|
-
expect(responses[0]!.statusCode).toBe(200);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('enforces concurrency limit', async () => {
|
|
105
|
-
// Create a fetch that blocks until we resolve it
|
|
106
|
-
let resolveFirst!: (value: Response) => void;
|
|
107
|
-
const firstFetch = new Promise<Response>((resolve) => { resolveFirst = resolve; });
|
|
108
|
-
|
|
109
|
-
fetchMock.mockReturnValueOnce(firstFetch);
|
|
110
|
-
|
|
111
|
-
const responses: SerializedHttpResponse[] = [];
|
|
112
|
-
const callbacks: RelayCallbacks = {
|
|
113
|
-
onResponse: (res) => responses.push(res),
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
const relay = new HttpRelay(makeConfig({ maxConcurrency: 1 }), callbacks);
|
|
117
|
-
|
|
118
|
-
// Start first request (fills concurrency) — do NOT await
|
|
119
|
-
const p1 = relay.handleRequest(makeRequest({ requestId: 'req-1' }));
|
|
120
|
-
|
|
121
|
-
// Yield to allow the first handleRequest to progress to its await
|
|
122
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
123
|
-
|
|
124
|
-
// Active count should be 1 now
|
|
125
|
-
expect(relay.getActiveCount()).toBe(1);
|
|
126
|
-
|
|
127
|
-
// Second request should be rejected (concurrency full)
|
|
128
|
-
await relay.handleRequest(makeRequest({ requestId: 'req-2' }));
|
|
129
|
-
expect(responses).toHaveLength(1);
|
|
130
|
-
expect(responses[0]!.requestId).toBe('req-2');
|
|
131
|
-
expect(responses[0]!.statusCode).toBe(429);
|
|
132
|
-
|
|
133
|
-
// Complete first request
|
|
134
|
-
resolveFirst(new Response('{}', { status: 200 }));
|
|
135
|
-
await p1;
|
|
136
|
-
expect(responses).toHaveLength(2);
|
|
137
|
-
expect(responses[1]!.requestId).toBe('req-1');
|
|
138
|
-
expect(responses[1]!.statusCode).toBe(200);
|
|
139
|
-
|
|
140
|
-
// Now concurrency is free, third request should work
|
|
141
|
-
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
|
142
|
-
await relay.handleRequest(makeRequest({ requestId: 'req-3' }));
|
|
143
|
-
expect(responses).toHaveLength(3);
|
|
144
|
-
expect(responses[2]!.requestId).toBe('req-3');
|
|
145
|
-
expect(responses[2]!.statusCode).toBe(200);
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
it('strips hop-by-hop and internal headers from request', async () => {
|
|
149
|
-
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
|
150
|
-
|
|
151
|
-
const responses: SerializedHttpResponse[] = [];
|
|
152
|
-
const callbacks: RelayCallbacks = {
|
|
153
|
-
onResponse: (res) => responses.push(res),
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const relay = new HttpRelay(makeConfig(), callbacks);
|
|
157
|
-
await relay.handleRequest(makeRequest({
|
|
158
|
-
headers: {
|
|
159
|
-
'content-type': 'application/json',
|
|
160
|
-
'connection': 'keep-alive',
|
|
161
|
-
'x-antseed-provider': 'anthropic',
|
|
162
|
-
'host': 'localhost:3000',
|
|
163
|
-
'x-custom': 'keep-me',
|
|
164
|
-
},
|
|
165
|
-
}));
|
|
166
|
-
|
|
167
|
-
const [, opts] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
168
|
-
const sentHeaders = opts.headers as Record<string, string>;
|
|
169
|
-
expect(sentHeaders['connection']).toBeUndefined();
|
|
170
|
-
expect(sentHeaders['x-antseed-provider']).toBeUndefined();
|
|
171
|
-
expect(sentHeaders['host']).toBeUndefined();
|
|
172
|
-
expect(sentHeaders['x-custom']).toBe('keep-me');
|
|
173
|
-
});
|
|
174
|
-
|
|
175
|
-
it('uses tokenProvider when present', async () => {
|
|
176
|
-
fetchMock.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
|
177
|
-
|
|
178
|
-
const responses: SerializedHttpResponse[] = [];
|
|
179
|
-
const callbacks: RelayCallbacks = {
|
|
180
|
-
onResponse: (res) => responses.push(res),
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const tokenProvider = {
|
|
184
|
-
getToken: vi.fn().mockResolvedValue('fresh-token'),
|
|
185
|
-
stop: vi.fn(),
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
const relay = new HttpRelay(
|
|
189
|
-
makeConfig({
|
|
190
|
-
authHeaderName: 'authorization',
|
|
191
|
-
authHeaderValue: 'Bearer old-token',
|
|
192
|
-
tokenProvider,
|
|
193
|
-
}),
|
|
194
|
-
callbacks,
|
|
195
|
-
);
|
|
196
|
-
|
|
197
|
-
await relay.handleRequest(makeRequest());
|
|
198
|
-
|
|
199
|
-
expect(tokenProvider.getToken).toHaveBeenCalledOnce();
|
|
200
|
-
const [, opts] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
201
|
-
const sentHeaders = opts.headers as Record<string, string>;
|
|
202
|
-
expect(sentHeaders['authorization']).toBe('Bearer fresh-token');
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it('returns 502 on fetch failure', async () => {
|
|
206
|
-
fetchMock.mockRejectedValueOnce(new Error('Connection refused'));
|
|
207
|
-
|
|
208
|
-
const responses: SerializedHttpResponse[] = [];
|
|
209
|
-
const callbacks: RelayCallbacks = {
|
|
210
|
-
onResponse: (res) => responses.push(res),
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
const relay = new HttpRelay(makeConfig(), callbacks);
|
|
214
|
-
await relay.handleRequest(makeRequest());
|
|
215
|
-
|
|
216
|
-
expect(responses).toHaveLength(1);
|
|
217
|
-
expect(responses[0]!.statusCode).toBe(502);
|
|
218
|
-
const body = JSON.parse(new TextDecoder().decode(responses[0]!.body)) as { error: string };
|
|
219
|
-
expect(body.error).toContain('Connection refused');
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
it('accumulates SSE response into complete body', async () => {
|
|
223
|
-
const sseChunks = [
|
|
224
|
-
'event: message\ndata: {"text":"Hello"}\n\n',
|
|
225
|
-
'event: message\ndata: {"text":"World"}\n\n',
|
|
226
|
-
];
|
|
227
|
-
const stream = new ReadableStream({
|
|
228
|
-
start(controller) {
|
|
229
|
-
for (const chunk of sseChunks) {
|
|
230
|
-
controller.enqueue(new TextEncoder().encode(chunk));
|
|
231
|
-
}
|
|
232
|
-
controller.close();
|
|
233
|
-
},
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
fetchMock.mockResolvedValueOnce(new Response(stream, {
|
|
237
|
-
status: 200,
|
|
238
|
-
headers: { 'content-type': 'text/event-stream' },
|
|
239
|
-
}));
|
|
240
|
-
|
|
241
|
-
const responses: SerializedHttpResponse[] = [];
|
|
242
|
-
const callbacks: RelayCallbacks = {
|
|
243
|
-
onResponse: (res) => responses.push(res),
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
const relay = new HttpRelay(makeConfig(), callbacks);
|
|
247
|
-
await relay.handleRequest(makeRequest());
|
|
248
|
-
|
|
249
|
-
expect(responses).toHaveLength(1);
|
|
250
|
-
expect(responses[0]!.statusCode).toBe(200);
|
|
251
|
-
const bodyText = new TextDecoder().decode(responses[0]!.body);
|
|
252
|
-
expect(bodyText).toContain('Hello');
|
|
253
|
-
expect(bodyText).toContain('World');
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('tracks active count correctly', async () => {
|
|
257
|
-
fetchMock.mockResolvedValue(new Response('{}', { status: 200 }));
|
|
258
|
-
|
|
259
|
-
const callbacks: RelayCallbacks = {
|
|
260
|
-
onResponse: () => {},
|
|
261
|
-
};
|
|
262
|
-
|
|
263
|
-
const relay = new HttpRelay(makeConfig(), callbacks);
|
|
264
|
-
expect(relay.getActiveCount()).toBe(0);
|
|
265
|
-
|
|
266
|
-
await relay.handleRequest(makeRequest());
|
|
267
|
-
expect(relay.getActiveCount()).toBe(0); // decremented after completion
|
|
268
|
-
});
|
|
269
|
-
});
|
package/src/http-relay.ts
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
import type { TokenProvider } from '@antseed/node';
|
|
2
|
-
import type { SerializedHttpRequest, SerializedHttpResponse, SerializedHttpResponseChunk } from '@antseed/node';
|
|
3
|
-
import { swapAuthHeader, validateRequestModel } from './auth-swap.js';
|
|
4
|
-
|
|
5
|
-
/** Hop-by-hop headers that must not be forwarded. */
|
|
6
|
-
const HOP_BY_HOP_HEADERS = new Set([
|
|
7
|
-
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
8
|
-
'te', 'trailers', 'transfer-encoding', 'upgrade',
|
|
9
|
-
]);
|
|
10
|
-
|
|
11
|
-
/** Internal headers used only within Antseed routing. */
|
|
12
|
-
const INTERNAL_HEADERS = new Set([
|
|
13
|
-
'x-antseed-provider',
|
|
14
|
-
]);
|
|
15
|
-
|
|
16
|
-
export interface RelayConfig {
|
|
17
|
-
baseUrl: string;
|
|
18
|
-
authHeaderName: string;
|
|
19
|
-
authHeaderValue: string;
|
|
20
|
-
tokenProvider?: TokenProvider;
|
|
21
|
-
extraHeaders?: Record<string, string>;
|
|
22
|
-
maxConcurrency: number;
|
|
23
|
-
allowedModels: string[];
|
|
24
|
-
timeoutMs?: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface RelayCallbacks {
|
|
28
|
-
onResponse: (response: SerializedHttpResponse) => void;
|
|
29
|
-
onResponseChunk?: (chunk: SerializedHttpResponseChunk) => void;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export class HttpRelay {
|
|
33
|
-
private readonly _config: RelayConfig;
|
|
34
|
-
private readonly _callbacks: RelayCallbacks;
|
|
35
|
-
private _activeCount = 0;
|
|
36
|
-
|
|
37
|
-
constructor(config: RelayConfig, callbacks: RelayCallbacks) {
|
|
38
|
-
this._config = config;
|
|
39
|
-
this._callbacks = callbacks;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
getActiveCount(): number {
|
|
43
|
-
return this._activeCount;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
private _sendError(requestId: string, statusCode: number, error: string): void {
|
|
47
|
-
this._callbacks.onResponse({
|
|
48
|
-
requestId,
|
|
49
|
-
statusCode,
|
|
50
|
-
headers: { 'content-type': 'application/json' },
|
|
51
|
-
body: new TextEncoder().encode(JSON.stringify({ error })),
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async handleRequest(request: SerializedHttpRequest): Promise<void> {
|
|
56
|
-
// Validate model against allowedModels
|
|
57
|
-
const validationError = validateRequestModel(request, this._config.allowedModels);
|
|
58
|
-
if (validationError) {
|
|
59
|
-
this._sendError(request.requestId, 403, validationError);
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Check concurrency
|
|
64
|
-
if (this._activeCount >= this._config.maxConcurrency) {
|
|
65
|
-
this._sendError(request.requestId, 429, 'Max concurrency reached');
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Increment active count
|
|
70
|
-
this._activeCount++;
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
// Resolve dynamic auth token if provider uses OAuth / keychain
|
|
74
|
-
let effectiveConfig: { authHeaderName: string; authHeaderValue: string; extraHeaders?: Record<string, string> } = {
|
|
75
|
-
authHeaderName: this._config.authHeaderName,
|
|
76
|
-
authHeaderValue: this._config.authHeaderValue,
|
|
77
|
-
extraHeaders: this._config.extraHeaders,
|
|
78
|
-
};
|
|
79
|
-
if (this._config.tokenProvider) {
|
|
80
|
-
const freshToken = await this._config.tokenProvider.getToken();
|
|
81
|
-
// Preserve Bearer prefix for OAuth providers that use Authorization header
|
|
82
|
-
const isBearer = this._config.authHeaderName === 'authorization';
|
|
83
|
-
const headerValue = isBearer ? `Bearer ${freshToken}` : freshToken;
|
|
84
|
-
effectiveConfig = { ...effectiveConfig, authHeaderValue: headerValue };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Swap auth headers
|
|
88
|
-
const swappedRequest = swapAuthHeader(request, effectiveConfig);
|
|
89
|
-
|
|
90
|
-
// Build upstream URL
|
|
91
|
-
const base = this._config.baseUrl.replace(/\/+$/, '');
|
|
92
|
-
const path = request.path.startsWith('/') ? request.path : `/${request.path}`;
|
|
93
|
-
const url = `${base}${path}`;
|
|
94
|
-
|
|
95
|
-
// Build fetch headers, stripping hop-by-hop
|
|
96
|
-
const fetchHeaders: Record<string, string> = {};
|
|
97
|
-
for (const [key, value] of Object.entries(swappedRequest.headers)) {
|
|
98
|
-
const lower = key.toLowerCase();
|
|
99
|
-
if (!HOP_BY_HOP_HEADERS.has(lower) && !INTERNAL_HEADERS.has(lower) && lower !== 'host' && lower !== 'content-length' && lower !== 'accept-encoding') {
|
|
100
|
-
fetchHeaders[key] = value;
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const timeoutMs = this._config.timeoutMs ?? 120_000;
|
|
105
|
-
const controller = new AbortController();
|
|
106
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
107
|
-
let fetchResponse: Response;
|
|
108
|
-
try {
|
|
109
|
-
fetchResponse = await fetch(url, {
|
|
110
|
-
method: swappedRequest.method,
|
|
111
|
-
headers: fetchHeaders,
|
|
112
|
-
body: swappedRequest.method !== 'GET' && swappedRequest.method !== 'HEAD'
|
|
113
|
-
? Buffer.from(swappedRequest.body)
|
|
114
|
-
: undefined,
|
|
115
|
-
signal: controller.signal,
|
|
116
|
-
});
|
|
117
|
-
} finally {
|
|
118
|
-
clearTimeout(timeout);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const contentType = fetchResponse.headers.get('content-type') ?? '';
|
|
122
|
-
const isSSE = contentType.includes('text/event-stream');
|
|
123
|
-
|
|
124
|
-
// Build response headers, stripping hop-by-hop and encoding headers.
|
|
125
|
-
// Node.js fetch auto-decompresses gzip/br responses, so we must strip
|
|
126
|
-
// content-encoding to prevent the client from double-decompressing.
|
|
127
|
-
const responseHeaders: Record<string, string> = {};
|
|
128
|
-
fetchResponse.headers.forEach((value, key) => {
|
|
129
|
-
const lower = key.toLowerCase();
|
|
130
|
-
if (!HOP_BY_HOP_HEADERS.has(lower) && lower !== 'content-encoding' && lower !== 'content-length') {
|
|
131
|
-
responseHeaders[lower] = value;
|
|
132
|
-
}
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
if (isSSE && fetchResponse.body) {
|
|
136
|
-
// Accumulate SSE body and send as a complete response so that
|
|
137
|
-
// upstream response headers (request-id, usage metadata, etc.)
|
|
138
|
-
// are preserved for the buyer.
|
|
139
|
-
const reader = fetchResponse.body.getReader();
|
|
140
|
-
const chunks: Uint8Array[] = [];
|
|
141
|
-
try {
|
|
142
|
-
while (true) {
|
|
143
|
-
const { done, value } = await reader.read();
|
|
144
|
-
if (done) break;
|
|
145
|
-
chunks.push(value);
|
|
146
|
-
}
|
|
147
|
-
} catch (err) {
|
|
148
|
-
chunks.push(
|
|
149
|
-
new TextEncoder().encode(
|
|
150
|
-
`event: error\ndata: ${err instanceof Error ? err.message : 'stream error'}\n\n`
|
|
151
|
-
),
|
|
152
|
-
);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const totalLength = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
156
|
-
const body = new Uint8Array(totalLength);
|
|
157
|
-
let offset = 0;
|
|
158
|
-
for (const c of chunks) {
|
|
159
|
-
body.set(c, offset);
|
|
160
|
-
offset += c.length;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
this._callbacks.onResponse({
|
|
164
|
-
requestId: request.requestId,
|
|
165
|
-
statusCode: fetchResponse.status,
|
|
166
|
-
headers: responseHeaders,
|
|
167
|
-
body,
|
|
168
|
-
});
|
|
169
|
-
} else {
|
|
170
|
-
// Complete response
|
|
171
|
-
const body = new Uint8Array(await fetchResponse.arrayBuffer());
|
|
172
|
-
this._callbacks.onResponse({
|
|
173
|
-
requestId: request.requestId,
|
|
174
|
-
statusCode: fetchResponse.status,
|
|
175
|
-
headers: responseHeaders,
|
|
176
|
-
body,
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
} catch (err) {
|
|
180
|
-
const errMsg = err instanceof Error ? err.message : String(err);
|
|
181
|
-
const sanitized = errMsg.replace(/sk-ant-[a-zA-Z0-9_-]+/g, 'sk-***');
|
|
182
|
-
this._sendError(request.requestId, 502, `Upstream error: ${sanitized}`);
|
|
183
|
-
} finally {
|
|
184
|
-
this._activeCount--;
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,5 +0,0 @@
|
|
|
1
|
-
export { HttpRelay, type RelayConfig, type RelayCallbacks } from './http-relay.js';
|
|
2
|
-
export { swapAuthHeader, validateRequestModel, KNOWN_AUTH_HEADERS } from './auth-swap.js';
|
|
3
|
-
export { StaticTokenProvider, OAuthTokenProvider, createTokenProvider, type AuthType } from './token-providers.js';
|
|
4
|
-
export type { TokenProvider, TokenProviderState } from './token-providers.js';
|
|
5
|
-
export { BaseProvider, type BaseProviderConfig } from './base-provider.js';
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import { StaticTokenProvider, OAuthTokenProvider, createTokenProvider } from './token-providers.js';
|
|
3
|
-
|
|
4
|
-
describe('StaticTokenProvider', () => {
|
|
5
|
-
it('returns the static token', async () => {
|
|
6
|
-
const provider = new StaticTokenProvider('sk-test-key');
|
|
7
|
-
const token = await provider.getToken();
|
|
8
|
-
expect(token).toBe('sk-test-key');
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('returns the same token on multiple calls', async () => {
|
|
12
|
-
const provider = new StaticTokenProvider('sk-test-key');
|
|
13
|
-
expect(await provider.getToken()).toBe('sk-test-key');
|
|
14
|
-
expect(await provider.getToken()).toBe('sk-test-key');
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('getState returns token state', () => {
|
|
18
|
-
const provider = new StaticTokenProvider('sk-test-key');
|
|
19
|
-
expect(provider.getState()).toEqual({ accessToken: 'sk-test-key' });
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it('stop is a no-op', () => {
|
|
23
|
-
const provider = new StaticTokenProvider('sk-test-key');
|
|
24
|
-
expect(() => provider.stop()).not.toThrow();
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe('OAuthTokenProvider', () => {
|
|
29
|
-
let fetchMock: ReturnType<typeof vi.fn>;
|
|
30
|
-
const originalFetch = globalThis.fetch;
|
|
31
|
-
|
|
32
|
-
beforeEach(() => {
|
|
33
|
-
fetchMock = vi.fn();
|
|
34
|
-
globalThis.fetch = fetchMock;
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
afterEach(() => {
|
|
38
|
-
globalThis.fetch = originalFetch;
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('returns access token when not expired', async () => {
|
|
42
|
-
const provider = new OAuthTokenProvider({
|
|
43
|
-
accessToken: 'access-1',
|
|
44
|
-
refreshToken: 'refresh-1',
|
|
45
|
-
expiresAt: Date.now() + 60 * 60 * 1000, // 1 hour from now
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
const token = await provider.getToken();
|
|
49
|
-
expect(token).toBe('access-1');
|
|
50
|
-
expect(fetchMock).not.toHaveBeenCalled();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('refreshes token when expired', async () => {
|
|
54
|
-
fetchMock.mockResolvedValueOnce(new Response(
|
|
55
|
-
JSON.stringify({
|
|
56
|
-
access_token: 'access-2',
|
|
57
|
-
refresh_token: 'refresh-2',
|
|
58
|
-
expires_in: 3600,
|
|
59
|
-
}),
|
|
60
|
-
{ status: 200 },
|
|
61
|
-
));
|
|
62
|
-
|
|
63
|
-
const provider = new OAuthTokenProvider({
|
|
64
|
-
accessToken: 'access-1',
|
|
65
|
-
refreshToken: 'refresh-1',
|
|
66
|
-
expiresAt: Date.now() - 1000, // already expired
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const token = await provider.getToken();
|
|
70
|
-
expect(token).toBe('access-2');
|
|
71
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it('refreshes when within 5 minute buffer', async () => {
|
|
75
|
-
fetchMock.mockResolvedValueOnce(new Response(
|
|
76
|
-
JSON.stringify({
|
|
77
|
-
access_token: 'access-refreshed',
|
|
78
|
-
expires_in: 3600,
|
|
79
|
-
}),
|
|
80
|
-
{ status: 200 },
|
|
81
|
-
));
|
|
82
|
-
|
|
83
|
-
const provider = new OAuthTokenProvider({
|
|
84
|
-
accessToken: 'access-old',
|
|
85
|
-
refreshToken: 'refresh-1',
|
|
86
|
-
expiresAt: Date.now() + 2 * 60 * 1000, // 2 minutes from now (within 5 min buffer)
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
const token = await provider.getToken();
|
|
90
|
-
expect(token).toBe('access-refreshed');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('deduplicates concurrent refresh calls', async () => {
|
|
94
|
-
let resolveRefresh!: (value: Response) => void;
|
|
95
|
-
const refreshPromise = new Promise<Response>((resolve) => { resolveRefresh = resolve; });
|
|
96
|
-
fetchMock.mockReturnValueOnce(refreshPromise);
|
|
97
|
-
|
|
98
|
-
const provider = new OAuthTokenProvider({
|
|
99
|
-
accessToken: 'access-1',
|
|
100
|
-
refreshToken: 'refresh-1',
|
|
101
|
-
expiresAt: Date.now() - 1000,
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
// Start two concurrent getToken calls
|
|
105
|
-
const p1 = provider.getToken();
|
|
106
|
-
const p2 = provider.getToken();
|
|
107
|
-
|
|
108
|
-
resolveRefresh(new Response(
|
|
109
|
-
JSON.stringify({ access_token: 'access-new', expires_in: 3600 }),
|
|
110
|
-
{ status: 200 },
|
|
111
|
-
));
|
|
112
|
-
|
|
113
|
-
const [t1, t2] = await Promise.all([p1, p2]);
|
|
114
|
-
expect(t1).toBe('access-new');
|
|
115
|
-
expect(t2).toBe('access-new');
|
|
116
|
-
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('throws on refresh failure', async () => {
|
|
120
|
-
fetchMock.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }));
|
|
121
|
-
|
|
122
|
-
const provider = new OAuthTokenProvider({
|
|
123
|
-
accessToken: 'access-1',
|
|
124
|
-
refreshToken: 'refresh-1',
|
|
125
|
-
expiresAt: Date.now() - 1000,
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
await expect(provider.getToken()).rejects.toThrow('OAuth refresh failed (401)');
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('getState returns current state', () => {
|
|
132
|
-
const provider = new OAuthTokenProvider({
|
|
133
|
-
accessToken: 'access-1',
|
|
134
|
-
refreshToken: 'refresh-1',
|
|
135
|
-
expiresAt: 1234567890,
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
const state = provider.getState();
|
|
139
|
-
expect(state).toEqual({
|
|
140
|
-
accessToken: 'access-1',
|
|
141
|
-
refreshToken: 'refresh-1',
|
|
142
|
-
expiresAt: 1234567890,
|
|
143
|
-
});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
it('updates refresh token when provided in response', async () => {
|
|
147
|
-
fetchMock.mockResolvedValueOnce(new Response(
|
|
148
|
-
JSON.stringify({
|
|
149
|
-
access_token: 'access-2',
|
|
150
|
-
refresh_token: 'refresh-2',
|
|
151
|
-
expires_in: 3600,
|
|
152
|
-
}),
|
|
153
|
-
{ status: 200 },
|
|
154
|
-
));
|
|
155
|
-
|
|
156
|
-
const provider = new OAuthTokenProvider({
|
|
157
|
-
accessToken: 'access-1',
|
|
158
|
-
refreshToken: 'refresh-1',
|
|
159
|
-
expiresAt: Date.now() - 1000,
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
await provider.getToken();
|
|
163
|
-
const state = provider.getState();
|
|
164
|
-
expect(state.refreshToken).toBe('refresh-2');
|
|
165
|
-
});
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
describe('createTokenProvider', () => {
|
|
169
|
-
it('creates StaticTokenProvider for apikey type', () => {
|
|
170
|
-
const provider = createTokenProvider({ authType: 'apikey', authValue: 'sk-key' });
|
|
171
|
-
expect(provider).toBeInstanceOf(StaticTokenProvider);
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
it('defaults to StaticTokenProvider when authType is omitted', () => {
|
|
175
|
-
const provider = createTokenProvider({ authValue: 'sk-key' });
|
|
176
|
-
expect(provider).toBeInstanceOf(StaticTokenProvider);
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('creates OAuthTokenProvider for oauth type with refresh token', () => {
|
|
180
|
-
const provider = createTokenProvider({
|
|
181
|
-
authType: 'oauth',
|
|
182
|
-
authValue: 'access-1',
|
|
183
|
-
refreshToken: 'refresh-1',
|
|
184
|
-
expiresAt: Date.now() + 3600_000,
|
|
185
|
-
});
|
|
186
|
-
expect(provider).toBeInstanceOf(OAuthTokenProvider);
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
it('creates StaticTokenProvider for oauth type without refresh token', () => {
|
|
190
|
-
const provider = createTokenProvider({
|
|
191
|
-
authType: 'oauth',
|
|
192
|
-
authValue: 'access-1',
|
|
193
|
-
});
|
|
194
|
-
expect(provider).toBeInstanceOf(StaticTokenProvider);
|
|
195
|
-
});
|
|
196
|
-
});
|