@learncard/lca-api-service 1.5.3 → 1.6.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 (45) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +26 -0
  3. package/bundle.mjs +1 -1
  4. package/dist/app.d.ts +4 -1
  5. package/dist/config/cacheEnvironment.d.ts +4 -4
  6. package/dist/config/environment.d.ts +145 -114
  7. package/dist/config/oidcEnvironment.d.ts +22 -22
  8. package/dist/didkit_wasm_bg.wasm +0 -0
  9. package/dist/helpers/bedrockGeneration.d.ts +17 -0
  10. package/dist/helpers/bedrockGeneration.test.d.ts +1 -0
  11. package/dist/helpers/bedrockSchema.d.ts +10 -0
  12. package/dist/helpers/imageGeneration.d.ts +42 -0
  13. package/dist/helpers/imageGeneration.test.d.ts +1 -0
  14. package/dist/index.js +2 -2
  15. package/dist/models/Notification.d.ts +3 -1
  16. package/dist/publish-metafile.json +1 -1
  17. package/dist/routes/ai.skill-icons.test.d.ts +1 -0
  18. package/dist/routes/keys.d.ts +1 -0
  19. package/dist/routes/notifications.d.ts +3 -1
  20. package/dist/types/notifications.d.ts +3 -1
  21. package/docs/bedrock-generation.md +119 -0
  22. package/docs/image-generation.md +120 -0
  23. package/package.json +20 -15
  24. package/scripts/node24-runtime.cjs +9 -0
  25. package/scripts/verify-node24-layer.cjs +49 -0
  26. package/serverless-local.yml +3 -1
  27. package/serverless.function-env.cjs +2 -4
  28. package/serverless.yml +76 -3
  29. package/src/config/environment.ts +14 -0
  30. package/src/helpers/bedrockGeneration.test.ts +222 -0
  31. package/src/helpers/bedrockGeneration.ts +110 -0
  32. package/src/helpers/bedrockSchema.ts +352 -0
  33. package/src/helpers/imageGeneration.test.ts +456 -0
  34. package/src/helpers/imageGeneration.ts +432 -0
  35. package/src/routes/ai.skill-icons.test.ts +92 -0
  36. package/src/routes/ai.test.ts +68 -91
  37. package/src/routes/ai.ts +31 -143
  38. package/src/routes/keys.ts +18 -0
  39. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
  40. package/src/services/escrow-enclave/logFailure.test.ts +8 -0
  41. package/src/services/escrow-enclave/logFailure.ts +17 -0
  42. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +10 -0
  43. package/test/keys-auth-share-escrow.spec.ts +103 -0
  44. package/test/oidc.spec.ts +41 -2
  45. package/tsconfig.bedrock.json +10 -0
