@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.
- package/.env.example +10 -0
- package/CHANGELOG.md +44 -0
- package/bundle.mjs +1 -1
- package/dist/app.d.ts +23 -1
- package/dist/config/cacheEnvironment.d.ts +4 -4
- package/dist/config/environment.d.ts +145 -114
- package/dist/config/oidcEnvironment.d.ts +22 -22
- package/dist/didkit_wasm_bg.wasm +0 -0
- package/dist/helpers/bedrockGeneration.d.ts +17 -0
- package/dist/helpers/bedrockGeneration.test.d.ts +1 -0
- package/dist/helpers/bedrockSchema.d.ts +10 -0
- package/dist/helpers/imageGeneration.d.ts +42 -0
- package/dist/helpers/imageGeneration.test.d.ts +1 -0
- package/dist/index.js +2 -2
- package/dist/models/Notification.d.ts +3 -1
- package/dist/publish-metafile.json +1 -1
- package/dist/routes/ai.skill-icons.test.d.ts +1 -0
- package/dist/routes/ai.test.d.ts +1 -0
- package/dist/routes/escrow.d.ts +19 -0
- package/dist/routes/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +3 -1
- package/dist/services/escrow-enclave/logFailure.d.ts +1 -0
- package/dist/services/escrow-enclave/logFailure.test.d.ts +1 -0
- package/dist/services/escrow-enclave/types.d.ts +17 -1
- package/dist/types/notifications.d.ts +3 -1
- package/docs/bedrock-generation.md +119 -0
- package/docs/image-generation.md +120 -0
- package/esbuildPlugins.cjs +9 -6
- package/package.json +21 -16
- package/scripts/node24-runtime.cjs +9 -0
- package/scripts/verify-node24-layer.cjs +49 -0
- package/serverless-local.yml +3 -1
- package/serverless.function-env.cjs +2 -4
- package/serverless.yml +76 -3
- package/src/config/environment.ts +14 -0
- package/src/helpers/bedrockGeneration.test.ts +222 -0
- package/src/helpers/bedrockGeneration.ts +110 -0
- package/src/helpers/bedrockSchema.ts +352 -0
- package/src/helpers/imageGeneration.test.ts +456 -0
- package/src/helpers/imageGeneration.ts +432 -0
- package/src/routes/ai.skill-icons.test.ts +92 -0
- package/src/routes/ai.test.ts +80 -0
- package/src/routes/ai.ts +33 -144
- package/src/routes/escrow.ts +15 -2
- package/src/routes/keys.ts +18 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
- package/src/services/escrow-enclave/logFailure.test.ts +72 -0
- package/src/services/escrow-enclave/logFailure.ts +46 -0
- package/src/services/escrow-enclave/remoteEnclave.test.ts +101 -3
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +192 -2
- package/src/services/escrow-enclave/remoteEnclave.ts +101 -36
- package/src/services/escrow-enclave/types.ts +26 -2
- package/test/keys-auth-share-escrow.spec.ts +103 -0
- package/test/keys-escrow.spec.ts +65 -0
- package/test/oidc.spec.ts +41 -2
- 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: '
|
|
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
|
-
-
|
|
121
|
+
- nodejs24.x
|
|
121
122
|
compatibleArchitectures:
|
|
122
123
|
- x86_64
|
|
123
124
|
|
|
124
125
|
provider:
|
|
125
126
|
name: aws
|
|
126
|
-
runtime:
|
|
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
|
+
};
|