@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.
Files changed (42) hide show
  1. package/.dockerignore +7 -0
  2. package/.env.example +59 -0
  3. package/Dockerfile +44 -0
  4. package/README.md +186 -0
  5. package/bun.lock +391 -0
  6. package/eslint.config.mjs +23 -0
  7. package/knip.json +8 -0
  8. package/package.json +27 -0
  9. package/src/__tests__/bearer-auth.test.ts +40 -0
  10. package/src/__tests__/config.test.ts +236 -0
  11. package/src/__tests__/dedup-cache.test.ts +101 -0
  12. package/src/__tests__/load-guards.test.ts +86 -0
  13. package/src/__tests__/probes.test.ts +94 -0
  14. package/src/__tests__/reply-path.test.ts +51 -0
  15. package/src/__tests__/resolve-assistant.test.ts +118 -0
  16. package/src/__tests__/runtime-client.test.ts +228 -0
  17. package/src/__tests__/runtime-proxy-auth.test.ts +127 -0
  18. package/src/__tests__/runtime-proxy.test.ts +262 -0
  19. package/src/__tests__/schema.test.ts +128 -0
  20. package/src/__tests__/telegram-normalize.test.ts +303 -0
  21. package/src/__tests__/telegram-only-default.test.ts +134 -0
  22. package/src/__tests__/telegram-send-attachments.test.ts +185 -0
  23. package/src/cli/schema.ts +8 -0
  24. package/src/config.ts +254 -0
  25. package/src/dedup-cache.ts +104 -0
  26. package/src/handlers/handle-inbound.ts +104 -0
  27. package/src/http/auth/bearer.ts +34 -0
  28. package/src/http/routes/runtime-proxy.ts +143 -0
  29. package/src/http/routes/telegram-webhook.ts +272 -0
  30. package/src/index.ts +117 -0
  31. package/src/logger.ts +103 -0
  32. package/src/routing/resolve-assistant.ts +45 -0
  33. package/src/routing/types.ts +11 -0
  34. package/src/runtime/client.ts +212 -0
  35. package/src/schema.ts +383 -0
  36. package/src/telegram/api.ts +153 -0
  37. package/src/telegram/download.ts +63 -0
  38. package/src/telegram/normalize.ts +118 -0
  39. package/src/telegram/send.ts +107 -0
  40. package/src/telegram/verify.ts +17 -0
  41. package/src/types.ts +37 -0
  42. package/tsconfig.json +20 -0
