okengine 0.5.1 → 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.
- package/package.json +2 -1
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +6 -1
- 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 +7 -0
- package/site/content/docs/reference/environment-variables.mdx +10 -5
- 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/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/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/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
- package/src/drivers/ai-ollama.integration.test.ts +197 -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-smtp.ts +8 -2
- package/src/drivers/index.ts +21 -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/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.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 +330 -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/measure.ts +8 -1
|
@@ -1,28 +1,151 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Lazy AI binder — loaded only when AI is declared.
|
|
3
|
+
*
|
|
4
|
+
* Resolves `drivers.ai` the same way store.index resolves its driver map:
|
|
5
|
+
* shared `aiDriverFor` switch, fail-loud on unknown / reserved-unimplemented
|
|
6
|
+
* ids, never a silent mock fallback when another driver is configured.
|
|
3
7
|
*/
|
|
4
8
|
|
|
9
|
+
import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
|
|
10
|
+
import { anthropicAiDriver } from "../../drivers/ai-anthropic.ts";
|
|
5
11
|
import { mockAiDriver } from "../../drivers/ai-mock.ts";
|
|
12
|
+
import {
|
|
13
|
+
normalizeOllamaBaseUrl,
|
|
14
|
+
ollamaAiDriver,
|
|
15
|
+
OLLAMA_DEFAULT_BASE_URL,
|
|
16
|
+
} from "../../drivers/ai-ollama.ts";
|
|
17
|
+
import { openaiCompatibleAiDriver } from "../../drivers/ai-openai-compatible.ts";
|
|
18
|
+
import type { AiDriver, AiOpenOptions } from "../../drivers/ai-types.ts";
|
|
6
19
|
import { createAiRuntime, type AiRuntime } from "../../elements/ai.ts";
|
|
7
20
|
import type { GateRuntime } from "../../elements/gate.ts";
|
|
8
21
|
import type { BootOptions } from "../boot.ts";
|
|
9
22
|
|
|
10
23
|
/**
|
|
11
|
-
* Construct an AI runtime
|
|
24
|
+
* Construct an AI runtime from config / injection.
|
|
12
25
|
*
|
|
13
26
|
* @param options - Boot options
|
|
14
27
|
* @param gate - Gate runtime for agent tool checks
|
|
15
28
|
* @param now - Clock
|
|
29
|
+
* @param env - Active environment
|
|
30
|
+
* @param docker - Prefer compose AI URL when active
|
|
16
31
|
*/
|
|
17
32
|
export function bindAi(
|
|
18
33
|
options: BootOptions,
|
|
19
34
|
gate: GateRuntime | undefined,
|
|
20
35
|
now: () => number,
|
|
36
|
+
env: ConfigEnv = "local",
|
|
37
|
+
docker = false,
|
|
21
38
|
): AiRuntime {
|
|
39
|
+
const id = resolveAiDriverId(options, env, docker);
|
|
40
|
+
const driver =
|
|
41
|
+
options.ai?.defaultDriver ?? withOpenDefaults(aiDriverFor(id), openDefaultsFor(id, docker));
|
|
22
42
|
return createAiRuntime({
|
|
23
43
|
...(options.ai ?? {}),
|
|
24
|
-
defaultDriver:
|
|
44
|
+
defaultDriver: driver,
|
|
25
45
|
gates: options.ai?.gates ?? gate,
|
|
26
46
|
now,
|
|
27
47
|
});
|
|
28
48
|
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the configured AI driver for one environment.
|
|
52
|
+
*
|
|
53
|
+
* Dev / test default is `mock`. There is **no** production default — prod must
|
|
54
|
+
* declare. Docker may override via `OKE_AI_DRIVER`.
|
|
55
|
+
*
|
|
56
|
+
* @param options - Boot options
|
|
57
|
+
* @param env - Active environment
|
|
58
|
+
* @param docker - Docker mode
|
|
59
|
+
*/
|
|
60
|
+
export function resolveAiDriverId(options: BootOptions, env: ConfigEnv, docker = false): string {
|
|
61
|
+
const fromEnv = process.env.OKE_AI_DRIVER?.trim();
|
|
62
|
+
if (docker && fromEnv) return fromEnv;
|
|
63
|
+
const resolved = resolveDriverId(options.config?.drivers?.ai, env);
|
|
64
|
+
if (resolved) return resolved;
|
|
65
|
+
return "mock";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Single id → AI driver switch — shared by boot (and future Console) so
|
|
70
|
+
* resolution can never drift into two maintained copies.
|
|
71
|
+
*
|
|
72
|
+
* @param id - Protocol driver id
|
|
73
|
+
*/
|
|
74
|
+
export function aiDriverFor(id: string): AiDriver {
|
|
75
|
+
switch (id) {
|
|
76
|
+
case "mock":
|
|
77
|
+
return mockAiDriver;
|
|
78
|
+
case "anthropic":
|
|
79
|
+
return anthropicAiDriver;
|
|
80
|
+
case "openai-compatible":
|
|
81
|
+
return openaiCompatibleAiDriver;
|
|
82
|
+
case "ollama":
|
|
83
|
+
return ollamaAiDriver;
|
|
84
|
+
case "bedrock":
|
|
85
|
+
case "vertex":
|
|
86
|
+
throw new Error(`oke boot: AI driver "${id}" is reserved but not implemented yet`);
|
|
87
|
+
default:
|
|
88
|
+
throw new Error(`oke boot: unknown AI driver "${id}"`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Open defaults for a resolved driver id (URL / key from env).
|
|
94
|
+
*
|
|
95
|
+
* @param id - Driver id
|
|
96
|
+
* @param docker - Docker mode
|
|
97
|
+
*/
|
|
98
|
+
export function openDefaultsFor(id: string, docker = false): AiOpenOptions {
|
|
99
|
+
if (id === "ollama") {
|
|
100
|
+
return {
|
|
101
|
+
baseUrl: aiUrlFor(docker),
|
|
102
|
+
...(process.env.OKE_AI_MODEL?.trim() ? { model: process.env.OKE_AI_MODEL.trim() } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (id === "anthropic") {
|
|
106
|
+
const apiKey = process.env.ANTHROPIC_API_KEY?.trim();
|
|
107
|
+
return {
|
|
108
|
+
...(apiKey ? { apiKey } : {}),
|
|
109
|
+
...(process.env.ANTHROPIC_MODEL?.trim() ? { model: process.env.ANTHROPIC_MODEL.trim() } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (id === "openai-compatible") {
|
|
113
|
+
const apiKey = process.env.OPENAI_API_KEY?.trim();
|
|
114
|
+
const baseUrl = process.env.OPENAI_BASE_URL?.trim() || process.env.OKE_AI_URL?.trim();
|
|
115
|
+
return {
|
|
116
|
+
...(apiKey ? { apiKey } : {}),
|
|
117
|
+
...(baseUrl ? { baseUrl } : {}),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Ollama base URL — docker fails loud when compose did not write one.
|
|
125
|
+
*
|
|
126
|
+
* @param docker - Docker mode
|
|
127
|
+
*/
|
|
128
|
+
export function aiUrlFor(docker = false): string {
|
|
129
|
+
const raw = process.env.OKE_AI_URL?.trim() || process.env.OLLAMA_HOST?.trim();
|
|
130
|
+
if (raw) return normalizeOllamaBaseUrl(raw);
|
|
131
|
+
if (docker) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
"oke boot: ollama driver needs OKE_AI_URL (did `oke dev -d` write docker/.env.docker?)",
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
return OLLAMA_DEFAULT_BASE_URL;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Merge fixed open defaults under per-call options.
|
|
141
|
+
*
|
|
142
|
+
* @param driver - Base driver
|
|
143
|
+
* @param defaults - Env-derived open options
|
|
144
|
+
*/
|
|
145
|
+
function withOpenDefaults(driver: AiDriver, defaults: AiOpenOptions): AiDriver {
|
|
146
|
+
if (Object.keys(defaults).length === 0) return driver;
|
|
147
|
+
return {
|
|
148
|
+
id: driver.id,
|
|
149
|
+
open: (opts = {}) => driver.open({ ...defaults, ...opts }),
|
|
150
|
+
};
|
|
151
|
+
}
|
package/src/kernel/boot.test.ts
CHANGED
|
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
|
|
|
79
79
|
expect(needs.signal).toBe(false);
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
-
test("oke() Store-only graph stays under the prior
|
|
82
|
+
test("oke() Store-only graph stays under the prior 50 kB baseline", async () => {
|
|
83
83
|
const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
|
|
84
84
|
const entry = join(dir, "entry.ts");
|
|
85
85
|
const appPath = join(import.meta.dir, "app.ts");
|
|
@@ -116,8 +116,9 @@ describe("boot — lazy element needs", () => {
|
|
|
116
116
|
if (raw.byteLength === 0) continue;
|
|
117
117
|
total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
|
|
118
118
|
}
|
|
119
|
-
//
|
|
120
|
-
|
|
119
|
+
// Rebased after AI stream/tool-loop + fx.t/i18n on the shared fx surface
|
|
120
|
+
// (~49.5 kB gzip). Still far below an eager full-element bind.
|
|
121
|
+
expect(total).toBeLessThan(50_000);
|
|
121
122
|
} finally {
|
|
122
123
|
await rm(dir, { recursive: true, force: true });
|
|
123
124
|
}
|
package/src/kernel/boot.ts
CHANGED
|
@@ -388,7 +388,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
|
|
|
388
388
|
// 6. AI
|
|
389
389
|
let ai = pre.ai;
|
|
390
390
|
if (needs.ai && !ai) {
|
|
391
|
-
ai = aiBind!.bindAi(options, gate, now);
|
|
391
|
+
ai = aiBind!.bindAi(options, gate, now, env, docker);
|
|
392
392
|
}
|
|
393
393
|
|
|
394
394
|
// 7. Runs
|
package/src/kernel/errors.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { docsUrl as absoluteDocsUrl } from "../docs-origin.ts";
|
|
10
|
+
import { getActiveDefaultLocale, getActiveLocale } from "../i18n/locale-context.ts";
|
|
11
|
+
import { getMessageCatalogs, translate } from "../i18n/messages.ts";
|
|
12
|
+
import { resolveFailureMessage } from "../i18n/failure-message.ts";
|
|
10
13
|
|
|
11
14
|
/** Numeric OKE error code (permanent once published). */
|
|
12
15
|
export type OkeErrorCode = number;
|
|
@@ -43,10 +46,11 @@ export class OkeError extends Error {
|
|
|
43
46
|
/**
|
|
44
47
|
* @param definition - Registry entry
|
|
45
48
|
* @param params - Values for `{placeholders}` in cause/fix
|
|
49
|
+
* @param locale - Optional locale (defaults to active request locale)
|
|
46
50
|
*/
|
|
47
|
-
constructor(definition: OkeErrorDefinition, params: OkeErrorParams = {}) {
|
|
48
|
-
const causeText =
|
|
49
|
-
const fix =
|
|
51
|
+
constructor(definition: OkeErrorDefinition, params: OkeErrorParams = {}, locale?: string) {
|
|
52
|
+
const causeText = localizeOkePart(definition.code, "cause", definition.cause, params, locale);
|
|
53
|
+
const fix = localizeOkePart(definition.code, "fix", definition.fix, params, locale);
|
|
50
54
|
const docsUrl = absoluteDocsUrl(`/e/${definition.code}`);
|
|
51
55
|
const message = formatOkeMessage(definition.code, causeText, fix, docsUrl);
|
|
52
56
|
super(message);
|
|
@@ -184,13 +188,18 @@ export function lookupOkeError(code: OkeErrorCode): OkeErrorDefinition | undefin
|
|
|
184
188
|
/**
|
|
185
189
|
* Create a flow-boundary failure value (does not throw).
|
|
186
190
|
*
|
|
191
|
+
* When `opts.message` is omitted, attaches a localized message from the
|
|
192
|
+
* built-in / app catalogs (`errors.{code}.{reason}` → `errors.{code}`) using
|
|
193
|
+
* the active request locale. Custom codes with no catalog entry stay
|
|
194
|
+
* message-less.
|
|
195
|
+
*
|
|
187
196
|
* @param code - Declared error code from the flow's `errors` map
|
|
188
197
|
* @param data - Error payload
|
|
189
198
|
* @param opts - Optional message override
|
|
190
199
|
*/
|
|
191
200
|
export function fail<E>(code: string, data: E, opts?: FailOptions): FlowFailure<E> {
|
|
192
|
-
const
|
|
193
|
-
|
|
201
|
+
const message = opts?.message !== undefined ? opts.message : resolveFailureMessage(code, data);
|
|
202
|
+
const error: FlowErrorValue<E> = message !== undefined ? { code, data, message } : { code, data };
|
|
194
203
|
return { data: null, error };
|
|
195
204
|
}
|
|
196
205
|
|
|
@@ -216,3 +225,45 @@ function interpolate(template: string, params: OkeErrorParams): string {
|
|
|
216
225
|
return params[key] ?? `{${key}}`;
|
|
217
226
|
});
|
|
218
227
|
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Localize one OKE cause/fix line from catalogs, falling back to the registry
|
|
231
|
+
* English template.
|
|
232
|
+
*
|
|
233
|
+
* @param code - Numeric OKE code
|
|
234
|
+
* @param part - `cause` or `fix`
|
|
235
|
+
* @param fallback - Registry English template
|
|
236
|
+
* @param params - Interpolation params
|
|
237
|
+
* @param locale - Optional locale override
|
|
238
|
+
*/
|
|
239
|
+
function localizeOkePart(
|
|
240
|
+
code: OkeErrorCode,
|
|
241
|
+
part: "cause" | "fix",
|
|
242
|
+
fallback: string,
|
|
243
|
+
params: OkeErrorParams,
|
|
244
|
+
locale?: string,
|
|
245
|
+
): string {
|
|
246
|
+
const key = `oke.${code}.${part}`;
|
|
247
|
+
const catalogs = getMessageCatalogs();
|
|
248
|
+
const active = locale ?? getActiveLocale("en");
|
|
249
|
+
const defaultLocale = getActiveDefaultLocale("en");
|
|
250
|
+
if (catalogs[active]?.[key] === undefined && catalogs[defaultLocale]?.[key] === undefined) {
|
|
251
|
+
return interpolate(fallback, params);
|
|
252
|
+
}
|
|
253
|
+
const formatted = translate({
|
|
254
|
+
locale: active,
|
|
255
|
+
defaultLocale,
|
|
256
|
+
catalogs,
|
|
257
|
+
key,
|
|
258
|
+
values: params,
|
|
259
|
+
});
|
|
260
|
+
// Catalog returned uninterpolated ICU (missing args) → registry template.
|
|
261
|
+
if (
|
|
262
|
+
Object.keys(params).length === 0 ||
|
|
263
|
+
formatted.includes("{flow}") ||
|
|
264
|
+
formatted.includes("{resource}")
|
|
265
|
+
) {
|
|
266
|
+
return interpolate(fallback, params);
|
|
267
|
+
}
|
|
268
|
+
return formatted;
|
|
269
|
+
}
|
package/src/kernel/fx.test.ts
CHANGED
|
@@ -160,6 +160,7 @@ describe("fx — wholesale swap", () => {
|
|
|
160
160
|
error() {},
|
|
161
161
|
},
|
|
162
162
|
t: (key) => key,
|
|
163
|
+
locale: "en",
|
|
163
164
|
id: () => "fixed-id",
|
|
164
165
|
auth: { userId: "u1", scopes: new Set(["a"]) },
|
|
165
166
|
operator: { id: null },
|
|
@@ -216,6 +217,32 @@ describe("fx — wholesale swap", () => {
|
|
|
216
217
|
});
|
|
217
218
|
});
|
|
218
219
|
|
|
220
|
+
describe("fx.t — catalogs", () => {
|
|
221
|
+
test("resolves locale then default with ICU values", () => {
|
|
222
|
+
const fx = createFx({
|
|
223
|
+
flow: "x",
|
|
224
|
+
effects: {},
|
|
225
|
+
i18n: {
|
|
226
|
+
locale: "ar",
|
|
227
|
+
defaultLocale: "en",
|
|
228
|
+
catalogs: {
|
|
229
|
+
en: {
|
|
230
|
+
"errors.notFound": "Not found",
|
|
231
|
+
greeting: "Hello, {name}",
|
|
232
|
+
items: "{count, plural, one {# item} other {# items}}",
|
|
233
|
+
},
|
|
234
|
+
ar: { greeting: "مرحباً، {name}" },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
expect(fx.locale).toBe("ar");
|
|
239
|
+
expect(fx.t("greeting", { name: "Ada" })).toBe("مرحباً، Ada");
|
|
240
|
+
expect(fx.t("errors.notFound")).toBe("Not found");
|
|
241
|
+
expect(fx.t("items", { count: 2 })).toBe("2 items");
|
|
242
|
+
expect(fx.t("missing")).toBe("missing");
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
219
246
|
describe("errors — registry", () => {
|
|
220
247
|
test("OKE1042 is reserved with the §21 shape", () => {
|
|
221
248
|
const def = OKE_ERRORS.ORPHAN_EMIT;
|
package/src/kernel/fx.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
touchDryRunStore,
|
|
33
33
|
} from "./dry-run.ts";
|
|
34
34
|
import { fail, type FailOptions, type FlowFailure } from "./errors.ts";
|
|
35
|
-
import { currentAbortSignal } from "./abort-scope.ts";
|
|
35
|
+
import { currentAbortSignal, linkAbort } from "./abort-scope.ts";
|
|
36
36
|
import {
|
|
37
37
|
fxAll,
|
|
38
38
|
fxRace,
|
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
import { maskRedactedDeep, Redacted } from "./redacted.ts";
|
|
45
45
|
import type { JournalSession } from "./journal.ts";
|
|
46
46
|
import type { RunTelemetry } from "./run-telemetry.ts";
|
|
47
|
+
import { translate, type MessageCatalogs } from "../i18n/messages.ts";
|
|
48
|
+
import type { AppMessageKey, MessageValues } from "../i18n/types.ts";
|
|
47
49
|
|
|
48
50
|
export type { FxRetryOptions, FxThunk } from "./concurrency.ts";
|
|
49
51
|
|
|
@@ -217,11 +219,21 @@ export interface FxSendOptions {
|
|
|
217
219
|
readonly to?: string;
|
|
218
220
|
readonly via?: readonly NamedRef[];
|
|
219
221
|
readonly data?: Record<string, unknown>;
|
|
222
|
+
/** Explicit recipient locale (wins over profile / Accept-Language). */
|
|
223
|
+
readonly locale?: string;
|
|
224
|
+
/** Profile locale for the channel resolution chain. */
|
|
225
|
+
readonly profileLocale?: string;
|
|
226
|
+
/** Raw `Accept-Language` header value. */
|
|
227
|
+
readonly acceptLanguage?: string;
|
|
220
228
|
}
|
|
221
229
|
|
|
222
230
|
/** Options for {@link Fx.ask}. */
|
|
223
231
|
export interface FxAskOptions {
|
|
224
232
|
readonly via?: readonly NamedRef[];
|
|
233
|
+
/** Flow refs offered as tools — each model call goes through `fx.call`. */
|
|
234
|
+
readonly tools?: readonly NamedRef[];
|
|
235
|
+
/** Bound on tool invocations (default 6). */
|
|
236
|
+
readonly maxSteps?: number;
|
|
225
237
|
}
|
|
226
238
|
|
|
227
239
|
/** Options for {@link Fx.search}. */
|
|
@@ -349,12 +361,18 @@ export interface Fx {
|
|
|
349
361
|
/** Logger. */
|
|
350
362
|
readonly log: FxLog;
|
|
351
363
|
/**
|
|
352
|
-
*
|
|
364
|
+
* Localized ICU message from registered `defineLocale` catalogs.
|
|
365
|
+
* Falls back through the active locale → `i18n.default` → the key.
|
|
353
366
|
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
367
|
+
* Augment `Register` (`declare module "okengine"`) with `messages` for
|
|
368
|
+
* key autocomplete and compile-time typos.
|
|
369
|
+
*
|
|
370
|
+
* @param key - Dot-separated message key
|
|
371
|
+
* @param values - ICU values (interpolation, plurals, select, rich tags)
|
|
356
372
|
*/
|
|
357
|
-
t(key:
|
|
373
|
+
t(key: AppMessageKey, values?: MessageValues): string;
|
|
374
|
+
/** Active locale for {@link Fx.t} and default channel sends. */
|
|
375
|
+
readonly locale: string;
|
|
358
376
|
/** Generate a unique id (UUID). */
|
|
359
377
|
id(): string;
|
|
360
378
|
/** User-plane auth principal. */
|
|
@@ -515,6 +533,15 @@ export interface CreateFxOptions {
|
|
|
515
533
|
readonly journal?: JournalSession;
|
|
516
534
|
/** When true (or when `journal` is set), journal every fx call. */
|
|
517
535
|
readonly durable?: boolean;
|
|
536
|
+
/**
|
|
537
|
+
* i18n for {@link Fx.t}. When omitted, `fx.t` returns the key
|
|
538
|
+
* (optionally with a JSON params suffix) — same as an empty catalog.
|
|
539
|
+
*/
|
|
540
|
+
readonly i18n?: {
|
|
541
|
+
readonly locale?: string;
|
|
542
|
+
readonly defaultLocale?: string;
|
|
543
|
+
readonly catalogs?: MessageCatalogs;
|
|
544
|
+
};
|
|
518
545
|
}
|
|
519
546
|
|
|
520
547
|
/** Bundle returned by {@link createFxContext}. */
|
|
@@ -560,6 +587,9 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
560
587
|
};
|
|
561
588
|
const operator: FxOperator = options.operator ?? { id: null };
|
|
562
589
|
const tenant: FxTenant = options.tenant ?? { id: null };
|
|
590
|
+
const defaultLocale = options.i18n?.defaultLocale ?? "en";
|
|
591
|
+
const locale = options.i18n?.locale ?? defaultLocale;
|
|
592
|
+
const catalogs = options.i18n?.catalogs ?? {};
|
|
563
593
|
const principal: FxPrincipal =
|
|
564
594
|
options.principal ??
|
|
565
595
|
({
|
|
@@ -1030,6 +1060,9 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1030
1060
|
to: opts?.to ?? "",
|
|
1031
1061
|
data: opts?.data,
|
|
1032
1062
|
via: opts?.via?.map(resolveName),
|
|
1063
|
+
locale: opts?.locale ?? locale,
|
|
1064
|
+
profileLocale: opts?.profileLocale,
|
|
1065
|
+
acceptLanguage: opts?.acceptLanguage,
|
|
1033
1066
|
});
|
|
1034
1067
|
return { ok: result.ok as true };
|
|
1035
1068
|
}
|
|
@@ -1047,6 +1080,10 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1047
1080
|
if (options.aiRuntime) {
|
|
1048
1081
|
return options.aiRuntime.ask(name, input, {
|
|
1049
1082
|
via: opts?.via?.map(resolveName),
|
|
1083
|
+
tools: opts?.tools?.map(resolveName),
|
|
1084
|
+
maxSteps: opts?.maxSteps,
|
|
1085
|
+
// Host fx.call — same capability / ledger / Runs path as any call.
|
|
1086
|
+
callTool: (tool, toolInput) => fx.call(tool, toolInput),
|
|
1050
1087
|
});
|
|
1051
1088
|
}
|
|
1052
1089
|
return {};
|
|
@@ -1093,6 +1130,7 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1093
1130
|
scopes: auth.scopes,
|
|
1094
1131
|
verified: auth.verified,
|
|
1095
1132
|
},
|
|
1133
|
+
callTool: (tool, toolInput) => fx.call(tool, toolInput),
|
|
1096
1134
|
});
|
|
1097
1135
|
}
|
|
1098
1136
|
return { ok: true, steps: 0, denials: [], output: input };
|
|
@@ -1102,27 +1140,45 @@ export function createFxContext(options: CreateFxOptions): FxContext {
|
|
|
1102
1140
|
const name = resolveName(model);
|
|
1103
1141
|
const chunks = (async function* () {
|
|
1104
1142
|
await gated("ask", name, async () => undefined);
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
? JSON.stringify(opts.data)
|
|
1108
|
-
: String(opts?.prompt ?? "");
|
|
1109
|
-
if (text.length === 0) {
|
|
1110
|
-
yield "";
|
|
1143
|
+
if (isDryRun()) {
|
|
1144
|
+
recordWouldHaveFired("ask", name);
|
|
1111
1145
|
return;
|
|
1112
1146
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1147
|
+
if (!options.aiRuntime) {
|
|
1148
|
+
throw new Error(`fx.stream: AI runtime is not configured for model "${name}"`);
|
|
1149
|
+
}
|
|
1150
|
+
// One cancellation channel: ambient ALS signal (Prompt 57) + local
|
|
1151
|
+
// controller aborted when the consumer stops iterating.
|
|
1152
|
+
const ambient = currentAbortSignal();
|
|
1153
|
+
const local = new AbortController();
|
|
1154
|
+
const unlink = linkAbort(ambient, local);
|
|
1155
|
+
try {
|
|
1156
|
+
for await (const text of options.aiRuntime.stream(name, {
|
|
1157
|
+
prompt: opts?.prompt,
|
|
1158
|
+
data: opts?.data,
|
|
1159
|
+
signal: local.signal,
|
|
1160
|
+
})) {
|
|
1161
|
+
if (local.signal.aborted) break;
|
|
1162
|
+
yield text;
|
|
1163
|
+
}
|
|
1164
|
+
} finally {
|
|
1165
|
+
unlink();
|
|
1166
|
+
if (!local.signal.aborted) local.abort();
|
|
1117
1167
|
}
|
|
1118
1168
|
})();
|
|
1119
1169
|
return chunks;
|
|
1120
1170
|
},
|
|
1121
1171
|
log,
|
|
1122
|
-
t(key,
|
|
1123
|
-
|
|
1124
|
-
|
|
1172
|
+
t(key, values) {
|
|
1173
|
+
return translate({
|
|
1174
|
+
locale,
|
|
1175
|
+
defaultLocale,
|
|
1176
|
+
catalogs,
|
|
1177
|
+
key,
|
|
1178
|
+
values,
|
|
1179
|
+
});
|
|
1125
1180
|
},
|
|
1181
|
+
locale,
|
|
1126
1182
|
id() {
|
|
1127
1183
|
return crypto.randomUUID();
|
|
1128
1184
|
},
|
|
@@ -33,6 +33,7 @@ describe("gateDenialFailure", () => {
|
|
|
33
33
|
},
|
|
34
34
|
);
|
|
35
35
|
expect(anon.error.code).toBe("Unauthorized");
|
|
36
|
+
expect(anon.error.message).toBe("Authentication required.");
|
|
36
37
|
|
|
37
38
|
const forbid = gateDenialFailure(
|
|
38
39
|
{ name: "order:create", kind: "policy", allowed: false },
|
|
@@ -42,6 +43,8 @@ describe("gateDenialFailure", () => {
|
|
|
42
43
|
},
|
|
43
44
|
);
|
|
44
45
|
expect(forbid.error.code).toBe("Forbidden");
|
|
46
|
+
expect(forbid.error.data).toEqual({ gate: "order:create", reason: "policy_denied" });
|
|
47
|
+
expect(forbid.error.message).toBe("Policy denied this request.");
|
|
45
48
|
|
|
46
49
|
const rate = gateDenialFailure(
|
|
47
50
|
{
|
|
@@ -58,6 +61,7 @@ describe("gateDenialFailure", () => {
|
|
|
58
61
|
);
|
|
59
62
|
expect(rate.error.code).toBe("RateLimited");
|
|
60
63
|
expect(rate.error.data).toEqual({ retryAfterMs: 1500 });
|
|
64
|
+
expect(rate.error.message).toBe("Too many requests. Try again later.");
|
|
61
65
|
});
|
|
62
66
|
});
|
|
63
67
|
|
package/src/kernel/pipeline.ts
CHANGED
package/src/kernel/plugin.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import type { ChannelTemplateDecl } from "../elements/channel/declare.ts";
|
|
18
|
+
import type { TemplateCatalog } from "../elements/channel/runtime.ts";
|
|
18
19
|
import type { ClockDecl } from "../elements/clock/declare.ts";
|
|
19
20
|
import type { GateDecl } from "../elements/gate/declare.ts";
|
|
20
21
|
import type { SignalDecl } from "../elements/signal/declare.ts";
|
|
@@ -288,6 +289,12 @@ export interface PluginApi {
|
|
|
288
289
|
* @param decl - Template from `channel.email.template(...)` etc.
|
|
289
290
|
*/
|
|
290
291
|
channelTemplate(decl: ChannelTemplateDecl): PluginApi;
|
|
292
|
+
/**
|
|
293
|
+
* Contribute template body catalog entries (merged into boot channel catalog).
|
|
294
|
+
*
|
|
295
|
+
* @param catalog - Locale bodies keyed by template name (`{{field}}` interpolation)
|
|
296
|
+
*/
|
|
297
|
+
channelCatalog(catalog: TemplateCatalog): PluginApi;
|
|
291
298
|
}
|
|
292
299
|
|
|
293
300
|
/** Captured capability lists for the Manifest. */
|
|
@@ -328,6 +335,7 @@ export interface PluginRegistration {
|
|
|
328
335
|
readonly signals: readonly SignalDecl[];
|
|
329
336
|
readonly gates: readonly GateDecl[];
|
|
330
337
|
readonly channelTemplates: readonly ChannelTemplateDecl[];
|
|
338
|
+
readonly channelCatalogs: readonly TemplateCatalog[];
|
|
331
339
|
}
|
|
332
340
|
|
|
333
341
|
/**
|
|
@@ -467,6 +475,8 @@ export interface PluginDef<D extends Record<string, unknown> = {}> {
|
|
|
467
475
|
gate(decl: GateDecl): PluginDef<D>;
|
|
468
476
|
/** Queue a channel template. */
|
|
469
477
|
channelTemplate(decl: ChannelTemplateDecl): PluginDef<D>;
|
|
478
|
+
/** Queue channel template body catalog entries. */
|
|
479
|
+
channelCatalog(catalog: TemplateCatalog): PluginDef<D>;
|
|
470
480
|
}
|
|
471
481
|
|
|
472
482
|
/** Extract accumulated decoration types from a {@link PluginDef}. */
|
|
@@ -624,6 +634,12 @@ export function plugin(name: string, options: PluginOptions): PluginDef {
|
|
|
624
634
|
});
|
|
625
635
|
return def;
|
|
626
636
|
},
|
|
637
|
+
channelCatalog(catalog) {
|
|
638
|
+
steps.push((api) => {
|
|
639
|
+
api.channelCatalog(catalog);
|
|
640
|
+
});
|
|
641
|
+
return def;
|
|
642
|
+
},
|
|
627
643
|
};
|
|
628
644
|
|
|
629
645
|
return def;
|
package/src/kernel/registry.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ChannelTemplateDecl } from "../elements/channel/declare.ts";
|
|
16
|
+
import type { TemplateCatalog } from "../elements/channel/runtime.ts";
|
|
16
17
|
import type { ClockDecl } from "../elements/clock/declare.ts";
|
|
17
18
|
import type { GateDecl } from "../elements/gate/declare.ts";
|
|
18
19
|
import type { SignalDecl } from "../elements/signal/declare.ts";
|
|
@@ -85,6 +86,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
|
|
|
85
86
|
const signals: SignalDecl[] = [];
|
|
86
87
|
const gates: GateDecl[] = [];
|
|
87
88
|
const channelTemplates: ChannelTemplateDecl[] = [];
|
|
89
|
+
const channelCatalogs: TemplateCatalog[] = [];
|
|
88
90
|
const needs: string[] = [];
|
|
89
91
|
const declares: string[] = [];
|
|
90
92
|
const intercepts: string[] = [];
|
|
@@ -204,6 +206,13 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
|
|
|
204
206
|
pushDeclare(`channel:${decl.name}`);
|
|
205
207
|
return api;
|
|
206
208
|
},
|
|
209
|
+
channelCatalog(catalog) {
|
|
210
|
+
channelCatalogs.push(catalog);
|
|
211
|
+
for (const name of Object.keys(catalog)) {
|
|
212
|
+
pushDeclare(`channel-catalog:${name}`);
|
|
213
|
+
}
|
|
214
|
+
return api;
|
|
215
|
+
},
|
|
207
216
|
};
|
|
208
217
|
|
|
209
218
|
return {
|
|
@@ -238,6 +247,7 @@ export function createRecordingApi(identity: { readonly name: string; readonly v
|
|
|
238
247
|
signals: signals.slice(),
|
|
239
248
|
gates: gates.slice(),
|
|
240
249
|
channelTemplates: channelTemplates.slice(),
|
|
250
|
+
channelCatalogs: channelCatalogs.slice(),
|
|
241
251
|
};
|
|
242
252
|
},
|
|
243
253
|
};
|
|
@@ -318,6 +328,8 @@ export interface PluginRegistry {
|
|
|
318
328
|
gateContributions(): readonly GateDecl[];
|
|
319
329
|
/** Channel templates from installed plugins. */
|
|
320
330
|
channelTemplateContributions(): readonly ChannelTemplateDecl[];
|
|
331
|
+
/** Channel template body catalogs from installed plugins. */
|
|
332
|
+
channelCatalogContributions(): readonly TemplateCatalog[];
|
|
321
333
|
/** HTTP Bindings from installed plugins (auth method plugins). */
|
|
322
334
|
bindingContributions(): readonly Binding[];
|
|
323
335
|
}
|
|
@@ -489,6 +501,9 @@ export function createPluginRegistry(): PluginRegistry {
|
|
|
489
501
|
channelTemplateContributions() {
|
|
490
502
|
return firstPerPlugin(installed, (r) => r.channelTemplates);
|
|
491
503
|
},
|
|
504
|
+
channelCatalogContributions() {
|
|
505
|
+
return firstPerPlugin(installed, (r) => r.channelCatalogs);
|
|
506
|
+
},
|
|
492
507
|
bindingContributions() {
|
|
493
508
|
return firstPerPlugin(installed, (r) => r.bindings);
|
|
494
509
|
},
|
|
@@ -14,7 +14,11 @@ import type { Binding } from "../../kernel/on.ts";
|
|
|
14
14
|
import { http } from "../../kernel/triggers.ts";
|
|
15
15
|
import { createEnv } from "../../runtime/primitives.ts";
|
|
16
16
|
|
|
17
|
-
export const AuthFailed = z.object({
|
|
17
|
+
export const AuthFailed = z.object({
|
|
18
|
+
reason: z.string().optional(),
|
|
19
|
+
/** Policy failure details (`username_policy` / `password_policy`). */
|
|
20
|
+
reasons: z.array(z.string()).optional(),
|
|
21
|
+
});
|
|
18
22
|
export const AuthRateLimited = z.object({ reason: z.string() });
|
|
19
23
|
|
|
20
24
|
export const SessionTokensOut = z.object({
|