okengine 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/elements/ai.mdx +82 -1
  3. package/site/content/docs/elements/channel.mdx +6 -1
  4. package/site/content/docs/elements/flow.mdx +20 -17
  5. package/site/content/docs/get-started/why.mdx +10 -10
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/headers.mdx +10 -10
  8. package/site/content/docs/plugins/magic-link.mdx +27 -21
  9. package/site/content/docs/plugins/passkey.mdx +36 -24
  10. package/site/content/docs/plugins/two-factor.mdx +2 -1
  11. package/site/content/docs/reference/configuration.mdx +7 -0
  12. package/site/content/docs/reference/environment-variables.mdx +10 -5
  13. package/site/content/docs/reference/errors.mdx +14 -0
  14. package/site/content/docs/reference/fx.mdx +68 -16
  15. package/site/content/docs/reference/i18n.mdx +313 -0
  16. package/site/content/docs/reference/index.mdx +6 -1
  17. package/site/content/docs/reference/meta.json +1 -0
  18. package/site/content/docs/reference/plugins.mdx +1 -0
  19. package/src/auth/auth.test.ts +3 -0
  20. package/src/auth/bindings.ts +1 -1
  21. package/src/auth/constant-time.ts +22 -0
  22. package/src/auth/index.ts +2 -0
  23. package/src/auth/method-context.ts +12 -2
  24. package/src/cli/competitor-mention-removal.test.ts +3 -3
  25. package/src/compiler/aot.test.ts +16 -13
  26. package/src/compiler/effects-infer.ts +46 -0
  27. package/src/console/server/ai.test.ts +34 -5
  28. package/src/docker/compose.ts +9 -0
  29. package/src/docker/docker.test.ts +39 -0
  30. package/src/docker/index.ts +11 -1
  31. package/src/docker/recipes/index.ts +3 -1
  32. package/src/docker/recipes/ollama.ts +43 -0
  33. package/src/docker/stack-id.ts +2 -0
  34. package/src/drivers/ai-mock.ts +60 -0
  35. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  36. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  37. package/src/drivers/ai-ollama.ts +327 -0
  38. package/src/drivers/ai-openai-compatible.ts +211 -21
  39. package/src/drivers/ai-providers.test.ts +179 -2
  40. package/src/drivers/ai-stream.test.ts +195 -0
  41. package/src/drivers/ai-types.ts +42 -1
  42. package/src/drivers/channel-smtp.ts +8 -2
  43. package/src/drivers/index.ts +21 -1
  44. package/src/drivers/ollama.ts +14 -0
  45. package/src/elements/ai/rate.test.ts +53 -0
  46. package/src/elements/ai/rate.ts +66 -0
  47. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  48. package/src/elements/ai/runtime.ts +330 -100
  49. package/src/elements/ai/tools.test.ts +99 -0
  50. package/src/elements/ai.test.ts +26 -2
  51. package/src/elements/ai.ts +10 -1
  52. package/src/i18n/catalogs/ar.ts +67 -0
  53. package/src/i18n/catalogs/en.ts +68 -0
  54. package/src/i18n/failure-message.test.ts +56 -0
  55. package/src/i18n/failure-message.ts +93 -0
  56. package/src/i18n/format.ts +67 -0
  57. package/src/i18n/index.ts +57 -0
  58. package/src/i18n/locale-context.ts +48 -0
  59. package/src/i18n/messages.test.ts +173 -0
  60. package/src/i18n/messages.ts +169 -0
  61. package/src/i18n/types.ts +90 -0
  62. package/src/index.ts +26 -0
  63. package/src/kernel/app.ts +92 -2
  64. package/src/kernel/boot-bind/ai.test.ts +60 -0
  65. package/src/kernel/boot-bind/ai.ts +125 -2
  66. package/src/kernel/boot.test.ts +4 -3
  67. package/src/kernel/boot.ts +1 -1
  68. package/src/kernel/errors.ts +56 -5
  69. package/src/kernel/fx.test.ts +27 -0
  70. package/src/kernel/fx.ts +74 -18
  71. package/src/kernel/pipeline.test.ts +4 -0
  72. package/src/kernel/pipeline.ts +1 -1
  73. package/src/kernel/plugin.ts +16 -0
  74. package/src/kernel/registry.ts +15 -0
  75. package/src/plugins/auth/shared.ts +5 -1
  76. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  77. package/src/plugins/auth-methods.security.test.ts +764 -0
  78. package/src/plugins/compression.ts +1 -1
  79. package/src/plugins/config-source.test.ts +11 -11
  80. package/src/plugins/config-source.ts +2 -2
  81. package/src/plugins/cors.ts +1 -1
  82. package/src/plugins/email-otp.ts +54 -1
  83. package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
  84. package/src/plugins/headers.ts +240 -41
  85. package/src/plugins/index.ts +27 -5
  86. package/src/plugins/magic-link.ts +63 -3
  87. package/src/plugins/passkey-webauthn.ts +217 -0
  88. package/src/plugins/passkey.ts +99 -33
  89. package/src/plugins/response-headers.ts +54 -0
  90. package/src/plugins/two-factor.ts +6 -2
  91. package/src/plugins/username-policy.test.ts +302 -0
  92. package/src/plugins/username.ts +290 -9
  93. package/src/release/measure.ts +8 -1
  94. package/src/plugins/security-headers.ts +0 -255
