@vellumai/vellum-gateway 0.1.7
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/.dockerignore +7 -0
- package/.env.example +59 -0
- package/Dockerfile +44 -0
- package/README.md +186 -0
- package/bun.lock +391 -0
- package/eslint.config.mjs +23 -0
- package/knip.json +8 -0
- package/package.json +27 -0
- package/src/__tests__/bearer-auth.test.ts +40 -0
- package/src/__tests__/config.test.ts +236 -0
- package/src/__tests__/dedup-cache.test.ts +101 -0
- package/src/__tests__/load-guards.test.ts +86 -0
- package/src/__tests__/probes.test.ts +94 -0
- package/src/__tests__/reply-path.test.ts +51 -0
- package/src/__tests__/resolve-assistant.test.ts +118 -0
- package/src/__tests__/runtime-client.test.ts +228 -0
- package/src/__tests__/runtime-proxy-auth.test.ts +127 -0
- package/src/__tests__/runtime-proxy.test.ts +262 -0
- package/src/__tests__/schema.test.ts +128 -0
- package/src/__tests__/telegram-normalize.test.ts +303 -0
- package/src/__tests__/telegram-only-default.test.ts +134 -0
- package/src/__tests__/telegram-send-attachments.test.ts +185 -0
- package/src/cli/schema.ts +8 -0
- package/src/config.ts +254 -0
- package/src/dedup-cache.ts +104 -0
- package/src/handlers/handle-inbound.ts +104 -0
- package/src/http/auth/bearer.ts +34 -0
- package/src/http/routes/runtime-proxy.ts +143 -0
- package/src/http/routes/telegram-webhook.ts +272 -0
- package/src/index.ts +117 -0
- package/src/logger.ts +103 -0
- package/src/routing/resolve-assistant.ts +45 -0
- package/src/routing/types.ts +11 -0
- package/src/runtime/client.ts +212 -0
- package/src/schema.ts +383 -0
- package/src/telegram/api.ts +153 -0
- package/src/telegram/download.ts +63 -0
- package/src/telegram/normalize.ts +118 -0
- package/src/telegram/send.ts +107 -0
- package/src/telegram/verify.ts +17 -0
- package/src/types.ts +37 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
|
+
import { forwardToRuntime, downloadAttachment } from "../runtime/client.js";
|
|
3
|
+
import type { RuntimeAttachmentMeta } from "../runtime/client.js";
|
|
4
|
+
import type { GatewayConfig } from "../config.js";
|
|
5
|
+
|
|
6
|
+
const makeConfig = (overrides: Partial<GatewayConfig> = {}): GatewayConfig => ({
|
|
7
|
+
telegramBotToken: "tok",
|
|
8
|
+
telegramWebhookSecret: "wh-ver",
|
|
9
|
+
telegramApiBaseUrl: "https://api.telegram.org",
|
|
10
|
+
assistantRuntimeBaseUrl: "http://localhost:7821",
|
|
11
|
+
routingEntries: [],
|
|
12
|
+
defaultAssistantId: undefined,
|
|
13
|
+
unmappedPolicy: "reject",
|
|
14
|
+
port: 7830,
|
|
15
|
+
runtimeBearerToken: undefined,
|
|
16
|
+
runtimeProxyEnabled: false,
|
|
17
|
+
runtimeProxyRequireAuth: true,
|
|
18
|
+
runtimeProxyBearerToken: undefined,
|
|
19
|
+
shutdownDrainMs: 5000,
|
|
20
|
+
runtimeTimeoutMs: 30000,
|
|
21
|
+
runtimeMaxRetries: 2,
|
|
22
|
+
runtimeInitialBackoffMs: 500,
|
|
23
|
+
telegramInitialBackoffMs: 1000,
|
|
24
|
+
telegramMaxRetries: 3,
|
|
25
|
+
telegramTimeoutMs: 15000,
|
|
26
|
+
maxWebhookPayloadBytes: 1048576,
|
|
27
|
+
logFile: { dir: undefined, retentionDays: 30 },
|
|
28
|
+
maxAttachmentBytes: 20971520,
|
|
29
|
+
maxAttachmentConcurrency: 3,
|
|
30
|
+
...overrides,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const payload = {
|
|
34
|
+
sourceChannel: "telegram",
|
|
35
|
+
externalChatId: "99001",
|
|
36
|
+
externalMessageId: "123",
|
|
37
|
+
content: "Hello",
|
|
38
|
+
senderName: "Test User",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const testAttachment: RuntimeAttachmentMeta = {
|
|
42
|
+
id: "att-1",
|
|
43
|
+
filename: "chart.png",
|
|
44
|
+
mimeType: "image/png",
|
|
45
|
+
sizeBytes: 1024,
|
|
46
|
+
kind: "generated_image",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const successBody = {
|
|
50
|
+
accepted: true,
|
|
51
|
+
duplicate: false,
|
|
52
|
+
eventId: "evt-1",
|
|
53
|
+
assistantMessage: {
|
|
54
|
+
id: "msg-1",
|
|
55
|
+
role: "assistant" as const,
|
|
56
|
+
content: "Hi there!",
|
|
57
|
+
timestamp: new Date().toISOString(),
|
|
58
|
+
attachments: [testAttachment],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
function mockFetch(fn: () => Promise<Response>) {
|
|
63
|
+
const m = mock(fn);
|
|
64
|
+
Object.assign(m, { preconnect: () => {} });
|
|
65
|
+
globalThis.fetch = m as unknown as typeof fetch;
|
|
66
|
+
return m;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("forwardToRuntime", () => {
|
|
70
|
+
const originalFetch = globalThis.fetch;
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
globalThis.fetch = originalFetch;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("successful forward returns runtime response", async () => {
|
|
77
|
+
mockFetch(() =>
|
|
78
|
+
Promise.resolve(
|
|
79
|
+
new Response(JSON.stringify(successBody), { status: 200 }),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const config = makeConfig();
|
|
84
|
+
const result = await forwardToRuntime(config, "assistant-a", payload);
|
|
85
|
+
expect(result.accepted).toBe(true);
|
|
86
|
+
expect(result.eventId).toBe("evt-1");
|
|
87
|
+
expect(result.assistantMessage?.content).toBe("Hi there!");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("4xx error throws immediately without retry", async () => {
|
|
91
|
+
const fetchMock = mockFetch(() =>
|
|
92
|
+
Promise.resolve(new Response("Bad request", { status: 400 })),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const config = makeConfig();
|
|
96
|
+
await expect(
|
|
97
|
+
forwardToRuntime(config, "assistant-a", payload),
|
|
98
|
+
).rejects.toThrow("Runtime returned 400");
|
|
99
|
+
|
|
100
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("5xx error retries and eventually succeeds", async () => {
|
|
104
|
+
let callCount = 0;
|
|
105
|
+
mockFetch(() => {
|
|
106
|
+
callCount++;
|
|
107
|
+
if (callCount <= 2) {
|
|
108
|
+
return Promise.resolve(
|
|
109
|
+
new Response("Internal error", { status: 500 }),
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return Promise.resolve(
|
|
113
|
+
new Response(JSON.stringify(successBody), { status: 200 }),
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const config = makeConfig();
|
|
118
|
+
const result = await forwardToRuntime(config, "assistant-a", payload);
|
|
119
|
+
expect(result.accepted).toBe(true);
|
|
120
|
+
expect(callCount).toBe(3);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("5xx error exhausts retries and throws", async () => {
|
|
124
|
+
mockFetch(() =>
|
|
125
|
+
Promise.resolve(new Response("Server error", { status: 500 })),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const config = makeConfig();
|
|
129
|
+
await expect(
|
|
130
|
+
forwardToRuntime(config, "assistant-a", payload),
|
|
131
|
+
).rejects.toThrow("Runtime returned 500");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("response includes typed attachment metadata", async () => {
|
|
135
|
+
mockFetch(() =>
|
|
136
|
+
Promise.resolve(
|
|
137
|
+
new Response(JSON.stringify(successBody), { status: 200 }),
|
|
138
|
+
),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const config = makeConfig();
|
|
142
|
+
const result = await forwardToRuntime(config, "assistant-a", payload);
|
|
143
|
+
const attachments = result.assistantMessage?.attachments ?? [];
|
|
144
|
+
expect(attachments).toHaveLength(1);
|
|
145
|
+
expect(attachments[0].id).toBe("att-1");
|
|
146
|
+
expect(attachments[0].filename).toBe("chart.png");
|
|
147
|
+
expect(attachments[0].mimeType).toBe("image/png");
|
|
148
|
+
expect(attachments[0].sizeBytes).toBe(1024);
|
|
149
|
+
expect(attachments[0].kind).toBe("generated_image");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("sends Authorization header when runtimeBearerToken is configured", async () => {
|
|
153
|
+
const fetchMock = mockFetch(() =>
|
|
154
|
+
Promise.resolve(
|
|
155
|
+
new Response(JSON.stringify(successBody), { status: 200 }),
|
|
156
|
+
),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const config = makeConfig({ runtimeBearerToken: "my-secret-token" });
|
|
160
|
+
await forwardToRuntime(config, "assistant-a", payload);
|
|
161
|
+
|
|
162
|
+
const calledInit = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
|
|
163
|
+
const headers = calledInit.headers as Record<string, string>;
|
|
164
|
+
expect(headers["Authorization"]).toBe("Bearer my-secret-token");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("omits Authorization header when runtimeBearerToken is undefined", async () => {
|
|
168
|
+
const fetchMock = mockFetch(() =>
|
|
169
|
+
Promise.resolve(
|
|
170
|
+
new Response(JSON.stringify(successBody), { status: 200 }),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const config = makeConfig();
|
|
175
|
+
await forwardToRuntime(config, "assistant-a", payload);
|
|
176
|
+
|
|
177
|
+
const calledInit = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit;
|
|
178
|
+
const headers = calledInit.headers as Record<string, string>;
|
|
179
|
+
expect(headers["Authorization"]).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("downloadAttachment", () => {
|
|
184
|
+
const originalFetch = globalThis.fetch;
|
|
185
|
+
|
|
186
|
+
afterEach(() => {
|
|
187
|
+
globalThis.fetch = originalFetch;
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("downloads attachment payload with base64 data", async () => {
|
|
191
|
+
const attachmentPayload = {
|
|
192
|
+
id: "att-1",
|
|
193
|
+
filename: "chart.png",
|
|
194
|
+
mimeType: "image/png",
|
|
195
|
+
sizeBytes: 1024,
|
|
196
|
+
kind: "generated_image",
|
|
197
|
+
data: "iVBORw0KGgo=",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const fetchMock = mockFetch(() =>
|
|
201
|
+
Promise.resolve(
|
|
202
|
+
new Response(JSON.stringify(attachmentPayload), { status: 200 }),
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const config = makeConfig();
|
|
207
|
+
const result = await downloadAttachment(config, "assistant-a", "att-1");
|
|
208
|
+
expect(result.id).toBe("att-1");
|
|
209
|
+
expect(result.filename).toBe("chart.png");
|
|
210
|
+
expect(result.data).toBe("iVBORw0KGgo=");
|
|
211
|
+
|
|
212
|
+
const calledUrl = (fetchMock.mock.calls[0] as unknown[])[0] as string;
|
|
213
|
+
expect(calledUrl).toContain("/attachments/att-1");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("throws on 404 not found", async () => {
|
|
217
|
+
mockFetch(() =>
|
|
218
|
+
Promise.resolve(
|
|
219
|
+
new Response('{"error":"Attachment not found"}', { status: 404 }),
|
|
220
|
+
),
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
const config = makeConfig();
|
|
224
|
+
await expect(
|
|
225
|
+
downloadAttachment(config, "assistant-a", "nonexistent"),
|
|
226
|
+
).rejects.toThrow("Attachment download failed (404)");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
|
+
import { createRuntimeProxyHandler } from "../http/routes/runtime-proxy.js";
|
|
3
|
+
import type { GatewayConfig } from "../config.js";
|
|
4
|
+
|
|
5
|
+
const TOKEN = "test-secret-token";
|
|
6
|
+
|
|
7
|
+
function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
8
|
+
return {
|
|
9
|
+
telegramBotToken: "tok",
|
|
10
|
+
telegramWebhookSecret: "wh-ver",
|
|
11
|
+
telegramApiBaseUrl: "https://api.telegram.org",
|
|
12
|
+
assistantRuntimeBaseUrl: "http://localhost:7821",
|
|
13
|
+
routingEntries: [],
|
|
14
|
+
defaultAssistantId: undefined,
|
|
15
|
+
unmappedPolicy: "reject",
|
|
16
|
+
port: 7830,
|
|
17
|
+
runtimeBearerToken: undefined,
|
|
18
|
+
runtimeProxyEnabled: true,
|
|
19
|
+
runtimeProxyRequireAuth: true,
|
|
20
|
+
runtimeProxyBearerToken: TOKEN,
|
|
21
|
+
shutdownDrainMs: 5000,
|
|
22
|
+
runtimeTimeoutMs: 30000,
|
|
23
|
+
runtimeMaxRetries: 2,
|
|
24
|
+
runtimeInitialBackoffMs: 500,
|
|
25
|
+
telegramInitialBackoffMs: 1000,
|
|
26
|
+
telegramMaxRetries: 3,
|
|
27
|
+
telegramTimeoutMs: 15000,
|
|
28
|
+
maxWebhookPayloadBytes: 1048576,
|
|
29
|
+
logFile: { dir: undefined, retentionDays: 30 },
|
|
30
|
+
maxAttachmentBytes: 20971520,
|
|
31
|
+
maxAttachmentConcurrency: 3,
|
|
32
|
+
...overrides,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const originalFetch = globalThis.fetch;
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
globalThis.fetch = originalFetch;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function mockUpstream() {
|
|
43
|
+
globalThis.fetch = mock(async () => {
|
|
44
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
45
|
+
status: 200,
|
|
46
|
+
headers: { "content-type": "application/json" },
|
|
47
|
+
});
|
|
48
|
+
}) as any;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("runtime proxy auth enforcement", () => {
|
|
52
|
+
test("auth required: rejects missing token with 401", async () => {
|
|
53
|
+
mockUpstream();
|
|
54
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
55
|
+
const req = new Request("http://localhost:7830/v1/health");
|
|
56
|
+
const res = await handler(req);
|
|
57
|
+
|
|
58
|
+
expect(res.status).toBe(401);
|
|
59
|
+
const body = await res.json();
|
|
60
|
+
expect(body.error).toBe("Unauthorized");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("auth required: rejects invalid token with 401", async () => {
|
|
64
|
+
mockUpstream();
|
|
65
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
66
|
+
const req = new Request("http://localhost:7830/v1/health", {
|
|
67
|
+
headers: { authorization: "Bearer wrong-token" },
|
|
68
|
+
});
|
|
69
|
+
const res = await handler(req);
|
|
70
|
+
|
|
71
|
+
expect(res.status).toBe(401);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("auth required: accepts valid token and proxies", async () => {
|
|
75
|
+
mockUpstream();
|
|
76
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
77
|
+
const req = new Request("http://localhost:7830/v1/health", {
|
|
78
|
+
headers: { authorization: `Bearer ${TOKEN}` },
|
|
79
|
+
});
|
|
80
|
+
const res = await handler(req);
|
|
81
|
+
|
|
82
|
+
expect(res.status).toBe(200);
|
|
83
|
+
const body = await res.json();
|
|
84
|
+
expect(body.ok).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("auth required: replaces client authorization with configured bearer token for upstream", async () => {
|
|
88
|
+
let capturedHeaders: Headers | undefined;
|
|
89
|
+
globalThis.fetch = mock(async (_input: any, init?: any) => {
|
|
90
|
+
capturedHeaders = init?.headers;
|
|
91
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
92
|
+
status: 200,
|
|
93
|
+
headers: { "content-type": "application/json" },
|
|
94
|
+
});
|
|
95
|
+
}) as any;
|
|
96
|
+
|
|
97
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
98
|
+
const req = new Request("http://localhost:7830/v1/health", {
|
|
99
|
+
headers: { authorization: `Bearer ${TOKEN}` },
|
|
100
|
+
});
|
|
101
|
+
await handler(req);
|
|
102
|
+
|
|
103
|
+
expect(capturedHeaders!.get("authorization")).toBe(`Bearer ${TOKEN}`);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("auth not required: proxies without token", async () => {
|
|
107
|
+
mockUpstream();
|
|
108
|
+
const handler = createRuntimeProxyHandler(
|
|
109
|
+
makeConfig({ runtimeProxyRequireAuth: false, runtimeProxyBearerToken: undefined }),
|
|
110
|
+
);
|
|
111
|
+
const req = new Request("http://localhost:7830/v1/health");
|
|
112
|
+
const res = await handler(req);
|
|
113
|
+
|
|
114
|
+
expect(res.status).toBe(200);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("OPTIONS request bypasses auth", async () => {
|
|
118
|
+
mockUpstream();
|
|
119
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
120
|
+
const req = new Request("http://localhost:7830/v1/health", {
|
|
121
|
+
method: "OPTIONS",
|
|
122
|
+
});
|
|
123
|
+
const res = await handler(req);
|
|
124
|
+
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { describe, test, expect, mock, afterEach } from "bun:test";
|
|
2
|
+
import { createRuntimeProxyHandler } from "../http/routes/runtime-proxy.js";
|
|
3
|
+
import type { GatewayConfig } from "../config.js";
|
|
4
|
+
|
|
5
|
+
function makeConfig(overrides: Partial<GatewayConfig> = {}): GatewayConfig {
|
|
6
|
+
return {
|
|
7
|
+
telegramBotToken: "tok",
|
|
8
|
+
telegramWebhookSecret: "wh-ver",
|
|
9
|
+
telegramApiBaseUrl: "https://api.telegram.org",
|
|
10
|
+
assistantRuntimeBaseUrl: "http://localhost:7821",
|
|
11
|
+
routingEntries: [],
|
|
12
|
+
defaultAssistantId: undefined,
|
|
13
|
+
unmappedPolicy: "reject",
|
|
14
|
+
port: 7830,
|
|
15
|
+
runtimeBearerToken: undefined,
|
|
16
|
+
runtimeProxyEnabled: true,
|
|
17
|
+
runtimeProxyRequireAuth: false,
|
|
18
|
+
runtimeProxyBearerToken: undefined,
|
|
19
|
+
shutdownDrainMs: 5000,
|
|
20
|
+
runtimeTimeoutMs: 30000,
|
|
21
|
+
runtimeMaxRetries: 2,
|
|
22
|
+
runtimeInitialBackoffMs: 500,
|
|
23
|
+
telegramInitialBackoffMs: 1000,
|
|
24
|
+
telegramMaxRetries: 3,
|
|
25
|
+
telegramTimeoutMs: 15000,
|
|
26
|
+
maxWebhookPayloadBytes: 1048576,
|
|
27
|
+
logFile: { dir: undefined, retentionDays: 30 },
|
|
28
|
+
maxAttachmentBytes: 20971520,
|
|
29
|
+
maxAttachmentConcurrency: 3,
|
|
30
|
+
...overrides,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const originalFetch = globalThis.fetch;
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
globalThis.fetch = originalFetch;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("runtime proxy handler", () => {
|
|
41
|
+
test("forwards request to upstream with correct path and query", async () => {
|
|
42
|
+
const captured: { url: string; method: string }[] = [];
|
|
43
|
+
globalThis.fetch = mock(async (input: any, init?: any) => {
|
|
44
|
+
captured.push({ url: String(input), method: init?.method ?? "GET" });
|
|
45
|
+
return new Response(JSON.stringify({ ok: true }), {
|
|
46
|
+
status: 200,
|
|
47
|
+
headers: { "content-type": "application/json" },
|
|
48
|
+
});
|
|
49
|
+
}) as any;
|
|
50
|
+
|
|
51
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
52
|
+
const req = new Request("http://localhost:7830/v1/assistants/test/health?foo=bar");
|
|
53
|
+
const res = await handler(req);
|
|
54
|
+
|
|
55
|
+
expect(res.status).toBe(200);
|
|
56
|
+
expect(captured[0].url).toBe("http://localhost:7821/v1/assistants/test/health?foo=bar");
|
|
57
|
+
expect(captured[0].method).toBe("GET");
|
|
58
|
+
const body = await res.json();
|
|
59
|
+
expect(body).toEqual({ ok: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("forwards POST body to upstream", async () => {
|
|
63
|
+
let capturedBody = "";
|
|
64
|
+
globalThis.fetch = mock(async (_input: any, init?: any) => {
|
|
65
|
+
if (init?.body) {
|
|
66
|
+
const reader = init.body.getReader();
|
|
67
|
+
const chunks: Uint8Array[] = [];
|
|
68
|
+
while (true) {
|
|
69
|
+
const { done, value } = await reader.read();
|
|
70
|
+
if (done) break;
|
|
71
|
+
chunks.push(value);
|
|
72
|
+
}
|
|
73
|
+
capturedBody = new TextDecoder().decode(
|
|
74
|
+
new Uint8Array(chunks.reduce((acc, c) => acc + c.length, 0)),
|
|
75
|
+
);
|
|
76
|
+
// Simpler: just read as text
|
|
77
|
+
capturedBody = Buffer.concat(chunks).toString();
|
|
78
|
+
}
|
|
79
|
+
return new Response("ok", { status: 200 });
|
|
80
|
+
}) as any;
|
|
81
|
+
|
|
82
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
83
|
+
const req = new Request("http://localhost:7830/v1/chat", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
body: JSON.stringify({ message: "hello" }),
|
|
86
|
+
headers: { "content-type": "application/json" },
|
|
87
|
+
});
|
|
88
|
+
const res = await handler(req);
|
|
89
|
+
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
expect(capturedBody).toBe('{"message":"hello"}');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("relays upstream status code", async () => {
|
|
95
|
+
globalThis.fetch = mock(async () => {
|
|
96
|
+
return new Response("Not Found", { status: 404 });
|
|
97
|
+
}) as any;
|
|
98
|
+
|
|
99
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
100
|
+
const req = new Request("http://localhost:7830/v1/nonexistent");
|
|
101
|
+
const res = await handler(req);
|
|
102
|
+
|
|
103
|
+
expect(res.status).toBe(404);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("returns 502 on upstream connection failure", async () => {
|
|
107
|
+
globalThis.fetch = mock(async () => {
|
|
108
|
+
throw new Error("Connection refused");
|
|
109
|
+
}) as any;
|
|
110
|
+
|
|
111
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
112
|
+
const req = new Request("http://localhost:7830/v1/health");
|
|
113
|
+
const res = await handler(req);
|
|
114
|
+
|
|
115
|
+
expect(res.status).toBe(502);
|
|
116
|
+
const body = await res.json();
|
|
117
|
+
expect(body.error).toBe("Bad Gateway");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("strips hop-by-hop headers from request", async () => {
|
|
121
|
+
let capturedHeaders: Headers | undefined;
|
|
122
|
+
globalThis.fetch = mock(async (_input: any, init?: any) => {
|
|
123
|
+
capturedHeaders = init?.headers;
|
|
124
|
+
return new Response("ok", { status: 200 });
|
|
125
|
+
}) as any;
|
|
126
|
+
|
|
127
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
128
|
+
const req = new Request("http://localhost:7830/v1/health", {
|
|
129
|
+
headers: {
|
|
130
|
+
connection: "keep-alive",
|
|
131
|
+
"keep-alive": "timeout=5",
|
|
132
|
+
"transfer-encoding": "chunked",
|
|
133
|
+
"x-custom": "preserved",
|
|
134
|
+
},
|
|
135
|
+
});
|
|
136
|
+
await handler(req);
|
|
137
|
+
|
|
138
|
+
expect(capturedHeaders!.has("connection")).toBe(false);
|
|
139
|
+
expect(capturedHeaders!.has("keep-alive")).toBe(false);
|
|
140
|
+
expect(capturedHeaders!.has("transfer-encoding")).toBe(false);
|
|
141
|
+
expect(capturedHeaders!.get("x-custom")).toBe("preserved");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("strips hop-by-hop headers from response", async () => {
|
|
145
|
+
globalThis.fetch = mock(async () => {
|
|
146
|
+
return new Response("ok", {
|
|
147
|
+
status: 200,
|
|
148
|
+
headers: {
|
|
149
|
+
connection: "keep-alive",
|
|
150
|
+
"transfer-encoding": "chunked",
|
|
151
|
+
"x-custom": "preserved",
|
|
152
|
+
"content-type": "text/plain",
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
}) as any;
|
|
156
|
+
|
|
157
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
158
|
+
const req = new Request("http://localhost:7830/v1/health");
|
|
159
|
+
const res = await handler(req);
|
|
160
|
+
|
|
161
|
+
expect(res.headers.has("connection")).toBe(false);
|
|
162
|
+
expect(res.headers.has("transfer-encoding")).toBe(false);
|
|
163
|
+
expect(res.headers.get("x-custom")).toBe("preserved");
|
|
164
|
+
expect(res.headers.get("content-type")).toBe("text/plain");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("returns 504 on upstream timeout", async () => {
|
|
168
|
+
const timeoutError = new DOMException("The operation was aborted due to timeout", "TimeoutError");
|
|
169
|
+
globalThis.fetch = mock(async () => {
|
|
170
|
+
throw timeoutError;
|
|
171
|
+
}) as any;
|
|
172
|
+
|
|
173
|
+
const handler = createRuntimeProxyHandler(makeConfig({ runtimeTimeoutMs: 100 }));
|
|
174
|
+
const req = new Request("http://localhost:7830/v1/health");
|
|
175
|
+
const res = await handler(req);
|
|
176
|
+
|
|
177
|
+
expect(res.status).toBe(504);
|
|
178
|
+
const body = await res.json();
|
|
179
|
+
expect(body.error).toBe("Gateway Timeout");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("passes AbortSignal.timeout to upstream fetch", async () => {
|
|
183
|
+
let capturedSignal: AbortSignal | undefined;
|
|
184
|
+
globalThis.fetch = mock(async (_input: any, init?: any) => {
|
|
185
|
+
capturedSignal = init?.signal;
|
|
186
|
+
return new Response("ok", { status: 200 });
|
|
187
|
+
}) as any;
|
|
188
|
+
|
|
189
|
+
const handler = createRuntimeProxyHandler(makeConfig({ runtimeTimeoutMs: 5000 }));
|
|
190
|
+
const req = new Request("http://localhost:7830/v1/health");
|
|
191
|
+
await handler(req);
|
|
192
|
+
|
|
193
|
+
expect(capturedSignal).toBeDefined();
|
|
194
|
+
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("forwards authorization header when auth is not required", async () => {
|
|
198
|
+
let capturedHeaders: Headers | undefined;
|
|
199
|
+
globalThis.fetch = mock(async (_input: any, init?: any) => {
|
|
200
|
+
capturedHeaders = init?.headers;
|
|
201
|
+
return new Response("ok", { status: 200 });
|
|
202
|
+
}) as any;
|
|
203
|
+
|
|
204
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
205
|
+
const req = new Request("http://localhost:7830/v1/health", {
|
|
206
|
+
headers: { authorization: "Bearer upstream-token" },
|
|
207
|
+
});
|
|
208
|
+
await handler(req);
|
|
209
|
+
|
|
210
|
+
expect(capturedHeaders!.get("authorization")).toBe("Bearer upstream-token");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("replaces client authorization with configured bearer token for upstream", async () => {
|
|
214
|
+
let capturedHeaders: Headers | undefined;
|
|
215
|
+
globalThis.fetch = mock(async (_input: any, init?: any) => {
|
|
216
|
+
capturedHeaders = init?.headers;
|
|
217
|
+
return new Response("ok", { status: 200 });
|
|
218
|
+
}) as any;
|
|
219
|
+
|
|
220
|
+
const handler = createRuntimeProxyHandler(
|
|
221
|
+
makeConfig({ runtimeProxyBearerToken: "daemon-token" }),
|
|
222
|
+
);
|
|
223
|
+
const req = new Request("http://localhost:7830/v1/health", {
|
|
224
|
+
headers: { authorization: "Bearer client-token" },
|
|
225
|
+
});
|
|
226
|
+
await handler(req);
|
|
227
|
+
|
|
228
|
+
expect(capturedHeaders!.get("authorization")).toBe("Bearer daemon-token");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("truncates long upstream error bodies in logs", async () => {
|
|
232
|
+
const longBody = "x".repeat(512);
|
|
233
|
+
globalThis.fetch = mock(async () => {
|
|
234
|
+
return new Response(longBody, { status: 500 });
|
|
235
|
+
}) as any;
|
|
236
|
+
|
|
237
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
238
|
+
const req = new Request("http://localhost:7830/v1/fail");
|
|
239
|
+
const res = await handler(req);
|
|
240
|
+
|
|
241
|
+
expect(res.status).toBe(500);
|
|
242
|
+
// The full body is still returned to the client
|
|
243
|
+
const responseBody = await res.text();
|
|
244
|
+
expect(responseBody).toBe(longBody);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("does not forward host header to upstream", async () => {
|
|
248
|
+
let capturedHeaders: Headers | undefined;
|
|
249
|
+
globalThis.fetch = mock(async (_input: any, init?: any) => {
|
|
250
|
+
capturedHeaders = init?.headers;
|
|
251
|
+
return new Response("ok", { status: 200 });
|
|
252
|
+
}) as any;
|
|
253
|
+
|
|
254
|
+
const handler = createRuntimeProxyHandler(makeConfig());
|
|
255
|
+
const req = new Request("http://localhost:7830/v1/health", {
|
|
256
|
+
headers: { host: "localhost:7830" },
|
|
257
|
+
});
|
|
258
|
+
await handler(req);
|
|
259
|
+
|
|
260
|
+
expect(capturedHeaders!.has("host")).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
});
|