okengine 0.5.1 → 0.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/README.md +148 -13
- package/package.json +4 -3
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +77 -8
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/reference/configuration.mdx +12 -4
- package/site/content/docs/reference/environment-variables.mdx +42 -13
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/method-context.ts +12 -2
- package/src/cli/openbao-restart.integration.test.ts +106 -97
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/dockerfile.integration.test.ts +126 -119
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/docker/stack.integration.test.ts +118 -102
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +109 -0
- package/src/drivers/ai-ollama.integration.test.ts +181 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-fcm.ts +49 -53
- package/src/drivers/channel-msegat.ts +61 -0
- package/src/drivers/channel-sently-map.ts +57 -0
- package/src/drivers/channel-sently.test.ts +99 -0
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/channel-sndr.ts +28 -0
- package/src/drivers/channel-taqnyat.ts +57 -0
- package/src/drivers/channel-types.ts +79 -2
- package/src/drivers/channel-unifonic.ts +26 -43
- package/src/drivers/channel-wa-cloud.ts +33 -47
- package/src/drivers/channel-webpush.ts +39 -239
- package/src/drivers/index.ts +25 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/elements/channel/costs.test.ts +2 -2
- package/src/elements/channel/costs.ts +14 -2
- package/src/elements/channel/mime.ts +11 -0
- package/src/elements/channel/runtime.ts +94 -0
- package/src/elements/channel/sndr-webhooks.test.ts +26 -0
- package/src/elements/channel.ts +10 -1
- package/src/elements/index.ts +9 -0
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot-bind/channel.test.ts +68 -3
- package/src/kernel/boot-bind/channel.ts +93 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +336 -0
- package/src/plugins/auth-methods.security.test.ts +12 -10
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/index.ts +16 -2
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/exports.test.ts +26 -0
- package/src/release/exports.ts +64 -5
- package/src/release/index.ts +5 -0
- package/src/release/measure.exports.test.ts +13 -1
- package/src/release/measure.ts +84 -14
- package/src/release/official-plugins.ts +46 -0
- package/src/release/readme.test.ts +30 -2
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `webpush` channel driver — RFC 8030
|
|
2
|
+
* `webpush` channel driver — RFC 8030 + VAPID via sently.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Call path: ChannelTransport.send → createPushSender → WebPushTransport.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { createPushSender } from "sently/push";
|
|
8
|
+
import { WebPushTransport } from "sently/transports/webpush";
|
|
9
|
+
import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
|
|
7
10
|
import type {
|
|
8
11
|
ChannelDriver,
|
|
9
12
|
ChannelMessage,
|
|
@@ -12,263 +15,60 @@ import type {
|
|
|
12
15
|
ChannelTransport,
|
|
13
16
|
} from "./channel-types.ts";
|
|
14
17
|
|
|
15
|
-
/**
|
|
16
|
-
* Decode a URL-safe base64 string.
|
|
17
|
-
*
|
|
18
|
-
* @param input - Base64url text
|
|
19
|
-
*/
|
|
20
|
-
function b64urlDecode(input: string): Uint8Array<ArrayBuffer> {
|
|
21
|
-
const pad = "=".repeat((4 - (input.length % 4)) % 4);
|
|
22
|
-
const b64 = (input + pad).replace(/-/g, "+").replace(/_/g, "/");
|
|
23
|
-
const bin = atob(b64);
|
|
24
|
-
const out = new Uint8Array(bin.length);
|
|
25
|
-
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
26
|
-
return out;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Fresh buffer for Web Crypto `BufferSource` typing. */
|
|
30
|
-
function asBufferSource(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
31
|
-
return new Uint8Array(bytes);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Encode bytes as base64url (no padding).
|
|
36
|
-
*
|
|
37
|
-
* @param bytes - Input
|
|
38
|
-
*/
|
|
39
|
-
function b64urlEncode(bytes: Uint8Array): string {
|
|
40
|
-
let s = "";
|
|
41
|
-
for (const b of bytes) s += String.fromCharCode(b);
|
|
42
|
-
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Import a VAPID ECDSA P-256 private key from base64url raw or PKCS8.
|
|
47
|
-
*
|
|
48
|
-
* @param privateKeyB64 - Base64url private key
|
|
49
|
-
*/
|
|
50
|
-
async function importVapidPrivateKey(privateKeyB64: string): Promise<CryptoKey> {
|
|
51
|
-
const raw = b64urlDecode(privateKeyB64);
|
|
52
|
-
// Prefer PKCS8; fall back to raw JWK construction for 32-byte seeds.
|
|
53
|
-
if (raw.length > 32) {
|
|
54
|
-
return crypto.subtle.importKey(
|
|
55
|
-
"pkcs8",
|
|
56
|
-
asBufferSource(raw),
|
|
57
|
-
{ name: "ECDSA", namedCurve: "P-256" },
|
|
58
|
-
false,
|
|
59
|
-
["sign"],
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
// Build a minimal JWK from d (and a synthetic public x/y via derive is hard);
|
|
63
|
-
// tests inject fetch and only need Authorization header shape — use ECDSA
|
|
64
|
-
// with a generated key when raw seed is provided without full PKCS8.
|
|
65
|
-
const pair = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
|
|
66
|
-
"sign",
|
|
67
|
-
"verify",
|
|
68
|
-
]);
|
|
69
|
-
return pair.privateKey;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Build a VAPID JWT (RFC 8292) for the push audience.
|
|
74
|
-
*
|
|
75
|
-
* @param audience - Origin of the push service
|
|
76
|
-
* @param subject - `mailto:` or HTTPS contact
|
|
77
|
-
* @param privateKey - ECDSA P-256 key
|
|
78
|
-
*/
|
|
79
|
-
async function buildVapidJwt(
|
|
80
|
-
audience: string,
|
|
81
|
-
subject: string,
|
|
82
|
-
privateKey: CryptoKey,
|
|
83
|
-
): Promise<string> {
|
|
84
|
-
const header = b64urlEncode(
|
|
85
|
-
new TextEncoder().encode(JSON.stringify({ typ: "JWT", alg: "ES256" })),
|
|
86
|
-
);
|
|
87
|
-
const exp = Math.floor(Date.now() / 1000) + 12 * 60 * 60;
|
|
88
|
-
const payload = b64urlEncode(
|
|
89
|
-
new TextEncoder().encode(JSON.stringify({ aud: audience, exp, sub: subject })),
|
|
90
|
-
);
|
|
91
|
-
const data = new TextEncoder().encode(`${header}.${payload}`);
|
|
92
|
-
const sig = new Uint8Array(
|
|
93
|
-
await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, data),
|
|
94
|
-
);
|
|
95
|
-
// Convert DER → raw r||s if needed; Web Crypto in Bun returns raw P-1363.
|
|
96
|
-
return `${header}.${payload}.${b64urlEncode(sig)}`;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* Minimal RFC 8030 encrypted body: salt + rs + server public + ciphertext.
|
|
101
|
-
* For tests with injected fetch we send a well-formed envelope; production
|
|
102
|
-
* should use full ece (aes128gcm). Here we implement aes128gcm content-coding
|
|
103
|
-
* with Web Crypto ECDH + HKDF as specified.
|
|
104
|
-
*
|
|
105
|
-
* @param plaintext - Push payload bytes
|
|
106
|
-
* @param p256dh - Subscriber public key (base64url)
|
|
107
|
-
* @param auth - Subscriber auth secret (base64url)
|
|
108
|
-
*/
|
|
109
|
-
async function encryptPushBody(
|
|
110
|
-
plaintext: Uint8Array,
|
|
111
|
-
p256dh: string,
|
|
112
|
-
auth: string,
|
|
113
|
-
): Promise<{ body: Uint8Array; salt: Uint8Array; localPublic: Uint8Array }> {
|
|
114
|
-
const userPublic = await crypto.subtle.importKey(
|
|
115
|
-
"raw",
|
|
116
|
-
asBufferSource(b64urlDecode(p256dh)),
|
|
117
|
-
{ name: "ECDH", namedCurve: "P-256" },
|
|
118
|
-
true,
|
|
119
|
-
[],
|
|
120
|
-
);
|
|
121
|
-
const local = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, [
|
|
122
|
-
"deriveBits",
|
|
123
|
-
]);
|
|
124
|
-
const shared = new Uint8Array(
|
|
125
|
-
await crypto.subtle.deriveBits({ name: "ECDH", public: userPublic }, local.privateKey, 256),
|
|
126
|
-
);
|
|
127
|
-
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
128
|
-
const authSecret = b64urlDecode(auth);
|
|
129
|
-
|
|
130
|
-
// HKDF-SHA-256 as in RFC 8291
|
|
131
|
-
const ikmKey = await crypto.subtle.importKey("raw", asBufferSource(shared), "HKDF", false, [
|
|
132
|
-
"deriveBits",
|
|
133
|
-
]);
|
|
134
|
-
const prk = new Uint8Array(
|
|
135
|
-
await crypto.subtle.deriveBits(
|
|
136
|
-
{
|
|
137
|
-
name: "HKDF",
|
|
138
|
-
hash: "SHA-256",
|
|
139
|
-
salt: asBufferSource(authSecret),
|
|
140
|
-
info: new TextEncoder().encode("WebPush: info\0"),
|
|
141
|
-
},
|
|
142
|
-
ikmKey,
|
|
143
|
-
256,
|
|
144
|
-
),
|
|
145
|
-
);
|
|
146
|
-
// Simplified content encryption key / nonce derivation for aes128gcm
|
|
147
|
-
const prkKey = await crypto.subtle.importKey("raw", prk, "HKDF", false, ["deriveBits"]);
|
|
148
|
-
const cek = new Uint8Array(
|
|
149
|
-
await crypto.subtle.deriveBits(
|
|
150
|
-
{
|
|
151
|
-
name: "HKDF",
|
|
152
|
-
hash: "SHA-256",
|
|
153
|
-
salt,
|
|
154
|
-
info: new TextEncoder().encode("Content-Encoding: aes128gcm\0"),
|
|
155
|
-
},
|
|
156
|
-
prkKey,
|
|
157
|
-
128,
|
|
158
|
-
),
|
|
159
|
-
);
|
|
160
|
-
const nonce = new Uint8Array(
|
|
161
|
-
await crypto.subtle.deriveBits(
|
|
162
|
-
{
|
|
163
|
-
name: "HKDF",
|
|
164
|
-
hash: "SHA-256",
|
|
165
|
-
salt,
|
|
166
|
-
info: new TextEncoder().encode("Content-Encoding: nonce\0"),
|
|
167
|
-
},
|
|
168
|
-
prkKey,
|
|
169
|
-
96,
|
|
170
|
-
),
|
|
171
|
-
);
|
|
172
|
-
const aes = await crypto.subtle.importKey("raw", cek, { name: "AES-GCM" }, false, ["encrypt"]);
|
|
173
|
-
// Add RFC 8188 padding delimiter (0x02) then encrypt
|
|
174
|
-
const padded = new Uint8Array(plaintext.length + 1);
|
|
175
|
-
padded.set(plaintext, 0);
|
|
176
|
-
padded[plaintext.length] = 2;
|
|
177
|
-
const ciphertext = new Uint8Array(
|
|
178
|
-
await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, aes, padded),
|
|
179
|
-
);
|
|
180
|
-
const localPublic = new Uint8Array(await crypto.subtle.exportKey("raw", local.publicKey));
|
|
181
|
-
|
|
182
|
-
// Body = salt (16) || rs (4) || idlen (1) || keyid || ciphertext
|
|
183
|
-
const rs = new Uint8Array(4);
|
|
184
|
-
new DataView(rs.buffer).setUint32(0, 4096);
|
|
185
|
-
const idlen = new Uint8Array([localPublic.length]);
|
|
186
|
-
const body = new Uint8Array(16 + 4 + 1 + localPublic.length + ciphertext.length);
|
|
187
|
-
body.set(salt, 0);
|
|
188
|
-
body.set(rs, 16);
|
|
189
|
-
body.set(idlen, 20);
|
|
190
|
-
body.set(localPublic, 21);
|
|
191
|
-
body.set(ciphertext, 21 + localPublic.length);
|
|
192
|
-
return { body, salt, localPublic };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
18
|
/**
|
|
196
19
|
* Open a Web Push driver (RFC 8030 + VAPID).
|
|
197
20
|
*
|
|
198
21
|
* @param options - VAPID keys + subject
|
|
199
22
|
*/
|
|
200
23
|
export function openWebPushChannel(options: ChannelOpenOptions = {}): ChannelDriver {
|
|
201
|
-
const
|
|
202
|
-
const
|
|
24
|
+
const vapidPublicKey = options.vapidPublicKey;
|
|
25
|
+
const vapidPrivateKey = options.vapidPrivateKey;
|
|
203
26
|
const subject = options.vapidSubject ?? "mailto:ops@oke.local";
|
|
204
|
-
|
|
27
|
+
if (!vapidPublicKey || !vapidPrivateKey) {
|
|
28
|
+
throw new Error("webpush: vapidPublicKey and vapidPrivateKey are required");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const transport = new WebPushTransport({
|
|
32
|
+
vapidPublicKey,
|
|
33
|
+
vapidPrivateKey,
|
|
34
|
+
subject,
|
|
35
|
+
});
|
|
36
|
+
const sender = createPushSender({ transport });
|
|
205
37
|
|
|
206
38
|
const channel: ChannelTransport = {
|
|
207
39
|
provider: "webpush",
|
|
208
40
|
mediums: ["push"],
|
|
209
41
|
async send(message: ChannelMessage): Promise<ChannelSendResult> {
|
|
210
|
-
if (!vapidPrivate || !vapidPublic) {
|
|
211
|
-
throw new Error("webpush: vapidPublicKey and vapidPrivateKey are required");
|
|
212
|
-
}
|
|
213
42
|
const sub = message.pushSubscription;
|
|
214
43
|
const endpoint = sub?.endpoint ?? message.to;
|
|
215
|
-
if (!endpoint.startsWith("http")) {
|
|
216
|
-
throw new Error("webpush: pushSubscription
|
|
217
|
-
}
|
|
218
|
-
const audience = new URL(endpoint).origin;
|
|
219
|
-
const privateKey = await importVapidPrivateKey(vapidPrivate);
|
|
220
|
-
const jwt = await buildVapidJwt(audience, subject, privateKey);
|
|
221
|
-
|
|
222
|
-
const plaintext = new TextEncoder().encode(
|
|
223
|
-
message.text ?? JSON.stringify(message.data ?? {}),
|
|
224
|
-
);
|
|
225
|
-
let body: Uint8Array = plaintext;
|
|
226
|
-
const headers: Record<string, string> = {
|
|
227
|
-
Authorization: `vapid t=${jwt}, k=${vapidPublic}`,
|
|
228
|
-
TTL: "86400",
|
|
229
|
-
Urgency: "normal",
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
if (sub?.keys?.p256dh && sub.keys.auth) {
|
|
233
|
-
const enc = await encryptPushBody(plaintext, sub.keys.p256dh, sub.keys.auth);
|
|
234
|
-
body = enc.body;
|
|
235
|
-
headers["Content-Encoding"] = "aes128gcm";
|
|
236
|
-
headers["Content-Type"] = "application/octet-stream";
|
|
237
|
-
} else {
|
|
238
|
-
headers["Content-Type"] = "text/plain;charset=utf-8";
|
|
44
|
+
if (!endpoint.startsWith("http") || !sub?.keys?.p256dh || !sub.keys.auth) {
|
|
45
|
+
throw new Error("webpush: pushSubscription with endpoint + keys is required");
|
|
239
46
|
}
|
|
240
47
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
},
|
|
259
|
-
],
|
|
260
|
-
};
|
|
48
|
+
try {
|
|
49
|
+
const title =
|
|
50
|
+
message.subject ??
|
|
51
|
+
(typeof message.data?.title === "string" ? message.data.title : "Notification");
|
|
52
|
+
const body = message.text ?? JSON.stringify(message.data ?? {});
|
|
53
|
+
const result = await sender.send({
|
|
54
|
+
subscription: {
|
|
55
|
+
endpoint,
|
|
56
|
+
keys: { p256dh: sub.keys.p256dh, auth: sub.keys.auth },
|
|
57
|
+
},
|
|
58
|
+
title,
|
|
59
|
+
body,
|
|
60
|
+
...(message.data ? { data: { ...message.data } } : {}),
|
|
61
|
+
});
|
|
62
|
+
return mapSentlySendResult("webpush", result);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
return mapSentlySendError("webpush", err);
|
|
261
65
|
}
|
|
262
|
-
return {
|
|
263
|
-
ok: true,
|
|
264
|
-
messageId: id,
|
|
265
|
-
driverId: "webpush",
|
|
266
|
-
attempts: [{ driverId: "webpush", ok: true, at: Date.now(), messageId: id }],
|
|
267
|
-
};
|
|
268
66
|
},
|
|
67
|
+
verify: () => sender.verify(),
|
|
68
|
+
close: () => sender.close(),
|
|
269
69
|
};
|
|
270
70
|
|
|
271
|
-
return { id: "webpush", channel };
|
|
71
|
+
return { id: "webpush", channel, pushTransport: transport };
|
|
272
72
|
}
|
|
273
73
|
|
|
274
74
|
/** Web Push driver factory. */
|
package/src/drivers/index.ts
CHANGED
|
@@ -139,16 +139,23 @@ export { createChannelInbox } from "./channel-types.ts";
|
|
|
139
139
|
export { consoleChannelDriver, openConsoleChannel } from "./channel-console.ts";
|
|
140
140
|
export { smtpChannelDriver, openSmtpChannel } from "./channel-smtp.ts";
|
|
141
141
|
export { resendChannelDriver, openResendChannel } from "./channel-resend.ts";
|
|
142
|
+
export { sndrChannelDriver, openSndrChannel } from "./channel-sndr.ts";
|
|
143
|
+
export { taqnyatChannelDriver, openTaqnyatChannel } from "./channel-taqnyat.ts";
|
|
144
|
+
export { msegatChannelDriver, openMsegatChannel } from "./channel-msegat.ts";
|
|
142
145
|
export { unifonicChannelDriver, openUnifonicChannel } from "./channel-unifonic.ts";
|
|
143
146
|
export { waCloudChannelDriver, openWaCloudChannel } from "./channel-wa-cloud.ts";
|
|
144
147
|
export { fcmChannelDriver, openFcmChannel } from "./channel-fcm.ts";
|
|
145
148
|
export { webpushChannelDriver, openWebPushChannel } from "./channel-webpush.ts";
|
|
149
|
+
export { mapSentlySendResult, mapSentlySendError } from "./channel-sently-map.ts";
|
|
146
150
|
|
|
147
151
|
export type {
|
|
148
152
|
AiDriverId,
|
|
149
153
|
AiMessage,
|
|
154
|
+
AiToolDef,
|
|
155
|
+
AiToolCall,
|
|
150
156
|
AiCompleteOptions,
|
|
151
157
|
AiCompleteResult,
|
|
158
|
+
AiStreamChunk,
|
|
152
159
|
AiEmbedOptions,
|
|
153
160
|
AiEmbedResult,
|
|
154
161
|
AiModelClient,
|
|
@@ -158,4 +165,21 @@ export type {
|
|
|
158
165
|
|
|
159
166
|
export { mockAiDriver, createMockAiDriver } from "./ai-mock.ts";
|
|
160
167
|
export { anthropicAiDriver, openAnthropic } from "./ai-anthropic.ts";
|
|
161
|
-
export {
|
|
168
|
+
export {
|
|
169
|
+
openaiCompatibleAiDriver,
|
|
170
|
+
openOpenaiCompatible,
|
|
171
|
+
OPENAI_COMPAT_DEFAULT_BASE,
|
|
172
|
+
normalizeOpenaiCompatibleBaseUrl,
|
|
173
|
+
isOpenaiCloudBase,
|
|
174
|
+
openaiCompatibleHeaders,
|
|
175
|
+
} from "./ai-openai-compatible.ts";
|
|
176
|
+
export {
|
|
177
|
+
ollamaAiDriver,
|
|
178
|
+
openOllama,
|
|
179
|
+
OllamaUnavailableError,
|
|
180
|
+
OLLAMA_DEFAULT_MODEL,
|
|
181
|
+
OLLAMA_DEFAULT_BASE_URL,
|
|
182
|
+
normalizeOllamaBaseUrl,
|
|
183
|
+
resolveOllamaBaseUrl,
|
|
184
|
+
resolveOllamaModel,
|
|
185
|
+
} from "./ai-ollama.ts";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Protocol-named re-export — prefer `okengine/drivers/ai-ollama` or
|
|
3
|
+
* `okengine/drivers` (`ollamaAiDriver`). Same surface as {@link ./ai-ollama.ts}.
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
ollamaAiDriver,
|
|
7
|
+
openOllama,
|
|
8
|
+
OllamaUnavailableError,
|
|
9
|
+
OLLAMA_DEFAULT_MODEL,
|
|
10
|
+
OLLAMA_DEFAULT_BASE_URL,
|
|
11
|
+
normalizeOllamaBaseUrl,
|
|
12
|
+
resolveOllamaBaseUrl,
|
|
13
|
+
resolveOllamaModel,
|
|
14
|
+
} from "./ai-ollama.ts";
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI rate presets — gate.rate only, no parallel budgeting system.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { memoryKvDriver } from "../../drivers/index.ts";
|
|
7
|
+
import { createGateRuntime } from "../gate.ts";
|
|
8
|
+
import { AI_RATE_PRESETS, aiRateGate, createAiRateGates } from "./rate.ts";
|
|
9
|
+
|
|
10
|
+
describe("AI_RATE_PRESETS", () => {
|
|
11
|
+
test("ask is stricter than embed; agent is strictest", () => {
|
|
12
|
+
expect(AI_RATE_PRESETS.ask).toEqual({ max: 20, per: "1m", keyBy: "user" });
|
|
13
|
+
expect(AI_RATE_PRESETS.agent).toEqual({ max: 10, per: "1m", keyBy: "user" });
|
|
14
|
+
expect(AI_RATE_PRESETS.embed).toEqual({ max: 60, per: "1m", keyBy: "user" });
|
|
15
|
+
expect(AI_RATE_PRESETS.agent.max).toBeLessThan(AI_RATE_PRESETS.ask.max);
|
|
16
|
+
expect(AI_RATE_PRESETS.ask.max).toBeLessThan(AI_RATE_PRESETS.embed.max);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("aiRateGate builds a real gate.rate decl", () => {
|
|
20
|
+
const g = aiRateGate("ask");
|
|
21
|
+
expect(g.kind).toBe("rate");
|
|
22
|
+
expect(g.max).toBe(20);
|
|
23
|
+
expect(g.per).toBe("1m");
|
|
24
|
+
expect(g.keyBy).toBe("user");
|
|
25
|
+
expect(g.name).toContain("20/1m");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("createAiRateGates materializes three decls; deny after max", async () => {
|
|
29
|
+
const kv = await memoryKvDriver.open({ name: "ai-rate" });
|
|
30
|
+
const gates = createAiRateGates();
|
|
31
|
+
expect(gates).toHaveLength(3);
|
|
32
|
+
const runtime = createGateRuntime({ gates: [...gates], kv, now: () => 1_000 });
|
|
33
|
+
const ask = gates[0]!;
|
|
34
|
+
const ctx = {
|
|
35
|
+
auth: { userId: "u1", scopes: new Set<string>() },
|
|
36
|
+
operator: { id: null },
|
|
37
|
+
meta: {},
|
|
38
|
+
};
|
|
39
|
+
for (let i = 0; i < ask.max; i++) {
|
|
40
|
+
const ev = await runtime.check([ask.name], ctx);
|
|
41
|
+
expect(ev.every((e) => e.allowed)).toBe(true);
|
|
42
|
+
}
|
|
43
|
+
const denied = await runtime.check([ask.name], ctx);
|
|
44
|
+
expect(denied.some((e) => !e.allowed)).toBe(true);
|
|
45
|
+
await kv.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("public AI edge can override keyBy to ip", () => {
|
|
49
|
+
const g = aiRateGate("ask", { keyBy: "ip", max: 5 });
|
|
50
|
+
expect(g.keyBy).toBe("ip");
|
|
51
|
+
expect(g.max).toBe(5);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI rate-limit presets — reuse {@link gate.rate}, no parallel budgeting.
|
|
3
|
+
*
|
|
4
|
+
* Cost caps stay on prompt/agent `budget` decls. These presets throttle
|
|
5
|
+
* request volume on HTTP triggers that wrap `fx.ask` / agents / embeds.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { gate, type RateGateDecl } from "../gate/declare.ts";
|
|
9
|
+
|
|
10
|
+
/** Preset keys for AI-facing HTTP edges. */
|
|
11
|
+
export type AiRatePreset = "ask" | "agent" | "embed";
|
|
12
|
+
|
|
13
|
+
/** One AI rate preset row. */
|
|
14
|
+
export type AiRatePresetSpec = {
|
|
15
|
+
readonly max: number;
|
|
16
|
+
readonly per: string;
|
|
17
|
+
readonly keyBy: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Sensible defaults: AI calls are far more expensive per request than
|
|
22
|
+
* ordinary HTTP, so limits are tighter than typical API rate limits.
|
|
23
|
+
* Prefer `keyBy: "user"` when the edge is authenticated; use `ip` on
|
|
24
|
+
* public unauthenticated AI surfaces.
|
|
25
|
+
*/
|
|
26
|
+
export const AI_RATE_PRESETS: Readonly<Record<AiRatePreset, AiRatePresetSpec>> = {
|
|
27
|
+
ask: { max: 20, per: "1m", keyBy: "user" },
|
|
28
|
+
agent: { max: 10, per: "1m", keyBy: "user" },
|
|
29
|
+
embed: { max: 60, per: "1m", keyBy: "user" },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Build a `gate.rate` declaration from an AI preset.
|
|
34
|
+
*
|
|
35
|
+
* @param kind - ask · agent · embed
|
|
36
|
+
* @param overrides - Optional max / per / keyBy overrides
|
|
37
|
+
*/
|
|
38
|
+
export function aiRateGate(
|
|
39
|
+
kind: AiRatePreset,
|
|
40
|
+
overrides?: {
|
|
41
|
+
readonly max?: number;
|
|
42
|
+
readonly per?: string;
|
|
43
|
+
readonly keyBy?: string;
|
|
44
|
+
readonly description?: string;
|
|
45
|
+
},
|
|
46
|
+
): RateGateDecl {
|
|
47
|
+
const preset = AI_RATE_PRESETS[kind];
|
|
48
|
+
return gate.rate({
|
|
49
|
+
max: overrides?.max ?? preset.max,
|
|
50
|
+
per: overrides?.per ?? preset.per,
|
|
51
|
+
keyBy: overrides?.keyBy ?? preset.keyBy,
|
|
52
|
+
description:
|
|
53
|
+
overrides?.description ??
|
|
54
|
+
`AI ${kind} rate limit (${overrides?.max ?? preset.max}/${overrides?.per ?? preset.per})`,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Materialize all AI rate gate decls (ask + agent + embed).
|
|
60
|
+
*
|
|
61
|
+
* @param enabled - When false, returns []
|
|
62
|
+
*/
|
|
63
|
+
export function createAiRateGates(enabled = true): readonly RateGateDecl[] {
|
|
64
|
+
if (!enabled) return [];
|
|
65
|
+
return [aiRateGate("ask"), aiRateGate("agent"), aiRateGate("embed")];
|
|
66
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redacted values must never leak into provider-facing prompt content.
|
|
3
|
+
* Same class of protection as fx.log — AI prompts are an egress vector.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import type { AiCompleteOptions, AiModelClient } from "../../drivers/ai-types.ts";
|
|
8
|
+
import { REDACTED_PLACEHOLDER, Redacted } from "../../kernel/redacted.ts";
|
|
9
|
+
import { ai, createAiRuntime, promptContentFromInput } from "../ai.ts";
|
|
10
|
+
|
|
11
|
+
describe("Redacted never leaks into AI prompts", () => {
|
|
12
|
+
test("promptContentFromInput masks nested Redacted to placeholder", () => {
|
|
13
|
+
const secret = new Redacted("sk-live-super-secret");
|
|
14
|
+
const content = promptContentFromInput({
|
|
15
|
+
key: secret,
|
|
16
|
+
nested: { token: secret },
|
|
17
|
+
note: `billing with ${secret}`,
|
|
18
|
+
});
|
|
19
|
+
expect(content).toContain(REDACTED_PLACEHOLDER);
|
|
20
|
+
expect(content).not.toContain("sk-live-super-secret");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("ask sends placeholder to the model — never cleartext", async () => {
|
|
24
|
+
const secret = new Redacted("vault-stripe-key-REAL");
|
|
25
|
+
const captured: AiCompleteOptions[] = [];
|
|
26
|
+
const client: AiModelClient = {
|
|
27
|
+
driverId: "mock",
|
|
28
|
+
model: "smart",
|
|
29
|
+
async complete(opts) {
|
|
30
|
+
captured.push(opts);
|
|
31
|
+
return {
|
|
32
|
+
text: JSON.stringify({ ok: true }),
|
|
33
|
+
raw: { ok: true },
|
|
34
|
+
model: "smart",
|
|
35
|
+
driverId: "mock",
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const smart = ai.model("smart");
|
|
41
|
+
const prompt = smart.prompt("secure-ask", { out: { ok: "boolean" } });
|
|
42
|
+
const runtime = createAiRuntime({
|
|
43
|
+
models: [smart],
|
|
44
|
+
prompts: [prompt],
|
|
45
|
+
clients: { smart: client },
|
|
46
|
+
forceJournal: false,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
await runtime.ask("secure-ask", {
|
|
50
|
+
apiKey: secret,
|
|
51
|
+
nested: [secret],
|
|
52
|
+
label: `charge ${secret}`,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
expect(captured).toHaveLength(1);
|
|
56
|
+
const content = captured[0]!.messages[0]!.content;
|
|
57
|
+
expect(content).toContain(REDACTED_PLACEHOLDER);
|
|
58
|
+
expect(content).not.toContain("vault-stripe-key-REAL");
|
|
59
|
+
expect(JSON.stringify(captured[0]!.messages)).not.toContain("vault-stripe-key-REAL");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("template-string ask input already stringified stays placeholder-only", async () => {
|
|
63
|
+
const secret = new Redacted("TOP-SECRET-VALUE");
|
|
64
|
+
const captured: string[] = [];
|
|
65
|
+
const client: AiModelClient = {
|
|
66
|
+
driverId: "mock",
|
|
67
|
+
model: "smart",
|
|
68
|
+
async complete(opts) {
|
|
69
|
+
captured.push(opts.messages[0]!.content);
|
|
70
|
+
return {
|
|
71
|
+
text: "{}",
|
|
72
|
+
raw: {},
|
|
73
|
+
model: "smart",
|
|
74
|
+
driverId: "mock",
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
const smart = ai.model("smart");
|
|
79
|
+
const runtime = createAiRuntime({
|
|
80
|
+
models: [smart],
|
|
81
|
+
prompts: [smart.prompt("t")],
|
|
82
|
+
clients: { smart: client },
|
|
83
|
+
forceJournal: false,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await runtime.ask("t", `user said ${secret}`);
|
|
87
|
+
expect(captured[0]).toBe(`user said ${REDACTED_PLACEHOLDER}`);
|
|
88
|
+
expect(captured[0]).not.toContain("TOP-SECRET-VALUE");
|
|
89
|
+
});
|
|
90
|
+
});
|