@learncard/lca-api-service 1.5.3 → 1.6.1
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 +40 -0
- package/bundle.mjs +1 -1
- package/dist/app.d.ts +4 -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/keys.d.ts +1 -0
- package/dist/routes/notifications.d.ts +3 -1
- package/dist/types/notifications.d.ts +3 -1
- package/docs/bedrock-generation.md +119 -0
- package/docs/image-generation.md +120 -0
- package/package.json +21 -16
- package/project.json +1 -1
- 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/models/EscrowHold.ts +6 -3
- package/src/models/UserKey.ts +14 -0
- package/src/routes/ai.skill-icons.test.ts +92 -0
- package/src/routes/ai.test.ts +68 -91
- package/src/routes/ai.ts +31 -143
- package/src/routes/keys.ts +18 -0
- package/src/services/escrow-enclave/enclaveTrustAnchors.ts +19 -0
- package/src/services/escrow-enclave/logFailure.test.ts +8 -0
- package/src/services/escrow-enclave/logFailure.ts +17 -0
- package/src/services/escrow-enclave/remoteEnclave.tls.test.ts +10 -0
- package/test/jobs/escrowHoldReminders.spec.ts +88 -4
- package/test/keys-auth-share-escrow.spec.ts +103 -0
- package/test/keys-lifecycle-invariants.spec.ts +76 -0
- package/test/oidc.spec.ts +41 -2
- package/tsconfig.bedrock.json +10 -0
- package/tsconfig.json +13 -14
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
// Reviewed subset: https://docs.aws.amazon.com/bedrock/latest/userguide/structured-output.html
|
|
4
|
+
const basicTypes: Record<string, true> = {
|
|
5
|
+
object: true,
|
|
6
|
+
array: true,
|
|
7
|
+
string: true,
|
|
8
|
+
integer: true,
|
|
9
|
+
number: true,
|
|
10
|
+
boolean: true,
|
|
11
|
+
null: true,
|
|
12
|
+
};
|
|
13
|
+
const formats: Record<string, true> = {
|
|
14
|
+
'date-time': true,
|
|
15
|
+
time: true,
|
|
16
|
+
date: true,
|
|
17
|
+
duration: true,
|
|
18
|
+
email: true,
|
|
19
|
+
hostname: true,
|
|
20
|
+
uri: true,
|
|
21
|
+
ipv4: true,
|
|
22
|
+
ipv6: true,
|
|
23
|
+
uuid: true,
|
|
24
|
+
};
|
|
25
|
+
const keywords: Record<string, true> = {
|
|
26
|
+
$schema: true,
|
|
27
|
+
$defs: true,
|
|
28
|
+
$def: true,
|
|
29
|
+
definitions: true,
|
|
30
|
+
$ref: true,
|
|
31
|
+
type: true,
|
|
32
|
+
properties: true,
|
|
33
|
+
required: true,
|
|
34
|
+
additionalProperties: true,
|
|
35
|
+
items: true,
|
|
36
|
+
minItems: true,
|
|
37
|
+
enum: true,
|
|
38
|
+
const: true,
|
|
39
|
+
anyOf: true,
|
|
40
|
+
allOf: true,
|
|
41
|
+
format: true,
|
|
42
|
+
title: true,
|
|
43
|
+
description: true,
|
|
44
|
+
};
|
|
45
|
+
const representableZodTypes: Record<string, true> = {
|
|
46
|
+
string: true,
|
|
47
|
+
number: true,
|
|
48
|
+
boolean: true,
|
|
49
|
+
null: true,
|
|
50
|
+
object: true,
|
|
51
|
+
array: true,
|
|
52
|
+
union: true,
|
|
53
|
+
intersection: true,
|
|
54
|
+
enum: true,
|
|
55
|
+
literal: true,
|
|
56
|
+
nullable: true,
|
|
57
|
+
optional: true,
|
|
58
|
+
nonoptional: true,
|
|
59
|
+
pipe: true,
|
|
60
|
+
readonly: true,
|
|
61
|
+
lazy: true,
|
|
62
|
+
transform: true,
|
|
63
|
+
};
|
|
64
|
+
const projectedZodChecks: Record<string, true> = {
|
|
65
|
+
custom: true,
|
|
66
|
+
less_than: true,
|
|
67
|
+
greater_than: true,
|
|
68
|
+
multiple_of: true,
|
|
69
|
+
number_format: true,
|
|
70
|
+
min_length: true,
|
|
71
|
+
max_length: true,
|
|
72
|
+
length_equals: true,
|
|
73
|
+
string_format: true,
|
|
74
|
+
overwrite: true,
|
|
75
|
+
};
|
|
76
|
+
const omittedProviderConstraints = [
|
|
77
|
+
'minimum',
|
|
78
|
+
'maximum',
|
|
79
|
+
'exclusiveMinimum',
|
|
80
|
+
'exclusiveMaximum',
|
|
81
|
+
'multipleOf',
|
|
82
|
+
'minLength',
|
|
83
|
+
'maxLength',
|
|
84
|
+
'pattern',
|
|
85
|
+
'maxItems',
|
|
86
|
+
] as const;
|
|
87
|
+
|
|
88
|
+
export class BedrockSchemaError extends Error {
|
|
89
|
+
readonly code = 'unsupported_bedrock_schema';
|
|
90
|
+
constructor(
|
|
91
|
+
readonly path: string,
|
|
92
|
+
readonly reason: string
|
|
93
|
+
) {
|
|
94
|
+
super(`Unsupported Bedrock schema at ${path}: ${reason}`);
|
|
95
|
+
this.name = 'BedrockSchemaError';
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function fail(path: string, reason: string): never {
|
|
99
|
+
throw new BedrockSchemaError(path, reason);
|
|
100
|
+
}
|
|
101
|
+
const record = (value: unknown): value is Record<string, unknown> =>
|
|
102
|
+
value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
103
|
+
const segment = (key: string): string => key.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
104
|
+
const scalar = (value: unknown): boolean =>
|
|
105
|
+
value === null ||
|
|
106
|
+
typeof value === 'string' ||
|
|
107
|
+
typeof value === 'boolean' ||
|
|
108
|
+
(typeof value === 'number' && Number.isFinite(value));
|
|
109
|
+
|
|
110
|
+
/** Validates syntax and the reviewed nonrecursive Bedrock subset, before any egress. */
|
|
111
|
+
export function validateBedrockJsonSchema(schema: unknown): void {
|
|
112
|
+
if (!record(schema)) fail('#', 'schema must be an object');
|
|
113
|
+
const root = schema as Record<string, unknown>;
|
|
114
|
+
const active = new Set<object>();
|
|
115
|
+
const visited = new Set<object>();
|
|
116
|
+
const resolve = (ref: unknown, path: string): Record<string, unknown> => {
|
|
117
|
+
if (typeof ref !== 'string' || !ref.startsWith('#/'))
|
|
118
|
+
fail(path, 'only internal JSON Pointer references are supported');
|
|
119
|
+
let target: unknown = root;
|
|
120
|
+
for (const part of (ref as string).slice(2).split('/')) {
|
|
121
|
+
if (/~(?![01])/u.test(part)) fail(path, 'invalid JSON Pointer escape');
|
|
122
|
+
const key = part.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
123
|
+
if (!record(target) || !Object.hasOwn(target, key))
|
|
124
|
+
fail(path, 'unresolved internal reference');
|
|
125
|
+
target = (target as Record<string, unknown>)[key];
|
|
126
|
+
}
|
|
127
|
+
if (!record(target)) fail(path, 'reference target must be a schema object');
|
|
128
|
+
return target as Record<string, unknown>;
|
|
129
|
+
};
|
|
130
|
+
const visit = (value: unknown, path: string): void => {
|
|
131
|
+
if (!record(value)) fail(path, 'schema must be an object');
|
|
132
|
+
const node = value as Record<string, unknown>;
|
|
133
|
+
if (active.has(node)) fail(path, 'recursive schemas are not supported');
|
|
134
|
+
if (visited.has(node)) return;
|
|
135
|
+
active.add(node);
|
|
136
|
+
for (const key of Object.keys(node)) {
|
|
137
|
+
if (!Object.hasOwn(keywords, key))
|
|
138
|
+
fail(`${path}/${segment(key)}`, 'keyword is not supported');
|
|
139
|
+
}
|
|
140
|
+
for (const key of ['$schema', 'title', 'description']) {
|
|
141
|
+
if (key in node && typeof node[key] !== 'string')
|
|
142
|
+
fail(`${path}/${key}`, 'metadata must be a string');
|
|
143
|
+
}
|
|
144
|
+
if ('$schema' in node && node.$schema !== 'https://json-schema.org/draft/2020-12/schema')
|
|
145
|
+
fail(`${path}/$schema`, 'only Draft 2020-12 is supported');
|
|
146
|
+
for (const key of ['$defs', '$def', 'definitions']) {
|
|
147
|
+
if (!(key in node)) continue;
|
|
148
|
+
if (!record(node[key])) fail(`${path}/${key}`, 'definitions must be an object');
|
|
149
|
+
for (const [name, child] of Object.entries(node[key] as Record<string, unknown>))
|
|
150
|
+
visit(child, `${path}/${key}/${segment(name)}`);
|
|
151
|
+
}
|
|
152
|
+
if ('$ref' in node) {
|
|
153
|
+
if (
|
|
154
|
+
Object.keys(node).some(
|
|
155
|
+
key =>
|
|
156
|
+
![
|
|
157
|
+
'$ref',
|
|
158
|
+
'$schema',
|
|
159
|
+
'$defs',
|
|
160
|
+
'$def',
|
|
161
|
+
'definitions',
|
|
162
|
+
'description',
|
|
163
|
+
'title',
|
|
164
|
+
].includes(key)
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
fail(`${path}/$ref`, 'reference siblings cannot add constraints');
|
|
168
|
+
visit(resolve(node.$ref, `${path}/$ref`), `${path}/$ref`);
|
|
169
|
+
}
|
|
170
|
+
const types = Array.isArray(node.type) ? node.type : 'type' in node ? [node.type] : [];
|
|
171
|
+
if (
|
|
172
|
+
'type' in node &&
|
|
173
|
+
(!types.length ||
|
|
174
|
+
types.some(type => typeof type !== 'string' || !Object.hasOwn(basicTypes, type)) ||
|
|
175
|
+
new Set(types).size !== types.length)
|
|
176
|
+
)
|
|
177
|
+
fail(`${path}/type`, 'invalid basic type');
|
|
178
|
+
if (!types.length && !['$ref', 'enum', 'const', 'anyOf', 'allOf'].some(key => key in node))
|
|
179
|
+
fail(path, 'unconstrained schemas are not supported');
|
|
180
|
+
if (
|
|
181
|
+
'enum' in node &&
|
|
182
|
+
(!Array.isArray(node.enum) ||
|
|
183
|
+
!node.enum.length ||
|
|
184
|
+
node.enum.some(item => !scalar(item)))
|
|
185
|
+
)
|
|
186
|
+
fail(`${path}/enum`, 'enum must contain scalar JSON values');
|
|
187
|
+
if ('const' in node && !scalar(node.const))
|
|
188
|
+
fail(`${path}/const`, 'const must be a scalar JSON value');
|
|
189
|
+
if (
|
|
190
|
+
'format' in node &&
|
|
191
|
+
(!types.includes('string') ||
|
|
192
|
+
typeof node.format !== 'string' ||
|
|
193
|
+
!Object.hasOwn(formats, node.format))
|
|
194
|
+
)
|
|
195
|
+
fail(`${path}/format`, 'string format is not supported');
|
|
196
|
+
if (types.includes('object')) {
|
|
197
|
+
if (node.additionalProperties !== false)
|
|
198
|
+
fail(`${path}/additionalProperties`, 'objects must reject additional properties');
|
|
199
|
+
if (!record(node.properties))
|
|
200
|
+
fail(`${path}/properties`, 'object properties must be an object');
|
|
201
|
+
for (const [key, child] of Object.entries(node.properties as Record<string, unknown>))
|
|
202
|
+
visit(child, `${path}/properties/${segment(key)}`);
|
|
203
|
+
if (
|
|
204
|
+
'required' in node &&
|
|
205
|
+
(!Array.isArray(node.required) ||
|
|
206
|
+
new Set(node.required).size !== node.required.length ||
|
|
207
|
+
node.required.some(
|
|
208
|
+
key =>
|
|
209
|
+
typeof key !== 'string' ||
|
|
210
|
+
!Object.hasOwn(node.properties as object, key)
|
|
211
|
+
))
|
|
212
|
+
)
|
|
213
|
+
fail(`${path}/required`, 'required must name distinct declared properties');
|
|
214
|
+
} else if (['properties', 'required', 'additionalProperties'].some(key => key in node))
|
|
215
|
+
fail(path, 'object keywords require object type');
|
|
216
|
+
if (types.includes('array')) {
|
|
217
|
+
visit(node.items, `${path}/items`);
|
|
218
|
+
if ('minItems' in node && node.minItems !== 0 && node.minItems !== 1)
|
|
219
|
+
fail(`${path}/minItems`, 'only minItems 0 or 1 is supported');
|
|
220
|
+
} else if (['items', 'minItems'].some(key => key in node))
|
|
221
|
+
fail(path, 'array keywords require array type');
|
|
222
|
+
for (const key of ['anyOf', 'allOf']) {
|
|
223
|
+
if (!(key in node)) continue;
|
|
224
|
+
if (!Array.isArray(node[key]) || !(node[key] as unknown[]).length)
|
|
225
|
+
fail(`${path}/${key}`, 'composition must contain schema branches');
|
|
226
|
+
for (const [index, child] of (node[key] as unknown[]).entries()) {
|
|
227
|
+
visit(child, `${path}/${key}/${index}`);
|
|
228
|
+
// Bedrock documents allOf with limitations; only same-type scalar composition is reviewed.
|
|
229
|
+
if (key === 'allOf') {
|
|
230
|
+
const branch =
|
|
231
|
+
record(child) && '$ref' in child
|
|
232
|
+
? resolve(child.$ref, `${path}/${key}/${index}`)
|
|
233
|
+
: child;
|
|
234
|
+
if (
|
|
235
|
+
!record(branch) ||
|
|
236
|
+
typeof branch.type !== 'string' ||
|
|
237
|
+
['object', 'array'].includes(branch.type)
|
|
238
|
+
)
|
|
239
|
+
fail(
|
|
240
|
+
`${path}/${key}/${index}`,
|
|
241
|
+
'allOf is limited to explicitly typed scalar branches'
|
|
242
|
+
);
|
|
243
|
+
const first = (node[key] as Record<string, unknown>[])[0]!;
|
|
244
|
+
const firstType =
|
|
245
|
+
'$ref' in first ? resolve(first.$ref, `${path}/${key}/0`).type : first.type;
|
|
246
|
+
if (branch.type !== firstType)
|
|
247
|
+
fail(
|
|
248
|
+
`${path}/${key}/${index}`,
|
|
249
|
+
'allOf branches must have the same scalar type'
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
active.delete(node);
|
|
255
|
+
visited.add(node);
|
|
256
|
+
};
|
|
257
|
+
visit(root, '#');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Inspect structural input domains before exporting. Nonportable refinements
|
|
261
|
+
// remain authoritative in the unchanged original Zod parser after decoding.
|
|
262
|
+
function inspectZod(schema: z.ZodType, path = '#', active = new Set<z.ZodType>()): void {
|
|
263
|
+
if (active.has(schema)) fail(path, 'recursive schemas are not supported');
|
|
264
|
+
active.add(schema);
|
|
265
|
+
const def = schema._zod.def as z.core.$ZodTypes['_zod']['def'];
|
|
266
|
+
if (def.type === 'custom') fail(path, 'custom schemas have no representable input domain');
|
|
267
|
+
if (def.type === 'pipe' && def.in._zod.def.type === 'transform')
|
|
268
|
+
fail(path, 'preprocessing has no representable input domain');
|
|
269
|
+
if (!Object.hasOwn(representableZodTypes, def.type))
|
|
270
|
+
fail(path, 'Zod input domain cannot be represented as JSON Schema');
|
|
271
|
+
if ('coerce' in def && def.coerce) fail(path, 'coercion has no representable input domain');
|
|
272
|
+
if (def.type === 'object' && def.catchall && def.catchall._zod.def.type !== 'never')
|
|
273
|
+
fail(path, 'objects must reject additional properties');
|
|
274
|
+
const checks = (def.checks ?? []).map(check => check._zod.def);
|
|
275
|
+
if ('check' in def) checks.push(def as z.core.$ZodCheckDef);
|
|
276
|
+
for (const check of checks) {
|
|
277
|
+
const kind = check.check;
|
|
278
|
+
if (!Object.hasOwn(projectedZodChecks, kind)) fail(path, 'Zod check cannot be represented');
|
|
279
|
+
}
|
|
280
|
+
if (def.type === 'transform') {
|
|
281
|
+
active.delete(schema);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const visitValue = (value: unknown, childPath: string): void => {
|
|
285
|
+
if (value instanceof z.ZodType) inspectZod(value, childPath, active);
|
|
286
|
+
else if (Array.isArray(value))
|
|
287
|
+
value.forEach((child, index) => visitValue(child, `${childPath}/${index}`));
|
|
288
|
+
else if (record(value))
|
|
289
|
+
for (const [key, child] of Object.entries(value))
|
|
290
|
+
visitValue(child, `${childPath}/${segment(key)}`);
|
|
291
|
+
};
|
|
292
|
+
if (def.type === 'lazy') visitValue((def as z.core.$ZodLazyDef).getter(), `${path}/lazy`);
|
|
293
|
+
else
|
|
294
|
+
for (const [key, value] of Object.entries(def)) {
|
|
295
|
+
if (['checks', 'error'].includes(key) || (def.type === 'object' && key === 'catchall'))
|
|
296
|
+
continue;
|
|
297
|
+
visitValue(value, `${path}/${segment(key)}`);
|
|
298
|
+
}
|
|
299
|
+
active.delete(schema);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Portable input structure; no provider strictness or omitted-constraint guarantee. */
|
|
303
|
+
export function compileProjectedSchema(schema: z.ZodType): Record<string, unknown> {
|
|
304
|
+
let compiled: Record<string, unknown>;
|
|
305
|
+
try {
|
|
306
|
+
inspectZod(schema);
|
|
307
|
+
compiled = z.toJSONSchema(schema, {
|
|
308
|
+
target: 'draft-2020-12',
|
|
309
|
+
io: 'input',
|
|
310
|
+
unrepresentable: 'throw',
|
|
311
|
+
cycles: 'throw',
|
|
312
|
+
reused: 'inline',
|
|
313
|
+
override: ({ zodSchema, jsonSchema }) => {
|
|
314
|
+
// Default Zod objects strip unknown keys; constrain generated input to their declared fields.
|
|
315
|
+
// Strict and optional semantics remain unchanged; passthrough/catchall objects are rejected.
|
|
316
|
+
if (zodSchema._zod.def.type === 'object' && !zodSchema._zod.def.catchall)
|
|
317
|
+
jsonSchema.additionalProperties = false;
|
|
318
|
+
// Fixed scalar hints only; arbitrary schema descriptions never cross this boundary.
|
|
319
|
+
delete jsonSchema.description;
|
|
320
|
+
if (
|
|
321
|
+
zodSchema instanceof z.ZodString &&
|
|
322
|
+
(zodSchema.minLength !== null || zodSchema.maxLength !== null)
|
|
323
|
+
)
|
|
324
|
+
jsonSchema.description = `Length must be between ${zodSchema.minLength ?? 0} and ${zodSchema.maxLength ?? 'unbounded'} characters. Local validation remains authoritative.`;
|
|
325
|
+
// Modify only the exported provider schema, never the original validator.
|
|
326
|
+
for (const keyword of omittedProviderConstraints) delete jsonSchema[keyword];
|
|
327
|
+
if (
|
|
328
|
+
'minItems' in jsonSchema &&
|
|
329
|
+
jsonSchema.minItems !== 0 &&
|
|
330
|
+
jsonSchema.minItems !== 1
|
|
331
|
+
)
|
|
332
|
+
delete jsonSchema.minItems;
|
|
333
|
+
if (
|
|
334
|
+
'format' in jsonSchema &&
|
|
335
|
+
(typeof jsonSchema.format !== 'string' ||
|
|
336
|
+
!Object.hasOwn(formats, jsonSchema.format))
|
|
337
|
+
)
|
|
338
|
+
delete jsonSchema.format;
|
|
339
|
+
},
|
|
340
|
+
}) as Record<string, unknown>;
|
|
341
|
+
} catch (error) {
|
|
342
|
+
if (error instanceof BedrockSchemaError) throw error;
|
|
343
|
+
fail('#', 'Zod input domain cannot be represented as JSON Schema');
|
|
344
|
+
}
|
|
345
|
+
validateBedrockJsonSchema(compiled);
|
|
346
|
+
return compiled;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Return the original schema's parsed value, including async transformations. */
|
|
350
|
+
export async function parseBedrockOutput(value: unknown, schema: z.ZodType): Promise<unknown> {
|
|
351
|
+
return schema.parseAsync(value);
|
|
352
|
+
}
|