@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.
@@ -0,0 +1,187 @@
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 ADDED
@@ -0,0 +1,5 @@
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';
@@ -0,0 +1,196 @@
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
+ });
@@ -0,0 +1,211 @@
1
+ import type { TokenProvider, TokenProviderState } from '@antseed/node';
2
+
3
+ export type { TokenProvider, TokenProviderState };
4
+
5
+ const REFRESH_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min before expiry
6
+ const DEFAULT_REFRESH_TIMEOUT_MS = 15_000;
7
+ const DEFAULT_OAUTH_TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token';
8
+
9
+ function getRefreshTimeoutMs(): number {
10
+ const raw = process.env['ANTSEED_OAUTH_REFRESH_TIMEOUT_MS'];
11
+ if (!raw) {
12
+ return DEFAULT_REFRESH_TIMEOUT_MS;
13
+ }
14
+ const parsed = Number.parseInt(raw.trim(), 10);
15
+ if (!Number.isFinite(parsed) || parsed <= 0) {
16
+ return DEFAULT_REFRESH_TIMEOUT_MS;
17
+ }
18
+ return parsed;
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // StaticTokenProvider
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /** Wraps a static API key. No refresh logic. */
26
+ export class StaticTokenProvider implements TokenProvider {
27
+ constructor(private readonly token: string) {}
28
+ async getToken(): Promise<string> {
29
+ return this.token;
30
+ }
31
+ stop(): void {}
32
+ getState(): TokenProviderState {
33
+ return { accessToken: this.token };
34
+ }
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // OAuthTokenProvider
39
+ // ---------------------------------------------------------------------------
40
+
41
+ interface OAuthState {
42
+ accessToken: string;
43
+ refreshToken: string;
44
+ expiresAt: number; // epoch ms
45
+ }
46
+
47
+ type RefreshRequestEncoding = 'form' | 'json';
48
+
49
+ /**
50
+ * Manages an OAuth access/refresh token pair.
51
+ * Transparently refreshes the access token when it nears expiry.
52
+ */
53
+ export class OAuthTokenProvider implements TokenProvider {
54
+ private state: OAuthState;
55
+ private refreshPromise: Promise<string> | null = null;
56
+ private readonly tokenEndpoint: string;
57
+ private readonly requestEncoding: RefreshRequestEncoding;
58
+ private readonly clientId: string | undefined;
59
+
60
+ constructor(opts: {
61
+ accessToken: string;
62
+ refreshToken: string;
63
+ expiresAt: number;
64
+ tokenEndpoint?: string;
65
+ requestEncoding?: RefreshRequestEncoding;
66
+ clientId?: string;
67
+ }) {
68
+ this.state = {
69
+ accessToken: opts.accessToken,
70
+ refreshToken: opts.refreshToken,
71
+ expiresAt: opts.expiresAt,
72
+ };
73
+ this.tokenEndpoint = opts.tokenEndpoint ?? DEFAULT_OAUTH_TOKEN_ENDPOINT;
74
+ this.requestEncoding = opts.requestEncoding ?? 'form';
75
+ this.clientId = opts.clientId;
76
+ }
77
+
78
+ async getToken(): Promise<string> {
79
+ if (!this.isExpiringSoon()) {
80
+ return this.state.accessToken;
81
+ }
82
+ // Deduplicate concurrent refresh calls
83
+ if (!this.refreshPromise) {
84
+ this.refreshPromise = this.refresh().finally(() => {
85
+ this.refreshPromise = null;
86
+ });
87
+ }
88
+ return this.refreshPromise;
89
+ }
90
+
91
+ stop(): void {}
92
+
93
+ /** Expose current state for persistence. */
94
+ getState(): TokenProviderState {
95
+ return { ...this.state };
96
+ }
97
+
98
+ private isExpiringSoon(): boolean {
99
+ return Date.now() >= this.state.expiresAt - REFRESH_BUFFER_MS;
100
+ }
101
+
102
+ private async refresh(): Promise<string> {
103
+ const payload: Record<string, string> = {
104
+ grant_type: 'refresh_token',
105
+ refresh_token: this.state.refreshToken,
106
+ };
107
+ if (this.clientId) {
108
+ payload['client_id'] = this.clientId;
109
+ }
110
+
111
+ const headers =
112
+ this.requestEncoding === 'json'
113
+ ? { 'Content-Type': 'application/json' }
114
+ : { 'Content-Type': 'application/x-www-form-urlencoded' };
115
+ const body =
116
+ this.requestEncoding === 'json'
117
+ ? JSON.stringify(payload)
118
+ : new URLSearchParams(payload).toString();
119
+
120
+ const timeoutMs = getRefreshTimeoutMs();
121
+ const controller = new AbortController();
122
+ const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
123
+
124
+ let res: Response;
125
+ try {
126
+ res = await fetch(this.tokenEndpoint, {
127
+ method: 'POST',
128
+ headers,
129
+ body,
130
+ signal: controller.signal,
131
+ });
132
+ } catch (err) {
133
+ const message = err instanceof Error ? err.message : String(err);
134
+ if (err instanceof Error && err.name === 'AbortError') {
135
+ throw new Error(
136
+ `OAuth refresh timed out after ${timeoutMs}ms while reaching ${this.tokenEndpoint}. ` +
137
+ 'Check network/proxy/firewall access or use apikey auth.'
138
+ );
139
+ }
140
+ throw new Error(`OAuth refresh request failed: ${message}`);
141
+ } finally {
142
+ clearTimeout(timeoutHandle);
143
+ }
144
+
145
+ if (!res.ok) {
146
+ const text = await res.text();
147
+ throw new Error(`OAuth refresh failed (${res.status}): ${text}`);
148
+ }
149
+
150
+ const data = (await res.json()) as {
151
+ access_token?: string;
152
+ accessToken?: string;
153
+ refresh_token?: string;
154
+ refreshToken?: string;
155
+ expires_in?: number;
156
+ expires_at?: number;
157
+ expiresAt?: number;
158
+ };
159
+
160
+ const newAccess = data.access_token ?? data.accessToken;
161
+ if (!newAccess) {
162
+ throw new Error('OAuth refresh response missing access token');
163
+ }
164
+
165
+ this.state.accessToken = newAccess;
166
+ if (data.refresh_token ?? data.refreshToken) {
167
+ this.state.refreshToken = (data.refresh_token ?? data.refreshToken)!;
168
+ }
169
+ if (data.expires_at ?? data.expiresAt) {
170
+ this.state.expiresAt = (data.expires_at ?? data.expiresAt)!;
171
+ } else if (data.expires_in) {
172
+ this.state.expiresAt = Date.now() + data.expires_in * 1000;
173
+ }
174
+
175
+ return this.state.accessToken;
176
+ }
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Factory
181
+ // ---------------------------------------------------------------------------
182
+
183
+ export type AuthType = 'apikey' | 'oauth';
184
+
185
+ /**
186
+ * Create the appropriate TokenProvider from config values.
187
+ */
188
+ export function createTokenProvider(opts: {
189
+ authType?: AuthType;
190
+ authValue: string;
191
+ refreshToken?: string;
192
+ expiresAt?: number;
193
+ }): TokenProvider {
194
+ const authType = opts.authType ?? 'apikey';
195
+
196
+ switch (authType) {
197
+ case 'oauth':
198
+ if (!opts.refreshToken) {
199
+ // No refresh token — treat as static (works until expiry)
200
+ return new StaticTokenProvider(opts.authValue);
201
+ }
202
+ return new OAuthTokenProvider({
203
+ accessToken: opts.authValue,
204
+ refreshToken: opts.refreshToken,
205
+ expiresAt: opts.expiresAt ?? Date.now() + 3600_000,
206
+ });
207
+
208
+ default:
209
+ return new StaticTokenProvider(opts.authValue);
210
+ }
211
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "outDir": "dist",
5
+ "rootDir": "src"
6
+ },
7
+ "include": ["src"],
8
+ "exclude": ["src/**/*.test.ts"]
9
+ }