@learncard/lca-api-service 1.5.2 → 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 (56) hide show
  1. package/.env.example +10 -0
  2. package/CHANGELOG.md +44 -0
  3. package/bundle.mjs +1 -1
  4. package/dist/app.d.ts +23 -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/ai.test.d.ts +1 -0
  19. package/dist/routes/escrow.d.ts +19 -0
  20. package/dist/routes/keys.d.ts +1 -0
  21. package/dist/routes/notifications.d.ts +3 -1
  22. package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
  23. package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
  24. package/dist/services/escrow-enclave/types.d.ts +17 -1
  25. package/dist/types/notifications.d.ts +3 -1
  26. package/docs/bedrock-generation.md +119 -0
  27. package/docs/image-generation.md +120 -0
  28. package/esbuildPlugins.cjs +9 -6
  29. package/package.json +21 -16
  30. package/scripts/node24-runtime.cjs +9 -0
  31. package/scripts/verify-node24-layer.cjs +49 -0
  32. package/serverless-local.yml +3 -1
  33. package/serverless.function-env.cjs +2 -4
  34. package/serverless.yml +76 -3
  35. package/src/config/environment.ts +14 -0
  36. package/src/helpers/bedrockGeneration.test.ts +222 -0
  37. package/src/helpers/bedrockGeneration.ts +110 -0
  38. package/src/helpers/bedrockSchema.ts +352 -0
  39. package/src/helpers/imageGeneration.test.ts +456 -0
  40. package/src/helpers/imageGeneration.ts +432 -0
  41. package/src/routes/ai.skill-icons.test.ts +92 -0
  42. package/src/routes/ai.test.ts +80 -0
  43. package/src/routes/ai.ts +33 -144
  44. package/src/routes/escrow.ts +15 -2
  45. package/src/routes/keys.ts +18 -0
  46. package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
  47. package/src/services/escrow-enclave/logFailure.test.ts +72 -0
  48. package/src/services/escrow-enclave/logFailure.ts +46 -0
  49. package/src/services/escrow-enclave/remoteEnclave.test.ts +101 -3
  50. package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +192 -2
  51. package/src/services/escrow-enclave/remoteEnclave.ts +101 -36
  52. package/src/services/escrow-enclave/types.ts +26 -2
  53. package/test/keys-auth-share-escrow.spec.ts +103 -0
  54. package/test/keys-escrow.spec.ts +65 -0
  55. package/test/oidc.spec.ts +41 -2
  56. package/tsconfig.bedrock.json +10 -0
package/serverless.yml CHANGED
@@ -7,6 +7,7 @@ frameworkVersion: '3'
7
7
  useDotenv: true
8
8
 
9
9
  plugins:
10
+ - ./scripts/node24-runtime.cjs
10
11
  - serverless-esbuild
11
12
  - serverless-domain-manager
12
13
  - serverless-certificate-creator
@@ -47,7 +48,7 @@ custom:
47
48
  # serverless-esbuild default and must be preserved when overriding.
48
49
  exclude: ['aws-sdk', '@learncard/didkit-plugin-node']
49
50
  sourcemap: external
50
- target: 'node20'
51
+ target: 'node24'
51
52
  watch:
52
53
  pattern: ['src/**/*.ts', 'dids.ts']
53
54
 
@@ -117,13 +118,13 @@ layers:
117
118
  name: ${self:service}-${opt:stage, 'dev'}-didkit-native
118
119
  description: Native DIDKit napi addon and runtime DIDKit dependency
119
120
  compatibleRuntimes:
120
- - nodejs20.x
121
+ - nodejs24.x
121
122
  compatibleArchitectures:
122
123
  - x86_64
123
124
 
124
125
  provider:
125
126
  name: aws
126
- runtime: nodejs20.x
127
+ runtime: nodejs24.x
127
128
  # Attach the native DIDKit layer to every function in this service.
128
129
  layers:
129
130
  - { Ref: DidkitNativeLambdaLayer }
@@ -272,6 +273,14 @@ resources:
272
273
  # ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME (the enclave account's endpoint
273
274
  # service). TLS trust for the hostname is pinned in enclaveTrustAnchors.ts.