@@ -0,0 +1,212 @@
1
+ import type { GatewayConfig } from "../config.js";
2
+ import { getLogger } from "../logger.js";
3
+
4
+ const log = getLogger("runtime-client");
5
+
6
+ /** Build common headers for runtime requests, including auth when configured. */
7
+ function runtimeHeaders(config: GatewayConfig, extra?: Record<string, string>): Record<string, string> {
8
+ const headers: Record<string, string> = { ...extra };
9
+ if (config.runtimeBearerToken) {
10
+ headers["Authorization"] = `Bearer ${config.runtimeBearerToken}`;
11
+ }
12
+ return headers;
13
+ }
14
+
15
+ /**
16
+ * Thrown when the assistant rejects an attachment for a non-retriable reason
17
+ * (e.g. unsupported MIME type, dangerous file extension). Callers can use
18
+ * this to distinguish validation failures from transient errors.
19
+ */
20
+ export class AttachmentValidationError extends Error {
21
+ constructor(message: string) {
22
+ super(message);
23
+ this.name = "AttachmentValidationError";
24
+ }
25
+ }
26
+
27
+ export type RuntimeInboundPayload = {
28
+ sourceChannel: string;
29
+ externalChatId: string;
30
+ externalMessageId: string;
31
+ content: string;
32
+ isEdit?: boolean;
33
+ senderName?: string;
34
+ senderExternalUserId?: string;
35
+ senderUsername?: string;
36
+ sourceMetadata?: Record<string, unknown>;
37
+ attachmentIds?: string[];
38
+ };
39
+
40
+ export type RuntimeAttachmentMeta = {
41
+ id: string;
42
+ filename: string;
43
+ mimeType: string;
44
+ sizeBytes: number;
45
+ kind: string;
46
+ };
47
+
48
+ export type RuntimeAttachmentPayload = RuntimeAttachmentMeta & {
49
+ data: string; // base64-encoded
50
+ };
51
+
52
+ export type RuntimeInboundResponse = {
53
+ accepted: boolean;
54
+ duplicate: boolean;
55
+ eventId: string;
56
+ assistantMessage?: {
57
+ id: string;
58
+ role: "assistant";
59
+ content: string;
60
+ timestamp: string;
61
+ attachments: RuntimeAttachmentMeta[];
62
+ };
63
+ };
64
+
65
+ export async function forwardToRuntime(
66
+ config: GatewayConfig,
67
+ assistantId: string,
68
+ payload: RuntimeInboundPayload,
69
+ ): Promise<RuntimeInboundResponse> {
70
+ const url = `${config.assistantRuntimeBaseUrl}/v1/assistants/${encodeURIComponent(assistantId)}/channels/inbound`;
71
+
72
+ let lastError: Error | null = null;
73
+
74
+ for (let attempt = 0; attempt <= config.runtimeMaxRetries; attempt++) {
75
+ if (attempt > 0) {
76
+ const delay = config.runtimeInitialBackoffMs * Math.pow(2, attempt - 1);
77
+ log.debug({ attempt, delay, assistantId }, "Retrying runtime forward");
78
+ await new Promise((r) => setTimeout(r, delay));
79
+ }
80
+
81
+ try {
82
+ const response = await fetch(url, {
83
+ method: "POST",
84
+ headers: runtimeHeaders(config, { "Content-Type": "application/json" }),
85
+ body: JSON.stringify(payload),
86
+ signal: AbortSignal.timeout(config.runtimeTimeoutMs),
87
+ });
88
+
89
+ if (response.status >= 400 && response.status < 500) {
90
+ const body = await response.text();
91
+ log.warn(
92
+ { status: response.status, body, assistantId },
93
+ "Runtime returned client error, not retrying",
94
+ );
95
+ throw new Error(`Runtime returned ${response.status}: ${body}`);
96
+ }
97
+
98
+ if (response.status >= 500) {
99
+ const body = await response.text();
100
+ lastError = new Error(`Runtime returned ${response.status}: ${body}`);
101
+ log.warn(
102
+ { status: response.status, attempt, assistantId },
103
+ "Runtime returned server error",
104
+ );
105
+ continue;
106
+ }
107
+
108
+ const result = (await response.json()) as RuntimeInboundResponse;
109
+ log.debug(
110
+ { assistantId, eventId: result.eventId, duplicate: result.duplicate },
111
+ "Runtime forward succeeded",
112
+ );
113
+ return result;
114
+ } catch (err) {
115
+ if (
116
+ err instanceof Error &&
117
+ err.message.startsWith("Runtime returned 4")
118
+ ) {
119
+ throw err;
120
+ }
121
+ lastError = err instanceof Error ? err : new Error(String(err));
122
+ log.warn(
123
+ { err: lastError, attempt, assistantId },
124
+ "Runtime forward attempt failed",
125
+ );
126
+ }
127
+ }
128
+
129
+ throw lastError ?? new Error("Runtime forward failed after retries");
130
+ }
131
+
132
+ export async function resetConversation(
133
+ config: GatewayConfig,
134
+ assistantId: string,
135
+ sourceChannel: string,
136
+ externalChatId: string,
137
+ ): Promise<void> {
138
+ const url = `${config.assistantRuntimeBaseUrl}/v1/assistants/${encodeURIComponent(assistantId)}/channels/conversation`;
139
+
140
+ const response = await fetch(url, {
141
+ method: "DELETE",
142
+ headers: runtimeHeaders(config, { "Content-Type": "application/json" }),
143
+ body: JSON.stringify({ sourceChannel, externalChatId }),
144
+ signal: AbortSignal.timeout(config.runtimeTimeoutMs),
145
+ });
146
+
147
+ if (!response.ok) {
148
+ const body = await response.text();
149
+ throw new Error(`Reset conversation failed (${response.status}): ${body}`);
150
+ }
151
+ }
152
+
153
+ export type UploadAttachmentInput = {
154
+ filename: string;
155
+ mimeType: string;
156
+ data: string; // base64-encoded
157
+ };
158
+
159
+ export type UploadAttachmentResponse = {
160
+ id: string;
161
+ };
162
+
163
+ export async function downloadAttachment(
164
+ config: GatewayConfig,
165
+ assistantId: string,
166
+ attachmentId: string,
167
+ ): Promise<RuntimeAttachmentPayload> {
168
+ const url = `${config.assistantRuntimeBaseUrl}/v1/assistants/${encodeURIComponent(assistantId)}/attachments/${encodeURIComponent(attachmentId)}`;
169
+
170
+ const response = await fetch(url, {
171
+ method: "GET",
172
+ headers: runtimeHeaders(config),
173
+ signal: AbortSignal.timeout(config.runtimeTimeoutMs),
174
+ });
175
+
176
+ if (!response.ok) {
177
+ const body = await response.text();
178
+ throw new Error(`Attachment download failed (${response.status}): ${body}`);
179
+ }
180
+
181
+ return (await response.json()) as RuntimeAttachmentPayload;
182
+ }
183
+
184
+ export async function uploadAttachment(
185
+ config: GatewayConfig,
186
+ assistantId: string,
187
+ input: UploadAttachmentInput,
188
+ ): Promise<UploadAttachmentResponse> {
189
+ const url = `${config.assistantRuntimeBaseUrl}/v1/assistants/${encodeURIComponent(assistantId)}/attachments`;
190
+
191
+ const response = await fetch(url, {
192
+ method: "POST",
193
+ headers: runtimeHeaders(config, { "Content-Type": "application/json" }),
194
+ body: JSON.stringify(input),
195
+ signal: AbortSignal.timeout(config.runtimeTimeoutMs),
196
+ });
197
+
198
+ if (!response.ok) {
199
+ const body = await response.text();
200
+ // 4xx = non-retriable validation rejection (unsupported MIME, dangerous
201
+ // extension, missing fields). Distinguish from transient 5xx/network errors
202
+ // so callers can decide whether to skip or propagate.
203
+ if (response.status >= 400 && response.status < 500) {
204
+ throw new AttachmentValidationError(
205
+ `Attachment rejected (${response.status}): ${body}`,
206
+ );
207
+ }
208
+ throw new Error(`Attachment upload failed (${response.status}): ${body}`);
209
+ }
210
+
211
+ return (await response.json()) as UploadAttachmentResponse;
212
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,383 @@
1
+ import packageJson from "../package.json" with { type: "json" };
2
+
3
+ export function buildSchema(): Record<string, unknown> {
4
+ return {
5
+ openapi: "3.1.0",
6
+ info: {
7
+ title: "Vellum Gateway",
8
+ version: packageJson.version,
9
+ description:
10
+ "HTTP gateway that bridges external channels (Telegram, etc.) to the Vellum assistant runtime and provides an authenticated reverse proxy.",
11
+ },
12
+ paths: {
13
+ "/healthz": {
14
+ get: {
15
+ summary: "Liveness probe",
16
+ operationId: "healthz",
17
+ responses: {
18
+ "200": {
19
+ description: "Gateway is alive",
20
+ content: {
21
+ "application/json": {
22
+ schema: { $ref: "#/components/schemas/HealthResponse" },
23
+ },
24
+ },
25
+ },
26
+ },
27
+ },
28
+ },
29
+ "/readyz": {
30
+ get: {
31
+ summary: "Readiness probe",
32
+ description:
33
+ "Returns 200 when the gateway is ready to accept traffic. Returns 503 during graceful shutdown drain.",
34
+ operationId: "readyz",
35
+ responses: {
36
+ "200": {
37
+ description: "Gateway is ready",
38
+ content: {
39
+ "application/json": {
40
+ schema: { $ref: "#/components/schemas/ReadyResponse" },
41
+ },
42
+ },
43
+ },
44
+ "503": {
45
+ description:
46
+ "Gateway is draining (graceful shutdown in progress)",
47
+ content: {
48
+ "application/json": {
49
+ schema: { $ref: "#/components/schemas/DrainingResponse" },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ "/schema": {
57
+ get: {
58
+ summary: "OpenAPI schema",
59
+ description: "Returns the full OpenAPI schema for this gateway.",
60
+ operationId: "getSchema",
61
+ responses: {
62
+ "200": {
63
+ description: "OpenAPI 3.1 schema document",
64
+ content: {
65
+ "application/json": {
66
+ schema: { type: "object", additionalProperties: true },
67
+ },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ "/webhooks/telegram": {
74
+ post: {
75
+ summary: "Telegram webhook",
76
+ description:
77
+ "Receives inbound Telegram updates, normalizes them, resolves routing, and forwards to the assistant runtime.",
78
+ operationId: "telegramWebhook",
79
+ security: [{ TelegramWebhookSecret: [] }],
80
+ requestBody: {
81
+ required: true,
82
+ content: {
83
+ "application/json": {
84
+ schema: { $ref: "#/components/schemas/TelegramUpdate" },
85
+ },
86
+ },
87
+ },
88
+ responses: {
89
+ "200": {
90
+ description: "Update accepted",
91
+ content: {
92
+ "application/json": {
93
+ schema: { $ref: "#/components/schemas/TelegramOk" },
94
+ },
95
+ },
96
+ },
97
+ "400": {
98
+ description: "Invalid JSON or unreadable body",
99
+ content: {
100
+ "application/json": {
101
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
102
+ },
103
+ },
104
+ },
105
+ "401": {
106
+ description: "Webhook secret verification failed",
107
+ content: {
108
+ "application/json": {
109
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
110
+ },
111
+ },
112
+ },
113
+ "405": {
114
+ description: "Method not allowed (only POST accepted)",
115
+ content: {
116
+ "application/json": {
117
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
118
+ },
119
+ },
120
+ },
121
+ "413": {
122
+ description: "Webhook payload too large",
123
+ content: {
124
+ "application/json": {
125
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
126
+ },
127
+ },
128
+ },
129
+ "500": {
130
+ description: "Internal error processing inbound event",
131
+ content: {
132
+ "application/json": {
133
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
134
+ },
135
+ },
136
+ },
137
+ "503": {
138
+ description: "Telegram integration not configured",
139
+ content: {
140
+ "application/json": {
141
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
142
+ },
143
+ },
144
+ },
145
+ },
146
+ },
147
+ },
148
+ "/{path}": {
149
+ get: {
150
+ summary: "Runtime proxy",
151
+ description:
152
+ "Reverse-proxies requests to the assistant runtime when GATEWAY_RUNTIME_PROXY_ENABLED is true. Supports all HTTP methods. Returns 404 when the proxy is disabled.",
153
+ operationId: "runtimeProxyGet",
154
+ security: [{ BearerAuth: [] }],
155
+ parameters: [
156
+ {
157
+ name: "path",
158
+ in: "path",
159
+ required: true,
160
+ schema: { type: "string" },
161
+ description:
162
+ "Upstream path forwarded to the assistant runtime",
163
+ },
164
+ ],
165
+ responses: {
166
+ "200": {
167
+ description: "Proxied response from the assistant runtime",
168
+ },
169
+ "401": {
170
+ description: "Missing or invalid bearer token",
171
+ content: {
172
+ "application/json": {
173
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
174
+ },
175
+ },
176
+ },
177
+ "404": {
178
+ description: "Runtime proxy not enabled",
179
+ content: {
180
+ "application/json": {
181
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
182
+ },
183
+ },
184
+ },
185
+ "500": {
186
+ description:
187
+ "Server misconfigured (proxy auth enabled without token)",
188
+ content: {
189
+ "application/json": {
190
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
191
+ },
192
+ },
193
+ },
194
+ "502": {
195
+ description: "Upstream connection failed",
196
+ content: {
197
+ "application/json": {
198
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
199
+ },
200
+ },
201
+ },
202
+ "504": {
203
+ description: "Upstream request timed out",
204
+ content: {
205
+ "application/json": {
206
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
207
+ },
208
+ },
209
+ },
210
+ },
211
+ },
212
+ post: {
213
+ summary: "Runtime proxy",
214
+ description:
215
+ "Reverse-proxies requests to the assistant runtime when GATEWAY_RUNTIME_PROXY_ENABLED is true.",
216
+ operationId: "runtimeProxyPost",
217
+ security: [{ BearerAuth: [] }],
218
+ parameters: [
219
+ {
220
+ name: "path",
221
+ in: "path",
222
+ required: true,
223
+ schema: { type: "string" },
224
+ description:
225
+ "Upstream path forwarded to the assistant runtime",
226
+ },
227
+ ],
228
+ requestBody: {
229
+ required: false,
230
+ content: {
231
+ "application/json": {
232
+ schema: { type: "object", additionalProperties: true },
233
+ },
234
+ },
235
+ },
236
+ responses: {
237
+ "200": {
238
+ description: "Proxied response from the assistant runtime",
239
+ },
240
+ "401": {
241
+ description: "Missing or invalid bearer token",
242
+ content: {
243
+ "application/json": {
244
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
245
+ },
246
+ },
247
+ },
248
+ "502": {
249
+ description: "Upstream connection failed",
250
+ content: {
251
+ "application/json": {
252
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
253
+ },
254
+ },
255
+ },
256
+ "504": {
257
+ description: "Upstream request timed out",
258
+ content: {
259
+ "application/json": {
260
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
261
+ },
262
+ },
263
+ },
264
+ },
265
+ },
266
+ },
267
+ },
268
+ components: {
269
+ schemas: {
270
+ HealthResponse: {
271
+ type: "object",
272
+ required: ["status"],
273
+ properties: {
274
+ status: { type: "string", enum: ["ok"] },
275
+ },
276
+ },
277
+ ReadyResponse: {
278
+ type: "object",
279
+ required: ["status"],
280
+ properties: {
281
+ status: { type: "string", enum: ["ok"] },
282
+ },
283
+ },
284
+ DrainingResponse: {
285
+ type: "object",
286
+ required: ["status"],
287
+ properties: {
288
+ status: { type: "string", enum: ["draining"] },
289
+ },
290
+ },
291
+ ErrorResponse: {
292
+ type: "object",
293
+ required: ["error"],
294
+ properties: {
295
+ error: { type: "string" },
296
+ },
297
+ },
298
+ TelegramOk: {
299
+ type: "object",
300
+ required: ["ok"],
301
+ properties: {
302
+ ok: { type: "boolean" },
303
+ },
304
+ },
305
+ TelegramUpdate: {
306
+ type: "object",
307
+ description: "Telegram Bot API Update object",
308
+ properties: {
309
+ update_id: { type: "integer" },
310
+ message: { $ref: "#/components/schemas/TelegramMessage" },
311
+ edited_message: {
312
+ $ref: "#/components/schemas/TelegramMessage",
313
+ },
314
+ },
315
+ },
316
+ TelegramMessage: {
317
+ type: "object",
318
+ properties: {
319
+ message_id: { type: "integer" },
320
+ text: { type: "string" },
321
+ caption: { type: "string" },
322
+ chat: {
323
+ type: "object",
324
+ properties: {
325
+ id: { type: "integer" },
326
+ type: { type: "string" },
327
+ },
328
+ },
329
+ from: {
330
+ type: "object",
331
+ properties: {
332
+ id: { type: "integer" },
333
+ is_bot: { type: "boolean" },
334
+ username: { type: "string" },
335
+ first_name: { type: "string" },
336
+ last_name: { type: "string" },
337
+ language_code: { type: "string" },
338
+ },
339
+ },
340
+ photo: {
341
+ type: "array",
342
+ items: {
343
+ $ref: "#/components/schemas/TelegramPhotoSize",
344
+ },
345
+ },
346
+ document: { $ref: "#/components/schemas/TelegramDocument" },
347
+ },
348
+ },
349
+ TelegramPhotoSize: {
350
+ type: "object",
351
+ properties: {
352
+ file_id: { type: "string" },
353
+ file_unique_id: { type: "string" },
354
+ width: { type: "integer" },
355
+ height: { type: "integer" },
356
+ file_size: { type: "integer" },
357
+ },
358
+ },
359
+ TelegramDocument: {
360
+ type: "object",
361
+ properties: {
362
+ file_id: { type: "string" },
363
+ file_unique_id: { type: "string" },
364
+ file_name: { type: "string" },
365
+ mime_type: { type: "string" },
366
+ file_size: { type: "integer" },
367
+ },
368
+ },
369
+ },
370
+ securitySchemes: {
371
+ BearerAuth: {
372
+ type: "http",
373
+ scheme: "bearer",
374
+ },
375
+ TelegramWebhookSecret: {
376
+ type: "apiKey",
377
+ in: "header",
378
+ name: "X-Telegram-Bot-Api-Secret-Token",
379
+ },
380
+ },
381
+ },
382
+ };
383
+ }