@forklaunch/core 2.1.1 → 3.0.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/bin/gateway-mock/routes/email.mjs +233 -0
- package/bin/gateway-mock/routes/models.mjs +192 -0
- package/bin/gateway-mock/routes/payments.mjs +541 -0
- package/bin/gateway-mock/routes/sms.mjs +209 -0
- package/bin/gateway-mock/routes/voice.mjs +237 -0
- package/bin/gateway-mock/routes/whatsapp.mjs +227 -0
- package/bin/gateway-mock/server.mjs +321 -0
- package/bin/migrate-compliant-fields.mjs +312 -0
- package/bin/model-gateway-mock.mjs +13 -0
- package/lib/http/emailClient.d.ts +102 -0
- package/lib/http/emailClient.d.ts.map +1 -0
- package/lib/http/index.d.ts +7 -0
- package/lib/http/index.d.ts.map +1 -1
- package/lib/http/index.js +850 -4
- package/lib/http/index.js.map +1 -1
- package/lib/http/index.mjs +809 -1
- package/lib/http/index.mjs.map +1 -1
- package/lib/http/instanceGateway.d.ts +113 -0
- package/lib/http/instanceGateway.d.ts.map +1 -0
- package/lib/http/modelGatewayClient.d.ts +126 -0
- package/lib/http/modelGatewayClient.d.ts.map +1 -0
- package/lib/http/paymentsClient.d.ts +123 -0
- package/lib/http/paymentsClient.d.ts.map +1 -0
- package/lib/http/smsClient.d.ts +123 -0
- package/lib/http/smsClient.d.ts.map +1 -0
- package/lib/http/voiceClient.d.ts +105 -0
- package/lib/http/voiceClient.d.ts.map +1 -0
- package/lib/http/whatsappClient.d.ts +118 -0
- package/lib/http/whatsappClient.d.ts.map +1 -0
- package/lib/objectstore/interfaces/objectstore.interface.d.ts +45 -0
- package/lib/objectstore/interfaces/objectstore.interface.d.ts.map +1 -1
- package/lib/persistence/compliancePropertyBuilder.d.ts.map +1 -1
- package/lib/persistence/complianceTypes.d.ts +10 -1
- package/lib/persistence/complianceTypes.d.ts.map +1 -1
- package/lib/persistence/compliantField.d.ts +203 -0
- package/lib/persistence/compliantField.d.ts.map +1 -0
- package/lib/persistence/defineComplianceEntity.d.ts.map +1 -1
- package/lib/persistence/encryptedType.d.ts +8 -1
- package/lib/persistence/encryptedType.d.ts.map +1 -1
- package/lib/persistence/fieldEncryptor.d.ts +47 -6
- package/lib/persistence/fieldEncryptor.d.ts.map +1 -1
- package/lib/persistence/index.d.ts +3 -2
- package/lib/persistence/index.d.ts.map +1 -1
- package/lib/persistence/index.js +987 -259
- package/lib/persistence/index.js.map +1 -1
- package/lib/persistence/index.mjs +976 -257
- package/lib/persistence/index.mjs.map +1 -1
- package/lib/persistence/keyRotation.d.ts +5 -0
- package/lib/persistence/keyRotation.d.ts.map +1 -1
- package/lib/services/index.js.map +1 -1
- package/lib/services/index.mjs.map +1 -1
- package/package.json +25 -9
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email: the platform's SES-backed email gateway (createEmailClient).
|
|
3
|
+
*
|
|
4
|
+
* Messages are recorded, never sent. Outcomes follow the SES mailbox
|
|
5
|
+
* simulator's spirit with addresses anyone can type:
|
|
6
|
+
* - `bounce@<any>` or any address at `bounce.test`: a permanent bounce
|
|
7
|
+
* (`email.bounced`), and the address joins the instance's suppression
|
|
8
|
+
* list — GET /email/suppressions/:address answers true and a later send
|
|
9
|
+
* to it is refused with 422;
|
|
10
|
+
* - `complaint@<any>`: delivered, then a complaint (`email.delivered`,
|
|
11
|
+
* `email.complained`), and the address is suppressed;
|
|
12
|
+
* - anything else: `email.delivered`.
|
|
13
|
+
* Suppression is applied when the send is accepted; events are delivered
|
|
14
|
+
* just after the send answers, as SES reports them after accepting.
|
|
15
|
+
*
|
|
16
|
+
* Refusals, as the platform answers them: 400 for a malformed request, 422
|
|
17
|
+
* for a suppressed recipient, 429 (with retry-after) past the daily quota.
|
|
18
|
+
* The quota counts recipients, as SES does.
|
|
19
|
+
*
|
|
20
|
+
* Vendor route (unauthenticated): GET /__mock/email/messages[?to=address]
|
|
21
|
+
* lists what was "sent", with its from address and configuration set.
|
|
22
|
+
*
|
|
23
|
+
* MOCK_EMAIL_DAILY_QUOTA recipients per UTC day, default 200
|
|
24
|
+
* MOCK_EMAIL_SENDING_DOMAIN default mail.forklaunch.test; the from address
|
|
25
|
+
* is no-reply@<instance id>.<this>
|
|
26
|
+
* MOCK_EMAIL_EVENT_DELAY_MS wait before delivering events, default 0
|
|
27
|
+
*/
|
|
28
|
+
import { randomUUID } from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
const env = process.env;
|
|
31
|
+
const DAILY_QUOTA = Number(env.MOCK_EMAIL_DAILY_QUOTA ?? 200);
|
|
32
|
+
const SENDING_DOMAIN = env.MOCK_EMAIL_SENDING_DOMAIN ?? 'mail.forklaunch.test';
|
|
33
|
+
const EVENT_DELAY_MS = Number(env.MOCK_EMAIL_EVENT_DELAY_MS ?? 0);
|
|
34
|
+
|
|
35
|
+
const LIMITS = {
|
|
36
|
+
maxRecipients: 50,
|
|
37
|
+
maxSubjectLength: 998,
|
|
38
|
+
maxBodyBytes: 512 * 1024,
|
|
39
|
+
maxTags: 10
|
|
40
|
+
};
|
|
41
|
+
const ADDRESS =
|
|
42
|
+
/^[^\s@<>()[\]\\,;:"]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
|
|
43
|
+
const TAG_PART = /^[A-Za-z0-9_-]{1,256}$/;
|
|
44
|
+
|
|
45
|
+
const isAddress = (a) =>
|
|
46
|
+
typeof a === 'string' && a.length <= 254 && ADDRESS.test(a);
|
|
47
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
48
|
+
const secondsToMidnight = () => {
|
|
49
|
+
const now = new Date();
|
|
50
|
+
const next = Date.UTC(
|
|
51
|
+
now.getUTCFullYear(),
|
|
52
|
+
now.getUTCMonth(),
|
|
53
|
+
now.getUTCDate() + 1
|
|
54
|
+
);
|
|
55
|
+
return Math.max(1, Math.ceil((next - now.getTime()) / 1000));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function outcome(address) {
|
|
59
|
+
const [local, domain] = address.toLowerCase().split('@');
|
|
60
|
+
if (local === 'bounce' || domain === 'bounce.test') return 'bounce';
|
|
61
|
+
if (local === 'complaint') return 'complaint';
|
|
62
|
+
return 'deliver';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function init(state) {
|
|
66
|
+
state.messages ??= [];
|
|
67
|
+
state.suppressed ??= new Map();
|
|
68
|
+
state.day ??= today();
|
|
69
|
+
state.sent ??= 0;
|
|
70
|
+
if (state.day !== today()) {
|
|
71
|
+
state.day = today();
|
|
72
|
+
state.sent = 0;
|
|
73
|
+
}
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The gateway's validation: a message, or the reason it is refused. */
|
|
78
|
+
function validate(body) {
|
|
79
|
+
const list = (v) =>
|
|
80
|
+
v === undefined ? undefined : Array.isArray(v) ? v : [v];
|
|
81
|
+
const to = list(body.to);
|
|
82
|
+
const replyTo = list(body.replyTo);
|
|
83
|
+
if (!to || to.length === 0) return 'to is required';
|
|
84
|
+
if (to.length > LIMITS.maxRecipients)
|
|
85
|
+
return `to has ${to.length} addresses; the limit is ${LIMITS.maxRecipients}`;
|
|
86
|
+
for (const a of [...to, ...(replyTo ?? [])]) {
|
|
87
|
+
if (!isAddress(a))
|
|
88
|
+
return `'${String(a).slice(0, 80)}' is not an email address`;
|
|
89
|
+
}
|
|
90
|
+
if (typeof body.subject !== 'string' || !body.subject.trim())
|
|
91
|
+
return 'subject is required';
|
|
92
|
+
if (/[\r\n]/.test(body.subject)) return 'subject must be one line';
|
|
93
|
+
if (body.subject.length > LIMITS.maxSubjectLength)
|
|
94
|
+
return 'subject is too long';
|
|
95
|
+
if (!body.text && !body.html) return 'text or html is required';
|
|
96
|
+
const bytes =
|
|
97
|
+
Buffer.byteLength(String(body.text ?? '')) +
|
|
98
|
+
Buffer.byteLength(String(body.html ?? ''));
|
|
99
|
+
if (bytes > LIMITS.maxBodyBytes)
|
|
100
|
+
return `body is ${bytes} bytes; the limit is ${LIMITS.maxBodyBytes}`;
|
|
101
|
+
const tags = body.tags ?? {};
|
|
102
|
+
if (typeof tags !== 'object' || Array.isArray(tags))
|
|
103
|
+
return 'tags must be an object';
|
|
104
|
+
if (Object.keys(tags).length > LIMITS.maxTags)
|
|
105
|
+
return `the limit is ${LIMITS.maxTags} tags`;
|
|
106
|
+
for (const [name, value] of Object.entries(tags)) {
|
|
107
|
+
if (!TAG_PART.test(name) || !TAG_PART.test(String(value)))
|
|
108
|
+
return `tag '${name}' is malformed`;
|
|
109
|
+
if (name.startsWith('fl-') || name.startsWith('ses:'))
|
|
110
|
+
return `tag '${name}' is reserved`;
|
|
111
|
+
}
|
|
112
|
+
return { to, replyTo, tags };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export default {
|
|
116
|
+
feature: 'email',
|
|
117
|
+
routes: [
|
|
118
|
+
{
|
|
119
|
+
method: 'POST',
|
|
120
|
+
path: '/email/send',
|
|
121
|
+
handler: (ctx) => {
|
|
122
|
+
const state = init(ctx.state);
|
|
123
|
+
const checked = validate(ctx.body);
|
|
124
|
+
if (typeof checked === 'string') return ctx.send(400, checked);
|
|
125
|
+
const { to, replyTo, tags } = checked;
|
|
126
|
+
const refused = to.filter((a) => state.suppressed.has(a.toLowerCase()));
|
|
127
|
+
if (refused.length) {
|
|
128
|
+
return ctx.send(
|
|
129
|
+
422,
|
|
130
|
+
`Suppressed after a bounce or complaint: ${refused.join(', ')}`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (state.sent + to.length > DAILY_QUOTA) {
|
|
134
|
+
return ctx.send(
|
|
135
|
+
429,
|
|
136
|
+
`Daily email quota of ${DAILY_QUOTA} recipients is spent`,
|
|
137
|
+
{ 'retry-after': String(secondsToMidnight()) }
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
state.sent += to.length;
|
|
141
|
+
const messageId = `mock-${randomUUID()}`;
|
|
142
|
+
const configurationSet = `fl-${ctx.instanceId}`;
|
|
143
|
+
state.messages.push({
|
|
144
|
+
messageId,
|
|
145
|
+
instanceId: ctx.instanceId,
|
|
146
|
+
configurationSet,
|
|
147
|
+
from: `no-reply@${ctx.instanceId}.${SENDING_DOMAIN}`,
|
|
148
|
+
to,
|
|
149
|
+
replyTo,
|
|
150
|
+
subject: ctx.body.subject,
|
|
151
|
+
text: ctx.body.text,
|
|
152
|
+
html: ctx.body.html,
|
|
153
|
+
tags,
|
|
154
|
+
at: new Date().toISOString()
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// What SES will report, decided now so a follow-up call sees it.
|
|
158
|
+
const events = [];
|
|
159
|
+
const delivered = to.filter((a) => outcome(a) !== 'bounce');
|
|
160
|
+
const bounced = to.filter((a) => outcome(a) === 'bounce');
|
|
161
|
+
const complained = to.filter((a) => outcome(a) === 'complaint');
|
|
162
|
+
for (const a of bounced)
|
|
163
|
+
state.suppressed.set(a.toLowerCase(), 'bounce');
|
|
164
|
+
for (const a of complained)
|
|
165
|
+
state.suppressed.set(a.toLowerCase(), 'complaint');
|
|
166
|
+
const base = {
|
|
167
|
+
messageId,
|
|
168
|
+
...(Object.keys(tags).length ? { tags } : {})
|
|
169
|
+
};
|
|
170
|
+
if (delivered.length)
|
|
171
|
+
events.push(['email.delivered', { ...base, recipients: delivered }]);
|
|
172
|
+
if (bounced.length) {
|
|
173
|
+
events.push([
|
|
174
|
+
'email.bounced',
|
|
175
|
+
{
|
|
176
|
+
...base,
|
|
177
|
+
recipients: bounced,
|
|
178
|
+
bounceType: 'Permanent',
|
|
179
|
+
bounceSubType: 'General',
|
|
180
|
+
permanent: true
|
|
181
|
+
}
|
|
182
|
+
]);
|
|
183
|
+
}
|
|
184
|
+
if (complained.length) {
|
|
185
|
+
events.push([
|
|
186
|
+
'email.complained',
|
|
187
|
+
{ ...base, recipients: complained, feedbackType: 'abuse' }
|
|
188
|
+
]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
ctx.send(200, { messageId });
|
|
192
|
+
setTimeout(async () => {
|
|
193
|
+
for (const [type, data] of events) await ctx.emit(type, data);
|
|
194
|
+
}, EVENT_DELAY_MS);
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
method: 'GET',
|
|
199
|
+
path: /^\/email\/suppressions\/(?<address>[^/]+)$/,
|
|
200
|
+
handler: (ctx) => {
|
|
201
|
+
const state = init(ctx.state);
|
|
202
|
+
const address = decodeURIComponent(ctx.params.address).toLowerCase();
|
|
203
|
+
if (!isAddress(address)) return ctx.send(400, 'Not an email address');
|
|
204
|
+
const reason = state.suppressed.get(address);
|
|
205
|
+
return ctx.send(200, {
|
|
206
|
+
address,
|
|
207
|
+
suppressed: Boolean(reason),
|
|
208
|
+
...(reason ? { reason } : {})
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
],
|
|
213
|
+
vendorRoutes: [
|
|
214
|
+
{
|
|
215
|
+
method: 'GET',
|
|
216
|
+
path: '/__mock/email/messages',
|
|
217
|
+
handler: (ctx) => {
|
|
218
|
+
const state = init(ctx.state);
|
|
219
|
+
const to = new URL(ctx.req.url ?? '/', 'http://mock').searchParams.get(
|
|
220
|
+
'to'
|
|
221
|
+
);
|
|
222
|
+
return ctx.send(
|
|
223
|
+
200,
|
|
224
|
+
to
|
|
225
|
+
? state.messages.filter((m) =>
|
|
226
|
+
m.to.some((a) => a.toLowerCase() === to.toLowerCase())
|
|
227
|
+
)
|
|
228
|
+
: state.messages
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
]
|
|
233
|
+
};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Models: the model gateway (createModelGatewayClient) and, for platform
|
|
3
|
+
* developers, the Azure AI Foundry / Entra APIs behind it.
|
|
4
|
+
*
|
|
5
|
+
* Answers are `[mock <model>] <the last user message>`; token counts are
|
|
6
|
+
* words, so budgets move predictably.
|
|
7
|
+
*
|
|
8
|
+
* MOCK_MODELS comma-separated aliases, default sol,terra,luna
|
|
9
|
+
* MOCK_MONTHLY_TOKENS default 2000000
|
|
10
|
+
* MOCK_FAIL_EVERY answer 503 to every Nth completion (default off)
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from 'node:crypto';
|
|
13
|
+
|
|
14
|
+
const env = process.env;
|
|
15
|
+
const MODELS = (env.MOCK_MODELS ?? 'sol,terra,luna')
|
|
16
|
+
.split(',')
|
|
17
|
+
.map((m) => m.trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
const BUDGET = Number(env.MOCK_MONTHLY_TOKENS ?? 2_000_000);
|
|
20
|
+
const FAIL_EVERY = Number(env.MOCK_FAIL_EVERY ?? 0);
|
|
21
|
+
|
|
22
|
+
let tokensUsed = 0;
|
|
23
|
+
let completions = 0;
|
|
24
|
+
|
|
25
|
+
const words = (text) =>
|
|
26
|
+
String(text ?? '')
|
|
27
|
+
.split(/\s+/)
|
|
28
|
+
.filter(Boolean).length;
|
|
29
|
+
|
|
30
|
+
function lastUserText(messages) {
|
|
31
|
+
const user = [...(messages ?? [])].reverse().find((m) => m?.role === 'user');
|
|
32
|
+
if (!user) return '';
|
|
33
|
+
if (typeof user.content === 'string') return user.content;
|
|
34
|
+
if (Array.isArray(user.content)) {
|
|
35
|
+
return user.content
|
|
36
|
+
.map((part) => (typeof part?.text === 'string' ? part.text : ''))
|
|
37
|
+
.join(' ');
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function answer(body) {
|
|
43
|
+
const model = String(body.model ?? 'mock');
|
|
44
|
+
const content = `[mock ${model}] ${lastUserText(body.messages)}`.slice(0, 2000);
|
|
45
|
+
const usage = {
|
|
46
|
+
prompt_tokens: (body.messages ?? []).reduce(
|
|
47
|
+
(n, m) => n + words(typeof m?.content === 'string' ? m.content : ''),
|
|
48
|
+
0
|
|
49
|
+
),
|
|
50
|
+
completion_tokens: words(content)
|
|
51
|
+
};
|
|
52
|
+
usage.total_tokens = usage.prompt_tokens + usage.completion_tokens;
|
|
53
|
+
return { model, content, usage };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function completion(body) {
|
|
57
|
+
const { model, content, usage } = answer(body);
|
|
58
|
+
return {
|
|
59
|
+
id: `chatcmpl-mock-${randomUUID()}`,
|
|
60
|
+
object: 'chat.completion',
|
|
61
|
+
created: Math.floor(Date.now() / 1000),
|
|
62
|
+
model,
|
|
63
|
+
choices: [
|
|
64
|
+
{ index: 0, message: { role: 'assistant', content }, finish_reason: 'stop' }
|
|
65
|
+
],
|
|
66
|
+
usage
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function chunks(body) {
|
|
71
|
+
const { model, content, usage } = answer(body);
|
|
72
|
+
const base = {
|
|
73
|
+
id: `chatcmpl-mock-${randomUUID()}`,
|
|
74
|
+
object: 'chat.completion.chunk',
|
|
75
|
+
created: Math.floor(Date.now() / 1000),
|
|
76
|
+
model
|
|
77
|
+
};
|
|
78
|
+
const out = [{ ...base, choices: [{ index: 0, delta: { role: 'assistant' } }] }];
|
|
79
|
+
for (const word of content.split(/(?<= )/)) {
|
|
80
|
+
out.push({ ...base, choices: [{ index: 0, delta: { content: word } }] });
|
|
81
|
+
}
|
|
82
|
+
out.push({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] });
|
|
83
|
+
if (body.stream_options?.include_usage !== false) {
|
|
84
|
+
out.push({ ...base, choices: [], usage });
|
|
85
|
+
}
|
|
86
|
+
out.push('[DONE]');
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function failed(ctx) {
|
|
91
|
+
completions += 1;
|
|
92
|
+
if (FAIL_EVERY > 0 && completions % FAIL_EVERY === 0) {
|
|
93
|
+
ctx.send(503, 'Mock outage (MOCK_FAIL_EVERY)');
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function completionRoute(streaming) {
|
|
100
|
+
return (ctx) => {
|
|
101
|
+
const { body } = ctx;
|
|
102
|
+
if (!MODELS.includes(body.model)) {
|
|
103
|
+
return ctx.send(403, `'${body.model}' is not enabled; allowed: ${MODELS.join(', ')}`);
|
|
104
|
+
}
|
|
105
|
+
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
|
106
|
+
return ctx.send(400, 'messages must be a non-empty array');
|
|
107
|
+
}
|
|
108
|
+
if (tokensUsed >= BUDGET) {
|
|
109
|
+
return ctx.send(429, `Monthly token budget of ${BUDGET} is spent`, {
|
|
110
|
+
'retry-after': '3600'
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (failed(ctx)) return;
|
|
114
|
+
tokensUsed += answer(body).usage.total_tokens;
|
|
115
|
+
const headers = {
|
|
116
|
+
'x-forklaunch-model': String(body.model),
|
|
117
|
+
'x-forklaunch-tokens-remaining': String(Math.max(0, BUDGET - tokensUsed))
|
|
118
|
+
};
|
|
119
|
+
if (streaming) return ctx.stream(chunks(body), headers, true);
|
|
120
|
+
if (body.stream === true) {
|
|
121
|
+
return ctx.send(
|
|
122
|
+
400,
|
|
123
|
+
'For streaming, POST /instance-gateway/models/chat/completions/stream'
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return ctx.send(200, completion(body), headers);
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default {
|
|
131
|
+
feature: 'models',
|
|
132
|
+
reset() {
|
|
133
|
+
tokensUsed = 0;
|
|
134
|
+
completions = 0;
|
|
135
|
+
},
|
|
136
|
+
routes: [
|
|
137
|
+
{
|
|
138
|
+
method: 'GET',
|
|
139
|
+
path: '/models',
|
|
140
|
+
handler: (ctx) =>
|
|
141
|
+
ctx.send(200, {
|
|
142
|
+
models: MODELS.map((id) => ({ id, baa: true })),
|
|
143
|
+
monthlyTokenBudget: BUDGET,
|
|
144
|
+
tokensUsedThisMonth: tokensUsed,
|
|
145
|
+
requestsPerMinute: 600
|
|
146
|
+
})
|
|
147
|
+
},
|
|
148
|
+
{ method: 'POST', path: '/models/chat/completions', handler: completionRoute(false) },
|
|
149
|
+
{
|
|
150
|
+
method: 'POST',
|
|
151
|
+
path: '/models/chat/completions/stream',
|
|
152
|
+
handler: completionRoute(true)
|
|
153
|
+
}
|
|
154
|
+
],
|
|
155
|
+
vendorRoutes: [
|
|
156
|
+
// The Foundry (Azure OpenAI v1) API.
|
|
157
|
+
{
|
|
158
|
+
method: 'POST',
|
|
159
|
+
path: /\/v1\/chat\/completions$/,
|
|
160
|
+
handler: (ctx) => {
|
|
161
|
+
if (!ctx.req.headers.authorization?.startsWith('Bearer ')) {
|
|
162
|
+
return ctx.send(401, { error: { message: 'Missing bearer credential' } });
|
|
163
|
+
}
|
|
164
|
+
if (failed(ctx)) return;
|
|
165
|
+
return ctx.body.stream === true
|
|
166
|
+
? ctx.stream(chunks(ctx.body))
|
|
167
|
+
: ctx.send(200, completion(ctx.body));
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
// An Entra token endpoint.
|
|
171
|
+
{
|
|
172
|
+
method: 'POST',
|
|
173
|
+
path: /\/oauth2\/v2\.0\/token$/,
|
|
174
|
+
handler: (ctx) => {
|
|
175
|
+
const form = new URLSearchParams(ctx.raw);
|
|
176
|
+
if (
|
|
177
|
+
!form.get('client_id') ||
|
|
178
|
+
!(form.get('client_secret') || form.get('client_assertion'))
|
|
179
|
+
) {
|
|
180
|
+
return ctx.send(401, {
|
|
181
|
+
error_description: 'AADSTS7000215: mock requires a secret or an assertion'
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
return ctx.send(200, {
|
|
185
|
+
token_type: 'Bearer',
|
|
186
|
+
expires_in: 3600,
|
|
187
|
+
access_token: `mock-entra-token-${randomUUID()}`
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
};
|