@kb-labs/gateway-app 0.2.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.
Files changed (46) hide show
  1. package/.kb/database/kb.sqlite-shm +0 -0
  2. package/.kb/database/kb.sqlite-wal +0 -0
  3. package/package.json +49 -0
  4. package/src/__tests__/auth-routes.test.ts +279 -0
  5. package/src/__tests__/execute-routes.test.ts +408 -0
  6. package/src/__tests__/execution-registry.test.ts +218 -0
  7. package/src/__tests__/health.test.ts +215 -0
  8. package/src/__tests__/live-gateway.e2e.test.ts +648 -0
  9. package/src/__tests__/llm-gateway.test.ts +361 -0
  10. package/src/__tests__/observability-collector.test.ts +59 -0
  11. package/src/__tests__/platform-api.test.ts +317 -0
  12. package/src/__tests__/registry.test.ts +546 -0
  13. package/src/__tests__/retry-executor.test.ts +244 -0
  14. package/src/__tests__/server.integration.test.ts +417 -0
  15. package/src/__tests__/subscription-registry.test.ts +308 -0
  16. package/src/__tests__/telemetry-ingest.test.ts +309 -0
  17. package/src/__tests__/tokens.test.ts +83 -0
  18. package/src/__tests__/ws-client-connect.e2e.test.ts +381 -0
  19. package/src/__tests__/ws-handshake.e2e.test.ts +288 -0
  20. package/src/auth/middleware.ts +50 -0
  21. package/src/auth/routes.ts +57 -0
  22. package/src/auth/tokens.ts +41 -0
  23. package/src/bootstrap.ts +98 -0
  24. package/src/clients/subscription-registry.ts +137 -0
  25. package/src/clients/ws-handler.ts +196 -0
  26. package/src/config.ts +20 -0
  27. package/src/docs/routes.ts +70 -0
  28. package/src/execute/errors.ts +21 -0
  29. package/src/execute/execution-registry.ts +84 -0
  30. package/src/execute/retry-executor.ts +159 -0
  31. package/src/execute/routes.ts +239 -0
  32. package/src/hosts/dispatcher.ts +2 -0
  33. package/src/hosts/registry.ts +305 -0
  34. package/src/hosts/ws-handler.ts +445 -0
  35. package/src/index.ts +7 -0
  36. package/src/llm/routes.ts +343 -0
  37. package/src/manifest.ts +21 -0
  38. package/src/observability/collector.ts +346 -0
  39. package/src/platform/routes.ts +195 -0
  40. package/src/server.ts +447 -0
  41. package/src/telemetry/routes.ts +89 -0
  42. package/src/ws/gateway-ws.ts +73 -0
  43. package/tsconfig.build.json +15 -0
  44. package/tsconfig.json +10 -0
  45. package/tsup.config.ts +8 -0
  46. package/vitest.config.ts +23 -0