@@ -0,0 +1,432 @@
1
+ import { BedrockRuntimeClient, InvokeModelCommand } from '@aws-sdk/client-bedrock-runtime';
2
+ import { TRPCError } from '@trpc/server';
3
+ import type OpenAI from 'openai';
4
+ import { PNG } from 'pngjs';
5
+ import { createHash } from 'node:crypto';
6
+ import { inflateSync } from 'node:zlib';
7
+
8
+ export interface ImageGenerationConfig {
9
+ IMAGE_PROVIDER: 'openai' | 'bedrock';
10
+ BEDROCK_IMAGE_REGION: string;
11
+ BEDROCK_IMAGE_MODEL_ID: string;
12
+ IMAGE_TIMEOUT_MS: number;
13
+ }
14
+
15
+ interface ImageStorage {
16
+ storeURL: (url: string) => Promise<{ url?: string }>;
17
+ upload: (
18
+ bytes: Buffer,
19
+ options: { timeout: number },
20
+ storeOptions: { filename: string }
21
+ ) => Promise<{ url?: string }>;
22
+ }
23
+
24
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
25
+ const MAX_RESPONSE_BYTES = 14 * 1024 * 1024;
26
+ /** At most two quick retries, and only for rejections Bedrock does not bill (throttling/unavailable). */
27
+ const MAX_BEDROCK_ATTEMPTS = 3;
28
+ const RETRY_BASE_DELAY_MS = 250;
29
+ const RETRY_MAX_DELAY_MS = 2_000;
30
+ /** Do not start another attempt unless a full generation (~6 s) plus upload still fits the deadline. */
31
+ const MIN_ATTEMPT_BUDGET_MS = 12_000;
32
+
33
+ const failure = (): TRPCError =>
34
+ new TRPCError({
35
+ code: 'INTERNAL_SERVER_ERROR',
36
+ message: 'Image generation failed. Please try again later.',
37
+ });
38
+ const rateLimited = (): TRPCError =>
39
+ new TRPCError({ code: 'TOO_MANY_REQUESTS', message: 'AI generation rate limited' });
40
+ const unavailable = (): TRPCError =>
41
+ new TRPCError({
42
+ code: 'SERVICE_UNAVAILABLE',
43
+ message: 'Image generation unavailable. Please try again later.',
44
+ });
45
+ const filtered = (): TRPCError =>
46
+ new TRPCError({ code: 'BAD_REQUEST', message: 'The image request was filtered.' });
47
+
48
+ /** Sanitized, allowlisted diagnostics for one request. Never holds prompts, bodies or headers. */
49
+ export interface ImageDiagnostics {
50
+ stage?: 'invoke' | 'decode' | 'storage' | 'timeout' | 'config';
51
+ category?: ProviderErrorCategory;
52
+ decodeReason?: string;
53
+ finishReason?: string | null;
54
+ errorName?: string;
55
+ errorCode?: string;
56
+ httpStatus?: number;
57
+ awsRequestId?: string;
58
+ sdkRetryable?: boolean;
59
+ attempts?: number;
60
+ }
61
+
62
+ type ProviderErrorCategory = 'throttled' | 'unavailable' | 'timeout' | 'filtered' | 'rejected';
63
+
64
+ const SAFE_IDENTIFIER = /^[A-Za-z][\w.]{0,63}$/;
65
+ const SAFE_REQUEST_ID = /^[\w-]{1,128}$/;
66
+ const SAFE_FINISH_REASON = /^Filter reason: [a-z ]{1,32}$/;
67
+
68
+ /** HTTP status from an OpenAI SDK APIError or an AWS SDK service exception, if any. */
69
+ const providerStatus = (error: unknown): number | undefined => {
70
+ if (!error || typeof error !== 'object') return undefined;
71
+ const { status, $metadata: metadata } = error as {
72
+ status?: unknown;
73
+ $metadata?: { httpStatusCode?: unknown };
74
+ };
75
+ if (typeof status === 'number') return status;
76
+ return typeof metadata?.httpStatusCode === 'number' ? metadata.httpStatusCode : undefined;
77
+ };
78
+
79
+ /** Allowlisted metadata from an AWS SDK / OpenAI SDK / Node network error. Messages are never read. */
80
+ export const providerErrorInfo = (error: unknown): Partial<ImageDiagnostics> => {
81
+ if (!error || typeof error !== 'object') return {};
82
+ const {
83
+ name,
84
+ code,
85
+ $metadata: metadata,
86
+ $retryable: retryable,
87
+ } = error as {
88
+ name?: unknown;
89
+ code?: unknown;
90
+ $metadata?: { requestId?: unknown };
91
+ $retryable?: unknown;
92
+ };
93
+ const requestId = metadata?.requestId;
94
+ return {
95
+ errorName: typeof name === 'string' && SAFE_IDENTIFIER.test(name) ? name : undefined,
96
+ errorCode: typeof code === 'string' && SAFE_IDENTIFIER.test(code) ? code : undefined,
97
+ httpStatus: providerStatus(error),
98
+ awsRequestId:
99
+ typeof requestId === 'string' && SAFE_REQUEST_ID.test(requestId)
100
+ ? requestId
101
+ : undefined,
102
+ sdkRetryable: retryable ? true : undefined,
103
+ };
104
+ };
105
+
106
+ const THROTTLED_NAMES = new Set([
107
+ 'ThrottlingException',
108
+ // Bedrock reports some account quotas (e.g. requests/tokens per day or minute for a newly
109
+ // enabled model) as HTTP 400 ServiceQuotaExceededException rather than 429.
110
+ 'ServiceQuotaExceededException',
111
+ 'ModelNotReadyException',
112
+ 'TooManyRequestsException',
113
+ ]);
114
+ const UNAVAILABLE_NAMES = new Set(['ServiceUnavailableException', 'InternalServerException']);
115
+ const TIMEOUT_NAMES = new Set(['ModelTimeoutException', 'TimeoutError', 'RequestTimeout']);
116
+ /** Matched only to classify; the provider message is never logged or returned. */
117
+ const CONTENT_FILTER_MESSAGE = /content filter|blocked by our|moderation|flagged/i;
118
+
119
+ /** Classify a provider failure. Only throttling/unavailable rejections are retried. */
120
+ export const classifyProviderError = (
121
+ error: unknown
122
+ ): { category: ProviderErrorCategory; retryable: boolean } => {
123
+ const { errorName, httpStatus } = providerErrorInfo(error);
124
+ const name = errorName ?? '';
125
+ if (THROTTLED_NAMES.has(name) || httpStatus === 429) {
126
+ return { category: 'throttled', retryable: true };
127
+ }
128
+ if (UNAVAILABLE_NAMES.has(name) || httpStatus === 503 || httpStatus === 500) {
129
+ return { category: 'unavailable', retryable: true };
130
+ }
131
+ if (TIMEOUT_NAMES.has(name) || httpStatus === 504 || httpStatus === 408) {
132
+ // The model may have run (and billed); never retry inside the same request.
133
+ return { category: 'timeout', retryable: false };
134
+ }
135
+ const message = (error as { message?: unknown } | null)?.message;
136
+ if (
137
+ name === 'ValidationException' &&
138
+ typeof message === 'string' &&
139
+ CONTENT_FILTER_MESSAGE.test(message)
140
+ ) {
141
+ return { category: 'filtered', retryable: false };
142
+ }
143
+ return { category: 'rejected', retryable: false };
144
+ };
145
+
146
+ const clientError = (category: ProviderErrorCategory): TRPCError => {
147
+ if (category === 'throttled') return rateLimited();
148
+ if (category === 'unavailable' || category === 'timeout') return unavailable();
149
+ if (category === 'filtered') return filtered();
150
+ return failure();
151
+ };
152
+
153
+ const PASSTHROUGH_CODES = new Set(['BAD_REQUEST', 'TOO_MANY_REQUESTS', 'SERVICE_UNAVAILABLE']);
154
+
155
+ const promptHash = (prompt: string): string =>
156
+ createHash('sha256').update(prompt).digest('hex').slice(0, 12);
157
+
158
+ const abortableDelay = (ms: number, signal: AbortSignal): Promise<void> =>
159
+ new Promise((resolve, reject) => {
160
+ if (signal.aborted) reject(unavailable());
161
+ const timer = setTimeout(() => {
162
+ signal.removeEventListener('abort', onAbort);
163
+ resolve();
164
+ }, ms);
165
+ const onAbort = () => {
166
+ clearTimeout(timer);
167
+ reject(unavailable());
168
+ };
169
+ signal.addEventListener('abort', onAbort, { once: true });
170
+ });
171
+
172
+ /** Full jitter: uniform in [0, min(cap, base * 2^(attempt-1))]. */
173
+ const retryDelay = (attempt: number): number =>
174
+ Math.floor(Math.random() * Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_DELAY_MS * 2 ** attempt));
175
+
176
+ /** Validate a single unfiltered, bounded square PNG before handing bytes to storage. */
177
+ export const decodeBedrockImage = (
178
+ body: Uint8Array,
179
+ diagnostics: ImageDiagnostics = {}
180
+ ): Buffer => {
181
+ function reject(reason: string): never {
182
+ diagnostics.decodeReason = reason;
183
+ throw failure();
184
+ }
185
+ if (body.byteLength > MAX_RESPONSE_BYTES) reject('response_too_large');
186
+ let result: unknown;
187
+ try {
188
+ result = JSON.parse(Buffer.from(body).toString('utf8'));
189
+ } catch {
190
+ reject('json_invalid');
191
+ }
192
+ if (!result || typeof result !== 'object') reject('json_invalid');
193
+ const { images, finish_reasons: reasons } = result as Record<string, unknown>;
194
+ if (!Array.isArray(reasons) || reasons.length !== 1) reject('finish_reasons_invalid');
195
+ const reason: unknown = reasons[0];
196
+ diagnostics.finishReason =
197
+ reason === null
198
+ ? null
199
+ : typeof reason === 'string' && SAFE_FINISH_REASON.test(reason)
200
+ ? reason
201
+ : 'other';
202
+ if (typeof reason === 'string' && reason.startsWith('Filter reason:')) {
203
+ diagnostics.decodeReason = 'filtered';
204
+ throw filtered();
205
+ }
206
+ if (reason !== null) reject('not_success');
207
+ if (!Array.isArray(images) || images.length !== 1) reject('image_count_invalid');
208
+ const encoded: unknown = images[0];
209
+ if (
210
+ typeof encoded !== 'string' ||
211
+ !encoded.length ||
212
+ encoded.length > Math.ceil(MAX_IMAGE_BYTES / 3) * 4 ||
213
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)
214
+ )
215
+ reject('base64_invalid');
216
+ const bytes = Buffer.from(encoded, 'base64');
217
+ if (
218
+ bytes.length < 33 ||
219
+ bytes.length > MAX_IMAGE_BYTES ||
220
+ bytes.toString('base64') !== encoded ||
221
+ !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
222
+ bytes.readUInt32BE(8) !== 13 ||
223
+ bytes.toString('ascii', 12, 16) !== 'IHDR'
224
+ )
225
+ reject('png_header_invalid');
226
+ const width = bytes.readUInt32BE(16);
227
+ if (width !== bytes.readUInt32BE(20) || width < 640 || width > 1536) {
228
+ reject('png_dimensions_invalid');
229
+ }
230
+ // Reject duplicate headers and bound decompression, including interlaced PNGs.
231
+ const compressed: Buffer[] = [];
232
+ let ended = false;
233
+ for (let offset = 8; offset < bytes.length;) {
234
+ if (offset + 12 > bytes.length || ended) reject('png_structure_invalid');
235
+ const length = bytes.readUInt32BE(offset);
236
+ const end = offset + 12 + length;
237
+ if (end > bytes.length) reject('png_structure_invalid');
238
+ const type = bytes.toString('ascii', offset + 4, offset + 8);
239
+ if (type === 'IHDR' && offset !== 8) reject('png_structure_invalid');
240
+ if (type === 'IDAT') compressed.push(bytes.subarray(offset + 8, end - 4));
241
+ if (type === 'IEND') {
242
+ if (length !== 0) reject('png_structure_invalid');
243
+ ended = true;
244
+ }
245
+ offset = end;
246
+ }
247
+ if (!ended || !compressed.length) reject('png_structure_invalid');
248
+ try {
249
+ inflateSync(Buffer.concat(compressed), { maxOutputLength: 20 * 1024 * 1024 });
250
+ // Dimensions are bounded before decode, preventing oversized pixel allocations.
251
+ const image = PNG.sync.read(bytes, { checkCRC: true });
252
+ if (image.width !== width || image.height !== width) reject('png_dimensions_invalid');
253
+ } catch (error) {
254
+ if (error instanceof TRPCError) throw error;
255
+ reject('png_decode_failed');
256
+ }
257
+ return bytes;
258
+ };
259
+
260
+ /**
261
+ * One successful paid invocation per request, no provider fallback. Bedrock throttling/unavailable
262
+ * rejections (not billed) get at most two jittered retries, only while a full attempt still fits
263
+ * inside IMAGE_TIMEOUT_MS. Every failure logs one sanitized `image_generation_failure` record.
264
+ */
265
+ export const generateStoredImage = async (
266
+ prompt: string,
267
+ did: string,
268
+ config: ImageGenerationConfig,
269
+ openai: OpenAI | undefined,
270
+ storage: ImageStorage
271
+ ): Promise<{ url: string }> => {
272
+ if (config.IMAGE_PROVIDER === 'bedrock' && prompt.length > 10_000) {
273
+ throw new TRPCError({
274
+ code: 'BAD_REQUEST',
275
+ message: 'Image prompt exceeds 10000 characters.',
276
+ });
277
+ }
278
+ const startedAt = Date.now();
279
+ const deadline = startedAt + config.IMAGE_TIMEOUT_MS;
280
+ const bedrock = config.IMAGE_PROVIDER === 'bedrock';
281
+ const logBase = () => ({
282
+ provider: config.IMAGE_PROVIDER,
283
+ modelId: bedrock ? config.BEDROCK_IMAGE_MODEL_ID : 'dall-e-3',
284
+ region: bedrock ? config.BEDROCK_IMAGE_REGION : undefined,
285
+ durationMs: Date.now() - startedAt,
286
+ promptLength: prompt.length,
287
+ promptHash: promptHash(prompt),
288
+ });
289
+ const diagnostics: ImageDiagnostics = { attempts: 0 };
290
+ const providerError = (error: unknown): TRPCError => {
291
+ if (error instanceof TRPCError) return error;
292
+ const { category } = classifyProviderError(error);
293
+ Object.assign(diagnostics, providerErrorInfo(error), { stage: 'invoke', category });
294
+ return clientError(category);
295
+ };
296
+ const controller = new AbortController();
297
+ let timeout: ReturnType<typeof setTimeout> | undefined;
298
+ const work = async (): Promise<{ url: string }> => {
299
+ let stored: { url?: string };
300
+ if (bedrock) {
301
+ // SigV4 with the execution role's rotating credentials only: never a long-term
302
+ // AWS_BEARER_TOKEN_BEDROCK API key, matching the text adapter's credential policy.
303
+ const client = new BedrockRuntimeClient({
304
+ region: config.BEDROCK_IMAGE_REGION,
305
+ maxAttempts: 1,
306
+ authSchemePreference: ['sigv4'],
307
+ });
308
+ try {
309
+ let response: { body: Uint8Array } | undefined;
310
+ for (let attempt = 1; !response; attempt += 1) {
311
+ diagnostics.attempts = attempt;
312
+ try {
313
+ response = await client.send(
314
+ new InvokeModelCommand({
315
+ modelId: config.BEDROCK_IMAGE_MODEL_ID,
316
+ contentType: 'application/json',
317
+ accept: 'application/json',
318
+ body: JSON.stringify({
319
+ prompt,
320
+ aspect_ratio: '1:1',
321
+ output_format: 'png',
322
+ }),
323
+ }),
324
+ { abortSignal: controller.signal }
325
+ );
326
+ } catch (error) {
327
+ // Our own deadline aborted the call: the timeout path owns the outcome/log.
328
+ if (controller.signal.aborted) throw unavailable();
329
+ const mapped = providerError(error);
330
+ const delay = retryDelay(attempt);
331
+ if (
332
+ !classifyProviderError(error).retryable ||
333
+ attempt >= MAX_BEDROCK_ATTEMPTS ||
334
+ controller.signal.aborted ||
335
+ deadline - Date.now() - delay < MIN_ATTEMPT_BUDGET_MS
336
+ ) {
337
+ throw mapped;
338
+ }
339
+ console.warn({
340
+ event: 'image_generation_retry',
341
+ ...logBase(),
342
+ ...providerErrorInfo(error),
343
+ category: diagnostics.category,
344
+ attempt,
345
+ delayMs: delay,
346
+ });
347
+ await abortableDelay(delay, controller.signal);
348
+ }
349
+ }
350
+ diagnostics.stage = 'decode';
351
+ const bytes = decodeBedrockImage(response.body, diagnostics);
352
+ if (controller.signal.aborted) throw failure();
353
+ diagnostics.stage = 'storage';
354
+ // The upload isn't tied to the abort signal, so cap it at the remaining budget
355
+ // rather than a fresh IMAGE_TIMEOUT_MS that would outlive the request.
356
+ const uploadTimeoutMs = Math.max(1000, deadline - Date.now());
357
+ stored = await storage.upload(
358
+ bytes,
359
+ { timeout: uploadTimeoutMs },
360
+ { filename: 'generated-image.png' }
361
+ );
362
+ } finally {
363
+ client.destroy();
364
+ }
365
+ } else {
366
+ if (!openai) {
367
+ diagnostics.stage = 'config';
368
+ throw failure();
369
+ }
370
+ diagnostics.attempts = 1;
371
+ diagnostics.stage = 'invoke';
372
+ const response = await openai.images
373
+ .generate(
374
+ { prompt, model: 'dall-e-3', size: '1024x1024', n: 1, user: did },
375
+ { signal: controller.signal, timeout: config.IMAGE_TIMEOUT_MS, maxRetries: 0 }
376
+ )
377
+ .catch((error: unknown) => {
378
+ throw providerError(error);
379
+ });
380
+ if (!response.data?.[0]?.url || controller.signal.aborted) throw failure();
381
+ diagnostics.stage = 'storage';
382
+ stored = await storage.storeURL(response.data[0].url);
383
+ }
384
+ if (!stored?.url) throw failure();
385
+ return { url: stored.url };
386
+ };
387
+ try {
388
+ const result = await Promise.race([
389
+ work(),
390
+ new Promise<never>((_, reject) => {
391
+ timeout = setTimeout(() => {
392
+ diagnostics.stage = 'timeout';
393
+ controller.abort();
394
+ reject(unavailable());
395
+ }, config.IMAGE_TIMEOUT_MS);
396
+ }),
397
+ ]);
398
+ console.info({
399
+ event: 'image_generation_success',
400
+ ...logBase(),
401
+ attempts: diagnostics.attempts,
402
+ });
403
+ return result;
404
+ } catch (error) {
405
+ // Storage/config/decode failures are not provider statuses: keep them generic.
406
+ const clientFacing =
407
+ error instanceof TRPCError && PASSTHROUGH_CODES.has(error.code) ? error : failure();
408
+ // Allowlisted fields only: no prompt text, provider messages, headers, URLs or bytes.
409
+ console.error({
410
+ event: 'image_generation_failure',
411
+ ...logBase(),
412
+ stage: diagnostics.stage,
413
+ attempts: diagnostics.attempts,
414
+ category: diagnostics.category,
415
+ errorName: diagnostics.errorName,
416
+ errorCode: diagnostics.errorCode,
417
+ httpStatus: diagnostics.httpStatus,
418
+ awsRequestId: diagnostics.awsRequestId,
419
+ sdkRetryable: diagnostics.sdkRetryable,
420
+ retryable: diagnostics.category
421
+ ? diagnostics.category === 'throttled' || diagnostics.category === 'unavailable'
422
+ : undefined,
423
+ finishReason: diagnostics.finishReason,
424
+ decodeReason: diagnostics.decodeReason,
425
+ ...(diagnostics.stage === 'storage' ? { storage: providerErrorInfo(error) } : {}),
426
+ clientCode: clientFacing.code,
427
+ });
428
+ throw clientFacing;
429
+ } finally {
430
+ clearTimeout(timeout);
431
+ }
432
+ };
@@ -0,0 +1,92 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ SKILL_ICON_MAX_CHARACTERS_PER_REQUEST,
4
+ SKILL_ICON_MAX_NAMES_PER_REQUEST,
5
+ } from '@learncard/types';
6
+
7
+ const mocks = vi.hoisted(() => ({
8
+ create: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('@environment', () => ({ environment: {} }));
12
+ vi.mock('@helpers/bedrockGeneration', () => ({
13
+ createBedrockClient: vi.fn(() => ({})),
14
+ generateStructuredOutput: mocks.create,
15
+ }));
16
+ vi.mock('filestack-js', () => ({ init: () => ({}) }));
17
+ vi.mock('@routes', async () => {
18
+ const { initTRPC } = await import('@trpc/server');
19
+ const t = initTRPC.create();
20
+ return { t, didAndChallengeRoute: t.procedure };
21
+ });
22
+
23
+ import { aiRouter } from './ai';
24
+
25
+ const caller = aiRouter.createCaller({ user: { did: 'did:example:123' } } as never);
26
+
27
+ const respondWithIcons = (): void => {
28
+ mocks.create.mockImplementation(async (_client: unknown, { input }: { input: string }) => {
29
+ const names = [...input.matchAll(/^- (.*)$/gm)].map(match => match[1]);
30
+
31
+ return { items: names.map(name => ({ name, icon: '🎯' })) };
32
+ });
33
+ };
34
+
35
+ describe('generateSkillIcons input validation', () => {
36
+ beforeEach(() => {
37
+ vi.clearAllMocks();
38
+ respondWithIcons();
39
+ });
40
+
41
+ it('accepts a batch at both limits', async () => {
42
+ const names = Array.from({ length: SKILL_ICON_MAX_NAMES_PER_REQUEST }, (_, i) =>
43
+ `${i}`.padStart(
44
+ SKILL_ICON_MAX_CHARACTERS_PER_REQUEST / SKILL_ICON_MAX_NAMES_PER_REQUEST,
45
+ 'x'
46
+ )
47
+ );
48
+
49
+ const result = await caller.generateSkillIcons({ names });
50
+
51
+ expect(Object.keys(result)).toHaveLength(SKILL_ICON_MAX_NAMES_PER_REQUEST);
52
+ expect(mocks.create).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ it('rejects too many names with BAD_REQUEST before calling the model', async () => {
56
+ const names = Array.from(
57
+ { length: SKILL_ICON_MAX_NAMES_PER_REQUEST + 1 },
58
+ (_, i) => `s${i}`
59
+ );
60
+
61
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
62
+ code: 'BAD_REQUEST',
63
+ });
64
+ expect(mocks.create).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it('rejects names totalling more than the character limit with BAD_REQUEST', async () => {
68
+ const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST), 'b'];
69
+
70
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
71
+ code: 'BAD_REQUEST',
72
+ });
73
+ expect(mocks.create).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it('rejects a single name over the character limit', async () => {
77
+ const names = ['a'.repeat(SKILL_ICON_MAX_CHARACTERS_PER_REQUEST + 1)];
78
+
79
+ await expect(caller.generateSkillIcons({ names })).rejects.toMatchObject({
80
+ code: 'BAD_REQUEST',
81
+ });
82
+ });
83
+
84
+ it('still rejects empty lists and empty names', async () => {
85
+ await expect(caller.generateSkillIcons({ names: [] })).rejects.toMatchObject({
86
+ code: 'BAD_REQUEST',
87
+ });
88
+ await expect(caller.generateSkillIcons({ names: [''] })).rejects.toMatchObject({
89
+ code: 'BAD_REQUEST',
90
+ });
91
+ });
92
+ });