@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.
- package/.kb/database/kb.sqlite-shm +0 -0
- package/.kb/database/kb.sqlite-wal +0 -0
- package/package.json +49 -0
- package/src/__tests__/auth-routes.test.ts +279 -0
- package/src/__tests__/execute-routes.test.ts +408 -0
- package/src/__tests__/execution-registry.test.ts +218 -0
- package/src/__tests__/health.test.ts +215 -0
- package/src/__tests__/live-gateway.e2e.test.ts +648 -0
- package/src/__tests__/llm-gateway.test.ts +361 -0
- package/src/__tests__/observability-collector.test.ts +59 -0
- package/src/__tests__/platform-api.test.ts +317 -0
- package/src/__tests__/registry.test.ts +546 -0
- package/src/__tests__/retry-executor.test.ts +244 -0
- package/src/__tests__/server.integration.test.ts +417 -0
- package/src/__tests__/subscription-registry.test.ts +308 -0
- package/src/__tests__/telemetry-ingest.test.ts +309 -0
- package/src/__tests__/tokens.test.ts +83 -0
- package/src/__tests__/ws-client-connect.e2e.test.ts +381 -0
- package/src/__tests__/ws-handshake.e2e.test.ts +288 -0
- package/src/auth/middleware.ts +50 -0
- package/src/auth/routes.ts +57 -0
- package/src/auth/tokens.ts +41 -0
- package/src/bootstrap.ts +98 -0
- package/src/clients/subscription-registry.ts +137 -0
- package/src/clients/ws-handler.ts +196 -0
- package/src/config.ts +20 -0
- package/src/docs/routes.ts +70 -0
- package/src/execute/errors.ts +21 -0
- package/src/execute/execution-registry.ts +84 -0
- package/src/execute/retry-executor.ts +159 -0
- package/src/execute/routes.ts +239 -0
- package/src/hosts/dispatcher.ts +2 -0
- package/src/hosts/registry.ts +305 -0
- package/src/hosts/ws-handler.ts +445 -0
- package/src/index.ts +7 -0
- package/src/llm/routes.ts +343 -0
- package/src/manifest.ts +21 -0
- package/src/observability/collector.ts +346 -0
- package/src/platform/routes.ts +195 -0
- package/src/server.ts +447 -0
- package/src/telemetry/routes.ts +89 -0
- package/src/ws/gateway-ws.ts +73 -0
- package/tsconfig.build.json +15 -0
- package/tsconfig.json +10 -0
- package/tsup.config.ts +8 -0
- 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
|
+
});
|