@@ -14,14 +14,33 @@ export type AiDriverId =
14
14
  | "vertex"
15
15
  | "ollama";
16
16
 
17
+ /** Tool definition offered to a model (Flow-backed at the fx layer). */
18
+ export interface AiToolDef {
19
+ readonly name: string;
20
+ readonly description?: string;
21
+ /** JSON-schema-like parameters object. */
22
+ readonly parameters?: unknown;
23
+ }
24
+
25
+ /** One model-initiated tool invocation. */
26
+ export interface AiToolCall {
27
+ readonly id: string;
28
+ readonly name: string;
29
+ readonly arguments: unknown;
30
+ }
31
+
17
32
  /** One chat / completion turn. */
18
33
  export interface AiMessage {
19
34
  readonly role: "system" | "user" | "assistant" | "tool";
20
35
  readonly content: string;
21
36
  readonly name?: string;
37
+ /** When role is `tool`, the id of the tool call this message answers. */
38
+ readonly toolCallId?: string;
39
+ /** When role is `assistant`, optional tool calls the model requested. */
40
+ readonly toolCalls?: readonly AiToolCall[];
22
41
  }
23
42
 
24
- /** Options for a model completion. */
43
+ /** Options for a model completion (or stream start). */
25
44
  export interface AiCompleteOptions {
26
45
  readonly messages: readonly AiMessage[];
27
46
  readonly model?: string;
@@ -29,6 +48,10 @@ export interface AiCompleteOptions {
29
48
  readonly maxTokens?: number;
30
49
  /** Structured output hint (JSON schema or name). */
31
50
  readonly responseFormat?: unknown;
51
+ /** Tools the model may call (OpenAI-shaped; drivers map). */
52
+ readonly tools?: readonly AiToolDef[];
53
+ /** Ambient / local abort — cooperative cancellation for complete + stream. */
54
+ readonly signal?: AbortSignal;
32
55
  }
33
56
 
34
57
  /** Result of a model completion. */
@@ -37,6 +60,7 @@ export interface AiCompleteResult {
37
60
  readonly raw?: unknown;
38
61
  readonly model: string;
39
62
  readonly driverId: AiDriverId;
63
+ readonly toolCalls?: readonly AiToolCall[];
40
64
  readonly usage?: {
41
65
  readonly inputTokens?: number;
42
66
  readonly outputTokens?: number;
@@ -44,6 +68,12 @@ export interface AiCompleteResult {
44
68
  };
45
69
  }
46
70
 
71
+ /** One streamed token / delta from a model. */
72
+ export interface AiStreamChunk {
73
+ readonly text: string;
74
+ readonly done?: boolean;
75
+ }
76
+
47
77
  /** Embedding request. */
48
78
  export interface AiEmbedOptions {
49
79
  readonly input: string | readonly string[];
@@ -62,6 +92,12 @@ export interface AiModelClient {
62
92
  readonly driverId: AiDriverId;
63
93
  readonly model: string;
64
94
  complete(options: AiCompleteOptions): Promise<AiCompleteResult>;
95
+ /**
96
+ * Stream tokens. Optional — callers fail loud when missing (no stub echo).
97
+ *
98
+ * @param options - Same as complete, plus optional signal
99
+ */
100
+ stream?(options: AiCompleteOptions): AsyncIterable<AiStreamChunk>;
65
101
  embed?(options: AiEmbedOptions): Promise<AiEmbedResult>;
66
102
  close?(): Promise<void>;
67
103
  }
@@ -71,6 +107,11 @@ export interface AiOpenOptions {
71
107
  readonly model?: string;
72
108
  readonly apiKey?: string;
73
109
  readonly baseUrl?: string;
110
+ /**
111
+ * Extra HTTP headers (e.g. OpenRouter `HTTP-Referer` / `X-Title`).
112
+ * Merged into chat and embed requests; does not invent a new driver.
113
+ */
114
+ readonly headers?: Readonly<Record<string, string>>;
74
115
  /** Mock canned responses keyed by prompt name or substring. */
75
116
  readonly mockResponses?: Readonly<Record<string, unknown>>;
76
117
  readonly fetch?: typeof globalThis.fetch;
@@ -1,7 +1,8 @@
1
1
  /**
2
- * `smtp` channel driver — wraps sently's SMTP transport unchanged.
2
+ * `smtp` channel driver — wraps sently's SMTP transport with the Bun socket adapter.
3
3
  */
4
4
 
5
+ import { BunAdapter } from "sently/adapters/bun";
5
6
  import { SMTPTransport } from "sently/transports/smtp";
6
7
  import type { ChannelDriver, ChannelOpenOptions } from "./channel-types.ts";
7
8
 
@@ -14,9 +15,14 @@ export function openSmtpChannel(options: ChannelOpenOptions = {}): ChannelDriver
14
15
  if (!options.host) {
15
16
  throw new Error("smtp channel: host is required");
16
17
  }
18
+ const port = options.port ?? 587;
19
+ // Implicit TLS only on the classic SMTPS port; Mailpit / local relays are plain.
20
+ const secure = port === 465;
17
21
  const transport = new SMTPTransport({
18
22
  host: options.host,
19
- port: options.port ?? 587,
23
+ port,
24
+ secure,
25
+ adapter: new BunAdapter({ secure }),
20
26
  ...(options.user && options.pass ? { auth: { user: options.user, pass: options.pass } } : {}),
21
27
  });
22
28
  return { id: "smtp", transport };
@@ -147,8 +147,11 @@ export { webpushChannelDriver, openWebPushChannel } from "./channel-webpush.ts";
147
147
  export type {
148
148
  AiDriverId,
149
149
  AiMessage,
150
+ AiToolDef,
151
+ AiToolCall,
150
152
  AiCompleteOptions,
151
153
  AiCompleteResult,
154
+ AiStreamChunk,
152
155
  AiEmbedOptions,
153
156
  AiEmbedResult,
154
157
  AiModelClient,
@@ -158,4 +161,21 @@ export type {
158
161
 
159
162
  export { mockAiDriver, createMockAiDriver } from "./ai-mock.ts";
160
163
  export { anthropicAiDriver, openAnthropic } from "./ai-anthropic.ts";
161
- export { openaiCompatibleAiDriver, openOpenaiCompatible } from "./ai-openai-compatible.ts";
164
+ export {
165
+ openaiCompatibleAiDriver,
166
+ openOpenaiCompatible,
167
+ OPENAI_COMPAT_DEFAULT_BASE,
168
+ normalizeOpenaiCompatibleBaseUrl,
169
+ isOpenaiCloudBase,
170
+ openaiCompatibleHeaders,
171
+ } from "./ai-openai-compatible.ts";
172
+ export {
173
+ ollamaAiDriver,
174
+ openOllama,
175
+ OllamaUnavailableError,
176
+ OLLAMA_DEFAULT_MODEL,
177
+ OLLAMA_DEFAULT_BASE_URL,
178
+ normalizeOllamaBaseUrl,
179
+ resolveOllamaBaseUrl,
180
+ resolveOllamaModel,
181
+ } 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
+ });