274
275
  Conditions:
276
+ LcaDevBedrockGeneration:
277
+ Fn::And:
278
+ - Fn::Equals:
279
+ - ${sls:stage}
280
+ - dev
281
+ - Fn::Equals:
282
+ - Ref: AWS::Region
283
+ - us-east-1
275
284
  EscrowEnclavePrivateLink:
276
285
  Fn::Not:
277
286
  - Fn::Equals: ['${env:ESCROW_ENCLAVE_ENDPOINT_SERVICE_NAME, ""}', '']
@@ -283,6 +292,70 @@ resources:
283
292
  Value:
284
293
  Ref: SeedMigrationLambdaFunction
285
294
  Resources:
295
+ LcaDevBedrockGenerationPolicy:
296
+ Type: AWS::IAM::Policy
297
+ Condition: LcaDevBedrockGeneration
298
+ Properties:
299
+ PolicyName: lca-api-dev-bedrock-generation
300
+ Roles:
301
+ - Ref: SigningAuthorityExecutionRole
302
+ PolicyDocument:
303
+ Version: '2012-10-17'
304
+ Statement:
305
+ - Sid: ShortTermBearerOnly
306
+ Effect: Allow
307
+ Action: bedrock:CallWithBearerToken
308
+ Resource: '*'
309
+ Condition:
310
+ StringEquals:
311
+ bedrock:bearerTokenType: SHORT_TERM
312
+ - Sid: ApprovedProfiles
313
+ Effect: Allow
314
+ Action:
315
+ - bedrock:GetInferenceProfile
316
+ - bedrock:InvokeModel
317
+ Resource:
318
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-1:${AWS::AccountId}:inference-profile/us.openai.gpt-6.1-sol
319
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-1:${AWS::AccountId}:inference-profile/us.openai.gpt-6-luna
320
+ - Sid: DefaultProjectForApprovedProfiles
321
+ Effect: Allow
322
+ Action: bedrock:InvokeModel
323
+ Resource:
324
+ Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-1:${AWS::AccountId}:project/default
325
+ Condition:
326
+ StringEquals:
327
+ bedrock:ModelArn:
328
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-1:${AWS::AccountId}:inference-profile/us.openai.gpt-6.1-sol
329
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-1:${AWS::AccountId}:inference-profile/us.openai.gpt-6-luna
330
+ - Sid: SolBackingModelsThroughSolProfile
331
+ Effect: Allow
332
+ Action: bedrock:InvokeModel
333
+ Resource:
334
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-1::foundation-model/openai.gpt-6.1-sol
335
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-2::foundation-model/openai.gpt-6.1-sol
336
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-west-2::foundation-model/openai.gpt-6.1-sol
337
+ Condition:
338
+ StringEquals:
339
+ bedrock:InferenceProfileArn:
340
+ Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-1:${AWS::AccountId}:inference-profile/us.openai.gpt-6.1-sol
341
+ - Sid: LunaBackingModelsThroughLunaProfile
342
+ Effect: Allow
343
+ Action: bedrock:InvokeModel
344
+ Resource:
345
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-1::foundation-model/openai.gpt-6-luna
346
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-2::foundation-model/openai.gpt-6-luna
347
+ - Fn::Sub: arn:${AWS::Partition}:bedrock:us-west-2::foundation-model/openai.gpt-6-luna
348
+ Condition:
349
+ StringEquals:
350
+ bedrock:InferenceProfileArn:
351
+ Fn::Sub: arn:${AWS::Partition}:bedrock:us-east-1:${AWS::AccountId}:inference-profile/us.openai.gpt-6-luna
352
+ # generateImage default provider (docs/image-generation.md): one model,
353
+ # one region, non-streaming InvokeModel only.
354
+ - Sid: StableImageCoreUsWest2
355
+ Effect: Allow
356
+ Action: bedrock:InvokeModel
357
+ Resource:
358
+ Fn::Sub: arn:${AWS::Partition}:bedrock:us-west-2::foundation-model/stability.stable-image-core-v1:1
286
359
  # Only the two signing entry points and the migration worker assume this role.