@@ -0,0 +1,361 @@
1
+ /**
2
+ * Integration tests for AI Gateway LLM endpoint.
3
+ *
4
+ * Covers:
5
+ * POST /llm/v1/chat/completions
6
+ * - 401 without auth
7
+ * - 400 with invalid body
8
+ * - 400 with invalid tier
9
+ * - 503 when LLM not available
10
+ * - 200 non-streaming completion (simple complete)
11
+ * - 200 non-streaming with tool calls (chatWithTools)
12
+ * - 200 streaming (SSE format)
13
+ * - 500 on LLM error
14
+ */
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
16
+ import Fastify, { type FastifyInstance } from 'fastify';
17
+ import type { ICache, ILogger, ILLM, LLMResponse, LLMToolCallResponse } from '@kb-labs/core-platform';
18
+ import type { JwtConfig } from '@kb-labs/gateway-auth';
19
+ import { createAuthMiddleware } from '../auth/middleware.js';
20
+ import { registerLLMGatewayRoutes } from '../llm/routes.js';
21
+
22
+ // ── Mocks ─────────────────────────────────────────────────────────────────
23
+
24
+ // Mock platform singleton — must be before import
25
+ const mockLLM: ILLM & { chatWithTools: any } = {
26
+ complete: vi.fn(),
27
+ stream: vi.fn(),
28
+ chatWithTools: vi.fn(),
29
+ };
30
+
31
+ vi.mock('@kb-labs/core-runtime', () => ({
32
+ platform: {
33
+ get llm() { return mockLLM; },
34
+ },
35
+ }));
36
+
37
+ function makeCache(): ICache {
38
+ const store = new Map<string, unknown>();
39
+ return {
40
+ async get<T>(k: string) { return (store.get(k) as T) ?? null; },
41
+ async set(k: string, v: unknown) { store.set(k, v); },
42
+ async delete(k: string) { store.delete(k); },
43
+ async clear() { store.clear(); },
44
+ } as unknown as ICache;
45
+ }
46
+
47
+ const noopLogger: ILogger = {
48
+ info: vi.fn(),
49
+ warn: vi.fn(),
50
+ error: vi.fn(),
51
+ debug: vi.fn(),
52
+ child: vi.fn(() => noopLogger),
53
+ } as unknown as ILogger;
54
+
55
+ const stubJwtConfig: JwtConfig = { secret: 'test-secret' };
56
+
57
+ // Seed a static token so we can authenticate requests
58
+ const TEST_TOKEN = 'test-llm-token';
59
+ const TEST_AUTH_HEADER = `Bearer ${TEST_TOKEN}`;
60
+
61
+ // ── Test app builder ──────────────────────────────────────────────────────
62
+
63
+ async function buildApp(): Promise<{ app: FastifyInstance; cache: ICache }> {
64
+ const cache = makeCache();
65
+
66
+ // Seed machine token for auth
67
+ await cache.set(`host:token:${TEST_TOKEN}`, {
68
+ hostId: 'host-test',
69
+ namespaceId: 'ns-test',
70
+ });
71
+
72
+ const app = Fastify({ logger: false });
73
+
74
+ await app.register(async function scope(s) {
75
+ s.addHook('onRequest', createAuthMiddleware(cache, stubJwtConfig));
76
+ registerLLMGatewayRoutes(s as any, noopLogger);
77
+ });
78
+
79
+ await app.ready();
80
+ return { app, cache };
81
+ }
82
+
83
+ // ── Helpers ───────────────────────────────────────────────────────────────
84
+
85
+ function makeCompletionPayload(overrides: Record<string, unknown> = {}) {
86
+ return {
87
+ model: 'medium',
88
+ messages: [{ role: 'user', content: 'Hello' }],
89
+ ...overrides,
90
+ };
91
+ }
92
+
93
+ const MOCK_LLM_RESPONSE: LLMResponse = {
94
+ content: 'Hello! How can I help?',
95
+ usage: { promptTokens: 10, completionTokens: 8 },
96
+ model: 'gpt-4o',
97
+ };
98
+
99
+ const MOCK_TOOL_CALL_RESPONSE: LLMToolCallResponse = {
100
+ content: '',
101
+ usage: { promptTokens: 15, completionTokens: 20 },
102
+ model: 'gpt-4o',
103
+ toolCalls: [
104
+ {
105
+ id: 'call_abc123',
106
+ name: 'get_weather',
107
+ input: { location: 'Moscow' },
108
+ },
109
+ ],
110
+ stopReason: 'tool_use',
111
+ };
112
+
113
+ // ── Tests ─────────────────────────────────────────────────────────────────
114
+
115
+ describe('POST /llm/v1/chat/completions', () => {
116
+ let app: FastifyInstance;
117
+
118
+ beforeEach(async () => {
119
+ vi.clearAllMocks();
120
+ const result = await buildApp();
121
+ app = result.app;
122
+ });
123
+
124
+ afterEach(async () => {
125
+ await app.close();
126
+ });
127
+
128
+ // ── Auth ──────────────────────────────────────────────────────────────
129
+
130
+ it('returns 401 without auth token', async () => {
131
+ const res = await app.inject({
132
+ method: 'POST',
133
+ url: '/llm/v1/chat/completions',
134
+ payload: makeCompletionPayload(),
135
+ });
136
+
137
+ expect(res.statusCode).toBe(401);
138
+ });
139
+
140
+ // ── Validation ────────────────────────────────────────────────────────
141
+
142
+ it('returns 400 with empty body', async () => {
143
+ const res = await app.inject({
144
+ method: 'POST',
145
+ url: '/llm/v1/chat/completions',
146
+ headers: { authorization: TEST_AUTH_HEADER },
147
+ payload: {},
148
+ });
149
+
150
+ expect(res.statusCode).toBe(400);
151
+ const body = res.json();
152
+ expect(body.error.type).toBe('invalid_request_error');
153
+ });
154
+
155
+ it('returns 400 with invalid tier', async () => {
156
+ const res = await app.inject({
157
+ method: 'POST',
158
+ url: '/llm/v1/chat/completions',
159
+ headers: { authorization: TEST_AUTH_HEADER },
160
+ payload: makeCompletionPayload({ model: 'gpt-4o' }), // literal model name, not tier
161
+ });
162
+
163
+ expect(res.statusCode).toBe(400);
164
+ });
165
+
166
+ it('returns 400 with no messages', async () => {
167
+ const res = await app.inject({
168
+ method: 'POST',
169
+ url: '/llm/v1/chat/completions',
170
+ headers: { authorization: TEST_AUTH_HEADER },
171
+ payload: makeCompletionPayload({ messages: [] }),
172
+ });
173
+
174
+ expect(res.statusCode).toBe(400);
175
+ });
176
+
177
+ // ── Non-streaming completion ──────────────────────────────────────────
178
+
179
+ it('returns 200 with OpenAI-compatible response (non-streaming)', async () => {
180
+ (mockLLM.complete as any).mockResolvedValue(MOCK_LLM_RESPONSE);
181
+
182
+ const res = await app.inject({
183
+ method: 'POST',
184
+ url: '/llm/v1/chat/completions',
185
+ headers: { authorization: TEST_AUTH_HEADER },
186
+ payload: makeCompletionPayload(),
187
+ });
188
+
189
+ expect(res.statusCode).toBe(200);
190
+ const body = res.json();
191
+
192
+ // OpenAI format checks
193
+ expect(body.object).toBe('chat.completion');
194
+ expect(body.id).toMatch(/^chatcmpl-/);
195
+ expect(body.model).toBe('medium'); // tier, not concrete model
196
+ expect(body.choices).toHaveLength(1);
197
+ expect(body.choices[0].message.role).toBe('assistant');
198
+ expect(body.choices[0].message.content).toBe('Hello! How can I help?');
199
+ expect(body.choices[0].finish_reason).toBe('stop');
200
+ expect(body.usage.prompt_tokens).toBe(10);
201
+ expect(body.usage.completion_tokens).toBe(8);
202
+ expect(body.usage.total_tokens).toBe(18);
203
+ });
204
+
205
+ it('passes temperature and max_tokens to LLM', async () => {
206
+ (mockLLM.complete as any).mockResolvedValue(MOCK_LLM_RESPONSE);
207
+
208
+ await app.inject({
209
+ method: 'POST',
210
+ url: '/llm/v1/chat/completions',
211
+ headers: { authorization: TEST_AUTH_HEADER },
212
+ payload: makeCompletionPayload({ temperature: 0.5, max_tokens: 100 }),
213
+ });
214
+
215
+ expect(mockLLM.complete).toHaveBeenCalledWith(
216
+ expect.any(String),
217
+ expect.objectContaining({
218
+ temperature: 0.5,
219
+ maxTokens: 100,
220
+ }),
221
+ );
222
+ });
223
+
224
+ it('extracts system prompt from messages', async () => {
225
+ (mockLLM.complete as any).mockResolvedValue(MOCK_LLM_RESPONSE);
226
+
227
+ await app.inject({
228
+ method: 'POST',
229
+ url: '/llm/v1/chat/completions',
230
+ headers: { authorization: TEST_AUTH_HEADER },
231
+ payload: makeCompletionPayload({
232
+ messages: [
233
+ { role: 'system', content: 'You are helpful.' },
234
+ { role: 'user', content: 'Hi' },
235
+ ],
236
+ }),
237
+ });
238
+
239
+ expect(mockLLM.complete).toHaveBeenCalledWith(
240
+ 'Hi',
241
+ expect.objectContaining({ systemPrompt: 'You are helpful.' }),
242
+ );
243
+ });
244
+
245
+ // ── Tool calling ──────────────────────────────────────────────────────
246
+
247
+ it('returns tool_calls in OpenAI format when LLM requests tools', async () => {
248
+ (mockLLM.chatWithTools as any).mockResolvedValue(MOCK_TOOL_CALL_RESPONSE);
249
+
250
+ const res = await app.inject({
251
+ method: 'POST',
252
+ url: '/llm/v1/chat/completions',
253
+ headers: { authorization: TEST_AUTH_HEADER },
254
+ payload: makeCompletionPayload({
255
+ tools: [
256
+ {
257
+ type: 'function',
258
+ function: {
259
+ name: 'get_weather',
260
+ description: 'Get weather for a location',
261
+ parameters: { type: 'object', properties: { location: { type: 'string' } } },
262
+ },
263
+ },
264
+ ],
265
+ }),
266
+ });
267
+
268
+ expect(res.statusCode).toBe(200);
269
+ const body = res.json();
270
+
271
+ expect(body.choices[0].finish_reason).toBe('tool_calls');
272
+ expect(body.choices[0].message.tool_calls).toHaveLength(1);
273
+ expect(body.choices[0].message.tool_calls[0]).toEqual({
274
+ id: 'call_abc123',
275
+ type: 'function',
276
+ function: {
277
+ name: 'get_weather',
278
+ arguments: '{"location":"Moscow"}',
279
+ },
280
+ });
281
+ });
282
+
283
+ // ── Streaming ─────────────────────────────────────────────────────────
284
+
285
+ it('returns SSE stream with correct format', async () => {
286
+ // Mock stream as async iterable
287
+ const chunks = ['Hello', ', ', 'world', '!'];
288
+ (mockLLM.stream as any).mockReturnValue(
289
+ (async function* () {
290
+ for (const c of chunks) {yield c;}
291
+ })(),
292
+ );
293
+
294
+ const res = await app.inject({
295
+ method: 'POST',
296
+ url: '/llm/v1/chat/completions',
297
+ headers: { authorization: TEST_AUTH_HEADER },
298
+ payload: makeCompletionPayload({ stream: true }),
299
+ });
300
+
301
+ expect(res.statusCode).toBe(200);
302
+ expect(res.headers['content-type']).toBe('text/event-stream');
303
+
304
+ // Parse SSE events
305
+ const lines = res.body.split('\n').filter((l) => l.startsWith('data: '));
306
+ const events = lines.map((l) => l.replace('data: ', ''));
307
+
308
+ // First event: role chunk
309
+ const first = JSON.parse(events[0]!);
310
+ expect(first.object).toBe('chat.completion.chunk');
311
+ expect(first.choices[0].delta.role).toBe('assistant');
312
+
313
+ // Middle events: content chunks
314
+ const contentChunks = events
315
+ .slice(1, -2) // skip first (role) and last two (finish + [DONE])
316
+ .map((e) => JSON.parse(e));
317
+ const streamedText = contentChunks.map((c) => c.choices[0].delta.content).join('');
318
+ expect(streamedText).toBe('Hello, world!');
319
+
320
+ // Last JSON event: finish chunk
321
+ const finish = JSON.parse(events[events.length - 2]!);
322
+ expect(finish.choices[0].finish_reason).toBe('stop');
323
+
324
+ // Final: [DONE] sentinel
325
+ expect(events[events.length - 1]).toBe('[DONE]');
326
+ });
327
+
328
+ // ── Error handling ────────────────────────────────────────────────────
329
+
330
+ it('returns 500 when LLM throws', async () => {
331
+ (mockLLM.complete as any).mockRejectedValue(new Error('Provider timeout'));
332
+
333
+ const res = await app.inject({
334
+ method: 'POST',
335
+ url: '/llm/v1/chat/completions',
336
+ headers: { authorization: TEST_AUTH_HEADER },
337
+ payload: makeCompletionPayload(),
338
+ });
339
+
340
+ expect(res.statusCode).toBe(500);
341
+ const body = res.json();
342
+ expect(body.error.type).toBe('server_error');
343
+ });
344
+
345
+ // ── Tiers ─────────────────────────────────────────────────────────────
346
+
347
+ it('accepts all valid tiers: small, medium, large', async () => {
348
+ (mockLLM.complete as any).mockResolvedValue(MOCK_LLM_RESPONSE);
349
+
350
+ for (const tier of ['small', 'medium', 'large']) {
351
+ const res = await app.inject({
352
+ method: 'POST',
353
+ url: '/llm/v1/chat/completions',
354
+ headers: { authorization: TEST_AUTH_HEADER },
355
+ payload: makeCompletionPayload({ model: tier }),
356
+ });
357
+ expect(res.statusCode).toBe(200);
358
+ expect(res.json().model).toBe(tier);
359
+ }
360
+ });
361
+ });
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { GatewayConfig } from '@kb-labs/gateway-contracts';
3
+ import {
4
+ checkCanonicalObservabilityMetrics,
5
+ validateServiceObservabilityDescribe,
6
+ validateServiceObservabilityHealth,
7
+ } from '@kb-labs/core-contracts';
8
+ import { GatewayObservabilityCollector } from '../observability/collector.js';
9
+
10
+ const config: GatewayConfig = {
11
+ port: 4000,
12
+ upstreams: {
13
+ rest: {
14
+ url: 'http://localhost:5050',
15
+ prefix: '/api/v1',
16
+ },
17
+ },
18
+ staticTokens: {},
19
+ };
20
+
21
+ describe('GatewayObservabilityCollector', () => {
22
+ it('builds versioned describe payload', () => {
23
+ const collector = new GatewayObservabilityCollector(config);
24
+ const payload = collector.buildDescribe();
25
+
26
+ expect(payload).toMatchObject({
27
+ serviceId: 'gateway',
28
+ contractVersion: '1.0',
29
+ metricsEndpoint: '/metrics',
30
+ healthEndpoint: '/observability/health',
31
+ capabilities: expect.arrayContaining(['httpMetrics', 'eventLoopMetrics']),
32
+ });
33
+ expect(validateServiceObservabilityDescribe(payload).ok).toBe(true);
34
+ });
35
+
36
+ it('builds health payload and exposes canonical metrics', async () => {
37
+ const collector = new GatewayObservabilityCollector(config);
38
+ const health = collector.buildHealth({
39
+ status: 'healthy',
40
+ adapterChecks: [{ id: 'llm', available: true, latencyMs: 2 }],
41
+ upstreamChecks: [{ id: 'rest', status: 'up', latencyMs: 5 }],
42
+ });
43
+
44
+ expect(health).toMatchObject({
45
+ serviceId: 'gateway',
46
+ contractVersion: '1.0',
47
+ status: 'healthy',
48
+ state: 'active',
49
+ checks: expect.arrayContaining([
50
+ expect.objectContaining({ id: 'adapter:llm', status: 'ok' }),
51
+ expect.objectContaining({ id: 'upstream:rest', status: 'ok' }),
52
+ ]),
53
+ });
54
+ expect(validateServiceObservabilityHealth(health).ok).toBe(true);
55
+
56
+ const metrics = await collector.renderPrometheusMetrics('healthy');
57
+ expect(checkCanonicalObservabilityMetrics(metrics).missing).toEqual([]);
58
+ });
59
+ });