@antseed/provider-core 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/README.md +86 -0
- package/dist/auth-swap.d.ts +19 -0
- package/dist/auth-swap.d.ts.map +1 -0
- package/dist/auth-swap.js +73 -0
- package/dist/auth-swap.js.map +1 -0
- package/dist/base-provider.d.ts +30 -0
- package/dist/base-provider.d.ts.map +1 -0
- package/dist/base-provider.js +65 -0
- package/dist/base-provider.js.map +1 -0
- package/dist/http-relay.d.ts +26 -0
- package/dist/http-relay.d.ts.map +1 -0
- package/dist/http-relay.js +153 -0
- package/dist/http-relay.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/token-providers.d.ts +47 -0
- package/dist/token-providers.d.ts.map +1 -0
- package/dist/token-providers.js +151 -0
- package/dist/token-providers.js.map +1 -0
- package/package.json +22 -0
- package/src/auth-swap.ts +89 -0
- package/src/base-provider.ts +84 -0
- package/src/http-relay.test.ts +269 -0
- package/src/http-relay.ts +187 -0
- package/src/index.ts +5 -0
- package/src/token-providers.test.ts +196 -0
- package/src/token-providers.ts +211 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
const REFRESH_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min before expiry
|
|
2
|
+
const DEFAULT_REFRESH_TIMEOUT_MS = 15_000;
|
|
3
|
+
const DEFAULT_OAUTH_TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token';
|
|
4
|
+
function getRefreshTimeoutMs() {
|
|
5
|
+
const raw = process.env['ANTSEED_OAUTH_REFRESH_TIMEOUT_MS'];
|
|
6
|
+
if (!raw) {
|
|
7
|
+
return DEFAULT_REFRESH_TIMEOUT_MS;
|
|
8
|
+
}
|
|
9
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
10
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
11
|
+
return DEFAULT_REFRESH_TIMEOUT_MS;
|
|
12
|
+
}
|
|
13
|
+
return parsed;
|
|
14
|
+
}
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// StaticTokenProvider
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/** Wraps a static API key. No refresh logic. */
|
|
19
|
+
export class StaticTokenProvider {
|
|
20
|
+
token;
|
|
21
|
+
constructor(token) {
|
|
22
|
+
this.token = token;
|
|
23
|
+
}
|
|
24
|
+
async getToken() {
|
|
25
|
+
return this.token;
|
|
26
|
+
}
|
|
27
|
+
stop() { }
|
|
28
|
+
getState() {
|
|
29
|
+
return { accessToken: this.token };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Manages an OAuth access/refresh token pair.
|
|
34
|
+
* Transparently refreshes the access token when it nears expiry.
|
|
35
|
+
*/
|
|
36
|
+
export class OAuthTokenProvider {
|
|
37
|
+
state;
|
|
38
|
+
refreshPromise = null;
|
|
39
|
+
tokenEndpoint;
|
|
40
|
+
requestEncoding;
|
|
41
|
+
clientId;
|
|
42
|
+
constructor(opts) {
|
|
43
|
+
this.state = {
|
|
44
|
+
accessToken: opts.accessToken,
|
|
45
|
+
refreshToken: opts.refreshToken,
|
|
46
|
+
expiresAt: opts.expiresAt,
|
|
47
|
+
};
|
|
48
|
+
this.tokenEndpoint = opts.tokenEndpoint ?? DEFAULT_OAUTH_TOKEN_ENDPOINT;
|
|
49
|
+
this.requestEncoding = opts.requestEncoding ?? 'form';
|
|
50
|
+
this.clientId = opts.clientId;
|
|
51
|
+
}
|
|
52
|
+
async getToken() {
|
|
53
|
+
if (!this.isExpiringSoon()) {
|
|
54
|
+
return this.state.accessToken;
|
|
55
|
+
}
|
|
56
|
+
// Deduplicate concurrent refresh calls
|
|
57
|
+
if (!this.refreshPromise) {
|
|
58
|
+
this.refreshPromise = this.refresh().finally(() => {
|
|
59
|
+
this.refreshPromise = null;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return this.refreshPromise;
|
|
63
|
+
}
|
|
64
|
+
stop() { }
|
|
65
|
+
/** Expose current state for persistence. */
|
|
66
|
+
getState() {
|
|
67
|
+
return { ...this.state };
|
|
68
|
+
}
|
|
69
|
+
isExpiringSoon() {
|
|
70
|
+
return Date.now() >= this.state.expiresAt - REFRESH_BUFFER_MS;
|
|
71
|
+
}
|
|
72
|
+
async refresh() {
|
|
73
|
+
const payload = {
|
|
74
|
+
grant_type: 'refresh_token',
|
|
75
|
+
refresh_token: this.state.refreshToken,
|
|
76
|
+
};
|
|
77
|
+
if (this.clientId) {
|
|
78
|
+
payload['client_id'] = this.clientId;
|
|
79
|
+
}
|
|
80
|
+
const headers = this.requestEncoding === 'json'
|
|
81
|
+
? { 'Content-Type': 'application/json' }
|
|
82
|
+
: { 'Content-Type': 'application/x-www-form-urlencoded' };
|
|
83
|
+
const body = this.requestEncoding === 'json'
|
|
84
|
+
? JSON.stringify(payload)
|
|
85
|
+
: new URLSearchParams(payload).toString();
|
|
86
|
+
const timeoutMs = getRefreshTimeoutMs();
|
|
87
|
+
const controller = new AbortController();
|
|
88
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
89
|
+
let res;
|
|
90
|
+
try {
|
|
91
|
+
res = await fetch(this.tokenEndpoint, {
|
|
92
|
+
method: 'POST',
|
|
93
|
+
headers,
|
|
94
|
+
body,
|
|
95
|
+
signal: controller.signal,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
100
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
101
|
+
throw new Error(`OAuth refresh timed out after ${timeoutMs}ms while reaching ${this.tokenEndpoint}. ` +
|
|
102
|
+
'Check network/proxy/firewall access or use apikey auth.');
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`OAuth refresh request failed: ${message}`);
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
clearTimeout(timeoutHandle);
|
|
108
|
+
}
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
const text = await res.text();
|
|
111
|
+
throw new Error(`OAuth refresh failed (${res.status}): ${text}`);
|
|
112
|
+
}
|
|
113
|
+
const data = (await res.json());
|
|
114
|
+
const newAccess = data.access_token ?? data.accessToken;
|
|
115
|
+
if (!newAccess) {
|
|
116
|
+
throw new Error('OAuth refresh response missing access token');
|
|
117
|
+
}
|
|
118
|
+
this.state.accessToken = newAccess;
|
|
119
|
+
if (data.refresh_token ?? data.refreshToken) {
|
|
120
|
+
this.state.refreshToken = (data.refresh_token ?? data.refreshToken);
|
|
121
|
+
}
|
|
122
|
+
if (data.expires_at ?? data.expiresAt) {
|
|
123
|
+
this.state.expiresAt = (data.expires_at ?? data.expiresAt);
|
|
124
|
+
}
|
|
125
|
+
else if (data.expires_in) {
|
|
126
|
+
this.state.expiresAt = Date.now() + data.expires_in * 1000;
|
|
127
|
+
}
|
|
128
|
+
return this.state.accessToken;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Create the appropriate TokenProvider from config values.
|
|
133
|
+
*/
|
|
134
|
+
export function createTokenProvider(opts) {
|
|
135
|
+
const authType = opts.authType ?? 'apikey';
|
|
136
|
+
switch (authType) {
|
|
137
|
+
case 'oauth':
|
|
138
|
+
if (!opts.refreshToken) {
|
|
139
|
+
// No refresh token — treat as static (works until expiry)
|
|
140
|
+
return new StaticTokenProvider(opts.authValue);
|
|
141
|
+
}
|
|
142
|
+
return new OAuthTokenProvider({
|
|
143
|
+
accessToken: opts.authValue,
|
|
144
|
+
refreshToken: opts.refreshToken,
|
|
145
|
+
expiresAt: opts.expiresAt ?? Date.now() + 3600_000,
|
|
146
|
+
});
|
|
147
|
+
default:
|
|
148
|
+
return new StaticTokenProvider(opts.authValue);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
//# sourceMappingURL=token-providers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"token-providers.js","sourceRoot":"","sources":["../src/token-providers.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@antseed/provider-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared provider infrastructure for Antseed plugins",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"prebuild": "rm -rf dist",
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"typecheck": "tsc --noEmit"
|
|
13
|
+
},
|
|
14
|
+
"peerDependencies": {
|
|
15
|
+
"@antseed/node": ">=0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"typescript": "^5.5.0",
|
|
19
|
+
"vitest": "^2.0.0",
|
|
20
|
+
"@antseed/node": "workspace:*"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/auth-swap.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { SerializedHttpRequest } from '@antseed/node';
|
|
2
|
+
|
|
3
|
+
/** Set of all known auth header names (lowercase). */
|
|
4
|
+
export const KNOWN_AUTH_HEADERS: Set<string> = new Set([
|
|
5
|
+
'authorization', 'x-api-key', 'x-goog-api-key',
|
|
6
|
+
]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strips all known auth headers and injects the seller's auth.
|
|
10
|
+
* Returns a NEW object (no mutation of the original).
|
|
11
|
+
*/
|
|
12
|
+
export function swapAuthHeader(
|
|
13
|
+
request: SerializedHttpRequest,
|
|
14
|
+
config: { authHeaderName: string; authHeaderValue: string; extraHeaders?: Record<string, string> }
|
|
15
|
+
): SerializedHttpRequest {
|
|
16
|
+
const newHeaders: Record<string, string> = {};
|
|
17
|
+
|
|
18
|
+
for (const [key, value] of Object.entries(request.headers)) {
|
|
19
|
+
if (!KNOWN_AUTH_HEADERS.has(key.toLowerCase())) {
|
|
20
|
+
newHeaders[key] = value;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
newHeaders[config.authHeaderName] = config.authHeaderValue;
|
|
25
|
+
|
|
26
|
+
// Inject any extra headers (e.g. anthropic-beta for OAuth).
|
|
27
|
+
// For anthropic-beta, merge with existing values (comma-separated)
|
|
28
|
+
// so the buyer's beta flags (e.g. context-management) are preserved.
|
|
29
|
+
if (config.extraHeaders) {
|
|
30
|
+
for (const [key, value] of Object.entries(config.extraHeaders)) {
|
|
31
|
+
const lower = key.toLowerCase();
|
|
32
|
+
if (lower === 'anthropic-beta' && newHeaders[key]) {
|
|
33
|
+
// Merge: deduplicate comma-separated beta flags
|
|
34
|
+
const existing = new Set(newHeaders[key]!.split(',').map((s) => s.trim()));
|
|
35
|
+
for (const flag of value.split(',').map((s) => s.trim())) {
|
|
36
|
+
existing.add(flag);
|
|
37
|
+
}
|
|
38
|
+
newHeaders[key] = [...existing].join(',');
|
|
39
|
+
} else {
|
|
40
|
+
newHeaders[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
requestId: request.requestId,
|
|
47
|
+
method: request.method,
|
|
48
|
+
path: request.path,
|
|
49
|
+
headers: newHeaders,
|
|
50
|
+
body: request.body,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Validate request against allowed models.
|
|
56
|
+
* Parses JSON body and enforces strict top-level `"model"` allow-list.
|
|
57
|
+
* Returns null if ok, error string if rejected.
|
|
58
|
+
*/
|
|
59
|
+
export function validateRequestModel(
|
|
60
|
+
request: SerializedHttpRequest,
|
|
61
|
+
allowedModels: string[]
|
|
62
|
+
): string | null {
|
|
63
|
+
// If allowedModels is empty, allow everything
|
|
64
|
+
if (allowedModels.length === 0) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let payload: unknown;
|
|
69
|
+
try {
|
|
70
|
+
payload = JSON.parse(new TextDecoder().decode(request.body)) as unknown;
|
|
71
|
+
} catch {
|
|
72
|
+
return "Invalid JSON request body";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
|
76
|
+
return 'Request body must be a JSON object containing a "model" field';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const model = (payload as Record<string, unknown>)["model"];
|
|
80
|
+
if (typeof model !== "string" || model.trim() === "") {
|
|
81
|
+
return 'Request is missing a valid "model" field';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!allowedModels.includes(model)) {
|
|
85
|
+
return `Model "${model}" is not in the allowed list`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Provider, SerializedHttpRequest, SerializedHttpResponse, SerializedHttpResponseChunk } from '@antseed/node';
|
|
2
|
+
import { HttpRelay, type RelayConfig } from './http-relay.js';
|
|
3
|
+
|
|
4
|
+
export interface BaseProviderConfig {
|
|
5
|
+
name: string;
|
|
6
|
+
models: string[];
|
|
7
|
+
pricing: Provider['pricing'];
|
|
8
|
+
relay: RelayConfig;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Convenience base class that wires HttpRelay to the Provider interface.
|
|
13
|
+
* Pattern adapted from provider-anthropic's AnthropicProvider.
|
|
14
|
+
*/
|
|
15
|
+
export class BaseProvider implements Provider {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
readonly models: string[];
|
|
18
|
+
readonly pricing: Provider['pricing'];
|
|
19
|
+
readonly maxConcurrency: number;
|
|
20
|
+
|
|
21
|
+
private readonly _relay: HttpRelay;
|
|
22
|
+
private _activeCount = 0;
|
|
23
|
+
|
|
24
|
+
private readonly _pending = new Map<
|
|
25
|
+
string,
|
|
26
|
+
{ resolve: (res: SerializedHttpResponse) => void; reject: (err: Error) => void }
|
|
27
|
+
>();
|
|
28
|
+
|
|
29
|
+
constructor(config: BaseProviderConfig) {
|
|
30
|
+
this.name = config.name;
|
|
31
|
+
this.models = config.models;
|
|
32
|
+
this.pricing = config.pricing;
|
|
33
|
+
this.maxConcurrency = config.relay.maxConcurrency;
|
|
34
|
+
|
|
35
|
+
this._relay = new HttpRelay(config.relay, {
|
|
36
|
+
onResponse: (response: SerializedHttpResponse) => {
|
|
37
|
+
this._resolvePending(response.requestId, response);
|
|
38
|
+
},
|
|
39
|
+
onResponseChunk: (_chunk: SerializedHttpResponseChunk) => {
|
|
40
|
+
// Chunks are accumulated by HttpRelay into a complete response
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private _resolvePending(requestId: string, response: SerializedHttpResponse): void {
|
|
46
|
+
const entry = this._pending.get(requestId);
|
|
47
|
+
if (entry) {
|
|
48
|
+
this._pending.delete(requestId);
|
|
49
|
+
entry.resolve(response);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async init(): Promise<void> {
|
|
54
|
+
if (this._relay['_config'].tokenProvider) {
|
|
55
|
+
await this._relay['_config'].tokenProvider.getToken();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async handleRequest(req: SerializedHttpRequest): Promise<SerializedHttpResponse> {
|
|
60
|
+
this._activeCount++;
|
|
61
|
+
try {
|
|
62
|
+
const responsePromise = new Promise<SerializedHttpResponse>((resolve, reject) => {
|
|
63
|
+
this._pending.set(req.requestId, { resolve, reject });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Fire the relay (it calls onResponse when done)
|
|
67
|
+
await this._relay.handleRequest(req);
|
|
68
|
+
|
|
69
|
+
return await responsePromise;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
this._pending.delete(req.requestId);
|
|
72
|
+
throw err;
|
|
73
|
+
} finally {
|
|
74
|
+
this._activeCount--;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
getCapacity(): { current: number; max: number } {
|
|
79
|
+
return {
|
|
80
|
+
current: this._activeCount,
|
|
81
|
+
max: this.maxConcurrency,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
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
|
+
});
|