287
360
  # Keep KMS references out of the role itself to avoid a Role -> Key -> Role cycle.
288
361
  SigningAuthorityExecutionRole:
@@ -31,6 +31,20 @@ export const lcaApiEnvironmentShape = {
31
31
  NETWORK_BRAIN_SERVICE_URL: optionalEnvironmentUrl,
32
32
  GOOGLE_APPLICATION_CREDENTIAL: optionalEnvironmentString,
33
33
  OPENAI_API_KEY: optionalEnvironmentString,
34
+ BEDROCK_BASE_URL: optionalEnvironmentString,
35
+ IMAGE_PROVIDER: optionalEnvironmentString
36
+ .transform(value => value ?? 'bedrock')
37
+ .pipe(z.enum(['openai', 'bedrock'])),
38
+ BEDROCK_IMAGE_REGION: optionalEnvironmentString
39
+ .transform(value => value ?? 'us-west-2')
40
+ .pipe(z.literal('us-west-2')),
41
+ BEDROCK_IMAGE_MODEL_ID: optionalEnvironmentString
42
+ .transform(value => value ?? 'stability.stable-image-core-v1:1')
43
+ .pipe(z.literal('stability.stable-image-core-v1:1')),
44
+ // Must finish inside the 29-second Lambda timeout (same 25-second ceiling as text generation).
45
+ IMAGE_TIMEOUT_MS: optionalEnvironmentString
46
+ .transform(value => (value === undefined ? 25_000 : Number(value)))
47
+ .pipe(z.number().int().min(1_000).max(25_000)),
34
48
  METABASE_SECRET_KEY: optionalEnvironmentString,
35
49
  SCOUTS_SSO_CLIENT_SECRET: optionalEnvironmentString,
36
50
  KEYCLOAK_ISSUERS: optionalEnvironmentString,
@@ -0,0 +1,222 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { z } from 'zod';
3
+ import OpenAI from 'openai';
4
+ import {
5
+ BEDROCK_BASE_URL,
6
+ createBedrockClient,
7
+ generateStructuredOutput,
8
+ GENERATION_MODELS,
9
+ } from './bedrockGeneration';
10
+ import { compileProjectedSchema } from './bedrockSchema';
11
+ import { BoostSkillHierarchyValidator } from '../types/skills';
12
+
13
+ const schema = z.object({ title: z.string().min(1).max(8), optional: z.string().optional() });
14
+ const completed = (argumentsText = '{"title":"Hello"}'): Record<string, unknown> => ({
15
+ id: 'resp_synthetic',
16
+ object: 'response',
17
+ status: 'completed',
18
+ output: [
19
+ {
20
+ type: 'function_call',
21
+ name: 'result',
22
+ call_id: 'call_synthetic',
23
+ status: 'completed',
24
+ arguments: argumentsText,
25
+ },
26
+ ],
27
+ });
28
+ const harness = (result: unknown = completed(), status = 200) => {
29
+ const fetch = vi.fn<typeof globalThis.fetch>(
30
+ async () =>
31
+ new Response(JSON.stringify(result), {
32
+ status,
33
+ headers: { 'content-type': 'application/json' },
34
+ })
35
+ );
36
+ const provideToken = vi.fn(async () => 'synthetic-token');
37
+ const factory = vi.fn(() => provideToken);
38
+ const client = createBedrockClient(undefined, factory, fetch);
39
+ const run = (task: 'reasoning' | 'formatting' = 'reasoning') =>
40
+ generateStructuredOutput(client, {
41
+ task,
42
+ instructions: 'Fixed instruction',
43
+ input: 'Synthetic learner description',
44
+ schema,
45
+ });
46
+ return { client, fetch, provideToken, factory, run };
47
+ };
48
+ afterEach(() => vi.unstubAllEnvs());
49
+
50
+ describe('Bedrock Responses real SDK contract (offline)', () => {
51
+ it('retains SDK compatibility for the separate first-party DALL-E image request', async () => {
52
+ const fetch = vi.fn<typeof globalThis.fetch>(
53
+ async () =>
54
+ new Response(
55
+ JSON.stringify({ data: [{ url: 'https://example.com/synthetic.png' }] }),
56
+ { headers: { 'content-type': 'application/json' } }
57
+ )
58
+ );
59
+ const imageClient = new OpenAI({ apiKey: 'synthetic-image-key', fetch });
60
+ const result = await imageClient.images.generate({
61
+ prompt: 'Synthetic image',
62
+ model: 'dall-e-3',
63
+ size: '1024x1024',
64
+ user: 'did:synthetic',
65
+ });
66
+ expect(result.data?.[0]?.url).toBe('https://example.com/synthetic.png');
67
+ expect(String(fetch.mock.calls[0]![0])).toBe(
68
+ 'https://api.openai.com/v1/images/generations'
69
+ );
70
+ expect(JSON.parse(fetch.mock.calls[0]![1]!.body as string)).toMatchObject({
71
+ model: 'dall-e-3',
72
+ size: '1024x1024',
73
+ user: 'did:synthetic',
74
+ });
75
+ });
76
+ it.each(['reasoning', 'formatting'] as const)(
77
+ 'uses approved %s profile and request fields',
78
+ async task => {
79
+ vi.stubEnv('OPENAI_ORG_ID', 'ambient-organization');
80
+ vi.stubEnv('OPENAI_PROJECT_ID', 'ambient-project');
81
+ const h = harness();
82
+ expect(await h.run(task)).toEqual({ title: 'Hello' });
83
+ const [url, init] = h.fetch.mock.calls[0]!;
84
+ expect(String(url)).toBe(`${BEDROCK_BASE_URL}/responses`);
85
+ expect(init?.redirect).toBe('error');
86
+ const headers = new Headers(init?.headers);
87
+ expect(headers.get('authorization')).toBe('Bearer synthetic-token');
88
+ expect(headers.has('openai-organization')).toBe(false);
89
+ expect(headers.has('openai-project')).toBe(false);
90
+ const body = JSON.parse(init!.body as string);
91
+ expect(body).toMatchObject({
92
+ model: GENERATION_MODELS[task],
93
+ reasoning: { effort: task === 'reasoning' ? 'medium' : 'none' },
94
+ store: false,
95
+ stream: false,
96
+ max_output_tokens: 8192,
97
+ tool_choice: 'required',
98
+ parallel_tool_calls: false,
99
+ tools: [{ name: 'result', type: 'function', strict: false }],
100
+ });
101
+ expect(body).not.toHaveProperty('response_format');
102
+ expect(body).not.toHaveProperty('temperature');
103
+ expect(body).not.toHaveProperty('user');
104
+ expect(h.client.timeout).toBeDefined();
105
+ expect(h.factory).toHaveBeenCalledWith({ region: 'us-east-1' });
106
+ }
107
+ );
108
+ it('resolves the token on each request rather than pinning a bearer for process life', async () => {
109
+ const h = harness();
110
+ await h.run();
111
+ await h.run();
112
+ expect(h.provideToken).toHaveBeenCalledTimes(2);
113
+ });
114
+ it('rejects alternate endpoints before generating tokens', () => {
115
+ const factory = vi.fn();
116
+ expect(() => createBedrockClient('https://example.com/openai/v1', factory)).toThrow(
117
+ 'Unapproved'
118
+ );
119
+ expect(factory).not.toHaveBeenCalled();
120
+ expect(
121
+ createBedrockClient(`${BEDROCK_BASE_URL}/`, () => async () => 'synthetic')
122
+ ).toBeDefined();
123
+ });
124
+ it('removes SDK credential error causes and never sends a failed credential', async () => {
125
+ const fetch = vi.fn();
126
+ const client = createBedrockClient(
127
+ undefined,
128
+ () => async () => {
129
+ throw new Error('synthetic-sensitive-credential');
130
+ },
131
+ fetch
132
+ );
133
+ await expect(
134
+ generateStructuredOutput(client, {
135
+ task: 'reasoning',
136
+ instructions: 'test',
137
+ input: 'test',
138
+ schema,
139
+ })
140
+ ).rejects.toMatchObject({ message: 'AI generation unavailable', cause: undefined });
141
+ expect(fetch).not.toHaveBeenCalled();
142
+ });
143
+ it.each([429, 500, 503, 401])('sanitizes HTTP %s without retries or fallback', async status => {
144
+ const h = harness({ error: { message: 'synthetic-private-body', type: 'error' } }, status);
145
+ await expect(h.run()).rejects.toMatchObject({
146
+ code:
147
+ status === 429
148
+ ? 'TOO_MANY_REQUESTS'
149
+ : status === 503
150
+ ? 'SERVICE_UNAVAILABLE'
151
+ : 'INTERNAL_SERVER_ERROR',
152
+ cause: undefined,
153
+ });
154
+ expect(h.fetch).toHaveBeenCalledTimes(1);
155
+ });
156
+ it.each([
157
+ {
158
+ ...completed(),
159
+ status: 'incomplete',
160
+ incomplete_details: { reason: 'max_output_tokens' },
161
+ },
162
+ { ...completed(), status: 'failed', error: { code: 'error', message: 'private' } },
163
+ { ...completed(), output: [] },
164
+ {
165
+ ...completed(),
166
+ output: [...(completed().output as unknown[]), ...(completed().output as unknown[])],
167
+ },
168
+ {
169
+ ...completed(),
170
+ output: [{ type: 'function_call', name: 'wrong', call_id: 'call', arguments: '{}' }],
171
+ },
172
+ { ...completed(), output: [{ type: 'function_call', name: 'result', arguments: '{}' }] },
173
+ {
174
+ ...completed(),
175
+ output: [
176
+ {
177
+ type: 'function_call',
178
+ name: 'result',
179
+ call_id: 'call',
180
+ status: 'incomplete',
181
+ arguments: '{}',
182
+ },
183
+ ],
184
+ },
185
+ {
186
+ ...completed(),
187
+ output: [
188
+ ...(completed().output as unknown[]),
189
+ { type: 'message', content: [{ type: 'refusal', refusal: 'private' }] },
190
+ ],
191
+ },
192
+ completed('```json\n{"title":"Hello"}\n```'),
193
+ completed('{"title":"Too long to pass local validation"}'),
194
+ completed('{"title":7}'),
195
+ ])('rejects incomplete, malformed, refused or invalid structured output %#', async result => {
196
+ const h = harness(result);
197
+ await expect(h.run()).rejects.toMatchObject({
198
+ message: 'AI generation unavailable',
199
+ cause: undefined,
200
+ });
201
+ expect(h.fetch).toHaveBeenCalledTimes(1);
202
+ });
203
+ it('compiles the entire existing skills hierarchy preserving optional categories', () => {
204
+ const compiled = compileProjectedSchema(BoostSkillHierarchyValidator);
205
+ expect(compiled.type).toBe('object');
206
+ expect(compiled.required).toBeUndefined();
207
+ expect(compiled.properties).toHaveProperty('medical');
208
+ expect(BoostSkillHierarchyValidator.parse({})).toEqual({});
209
+ });
210
+ it('rejects unsupported schemas before egress', async () => {
211
+ const h = harness();
212
+ await expect(
213
+ generateStructuredOutput(h.client, {
214
+ task: 'formatting',
215
+ instructions: 'test',
216
+ input: 'test',
217
+ schema: z.record(z.string(), z.string()),
218
+ })
219
+ ).rejects.toThrow('Unsupported Bedrock schema');
220
+ expect(h.fetch).not.toHaveBeenCalled();
221
+ });
222
+ });
@@ -0,0 +1,110 @@
1
+ import OpenAI from 'openai';
2
+ import { getTokenProvider } from '@aws/bedrock-token-generator';
3
+ import { TRPCError } from '@trpc/server';
4
+ import { z } from 'zod';
5
+ import { compileProjectedSchema } from './bedrockSchema';
6
+
7
+ // Exact profiles and Responses protocol from AI Passport LC-2205 (#92).
8
+ export const BEDROCK_BASE_URL = 'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1';
9
+ export const GENERATION_MODELS = {
10
+ reasoning: 'us.openai.gpt-6.1-sol',
11
+ formatting: 'us.openai.gpt-6-luna',
12
+ } as const;
13
+ type GenerationTask = keyof typeof GENERATION_MODELS;
14
+
15
+ /** Lambda uses its execution role; the official generator refreshes short-term tokens. */
16
+ export const createBedrockClient = (
17
+ baseURL = BEDROCK_BASE_URL,
18
+ tokenFactory: typeof getTokenProvider = getTokenProvider,
19
+ fetch: typeof globalThis.fetch = globalThis.fetch
20
+ ): OpenAI => {
21
+ if (baseURL !== BEDROCK_BASE_URL && baseURL !== `${BEDROCK_BASE_URL}/`) {
22
+ throw new Error('Unapproved BEDROCK_BASE_URL for generation');
23
+ }
24
+ const provideToken = tokenFactory({ region: 'us-east-1' });
25
+ return new OpenAI({
26
+ baseURL: BEDROCK_BASE_URL,
27
+ organization: null,
28
+ project: null,
29
+ maxRetries: 0,
30
+ logLevel: 'off',
31
+ fetch: (input, init) => fetch(input, { ...init, redirect: 'error' }),
32
+ apiKey: async () => {
33
+ try {
34
+ const token = await provideToken();
35
+ if (!token?.trim()) throw new Error();
36
+ return token;
37
+ } catch {
38
+ // Never retain credential errors, JWTs, presigned URLs or raw provider bodies.
39
+ throw new Error('Bedrock credentials unavailable');
40
+ }
41
+ },
42
+ });
43
+ };
44
+
45
+ /** Non-streaming required-function output, with the original Zod validator authoritative. */
46
+ export const generateStructuredOutput = async <Schema extends z.ZodType>(
47
+ client: OpenAI,
48
+ params: {
49
+ task: GenerationTask;
50
+ instructions: string;
51
+ input: string;
52
+ schema: Schema;
53
+ }
54
+ ): Promise<z.output<Schema>> => {
55
+ // Schema incompatibility fails locally before any request.
56
+ const parameters = compileProjectedSchema(params.schema);
57
+ try {
58
+ const response = await client.responses.create(
59
+ {
60
+ model: GENERATION_MODELS[params.task],
61
+ reasoning: { effort: params.task === 'reasoning' ? 'medium' : 'none' },
62
+ instructions: params.instructions,
63
+ input: [{ role: 'user', content: params.input }],
64
+ store: false,
65
+ stream: false,
66
+ // Includes reasoning tokens. Incomplete/truncated output never becomes success.
67
+ max_output_tokens: 8192,
68
+ tools: [
69
+ {
70
+ type: 'function',
71
+ name: 'result',
72
+ description: 'Return the requested structured result',
73
+ parameters,
74
+ strict: false,
75
+ },
76
+ ],
77
+ tool_choice: 'required',
78
+ parallel_tool_calls: false,
79
+ },
80
+ { timeout: 25_000 }
81
+ );
82
+ if (response.status !== 'completed' || !Array.isArray(response.output)) throw new Error();
83
+ const calls = response.output.filter(item => item.type === 'function_call');
84
+ if (
85
+ calls.length !== 1 ||
86
+ calls[0]!.name !== 'result' ||
87
+ !calls[0]!.call_id ||
88
+ calls[0]!.status === 'incomplete' ||
89
+ calls[0]!.status === 'in_progress' ||
90
+ response.output.some(
91
+ item =>
92
+ item.type === 'message' && item.content.some(part => part.type === 'refusal')
93
+ )
94
+ )
95
+ throw new Error();
96
+ return await params.schema.parseAsync(JSON.parse(calls[0]!.arguments));
97
+ } catch (error) {
98
+ // Preserve actionable status without exposing SDK response bodies or validation values.
99
+ const status = error instanceof OpenAI.APIError ? error.status : undefined;
100
+ throw new TRPCError({
101
+ code:
102
+ status === 429
103
+ ? 'TOO_MANY_REQUESTS'
104
+ : status === 503 || status === 504
105
+ ? 'SERVICE_UNAVAILABLE'
106
+ : 'INTERNAL_SERVER_ERROR',
107
+ message: status === 429 ? 'AI generation rate limited' : 'AI generation unavailable',
108
+ });
109
+ }
110
+ };