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
package/src/kernel/app.ts
CHANGED
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
type GateOptions,
|
|
36
36
|
type ResolvedGateConfig,
|
|
37
37
|
} from "../elements/gate/config.ts";
|
|
38
|
+
import type { TemplateCatalog } from "../elements/channel/runtime.ts";
|
|
39
|
+
import { parseAcceptLanguage } from "../elements/channel/locale.ts";
|
|
40
|
+
import { runWithLocale } from "../i18n/locale-context.ts";
|
|
41
|
+
import { getMessageCatalogs, matchConfiguredLocale } from "../i18n/messages.ts";
|
|
38
42
|
import { runDurable } from "../elements/clock/durable.ts";
|
|
39
43
|
import { isFlow, type AnyFlowDef } from "./flow.ts";
|
|
40
44
|
import { fxRetry } from "./concurrency.ts";
|
|
@@ -567,6 +571,9 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
567
571
|
secret: authBinding.secret,
|
|
568
572
|
sessions: authBinding.sessions,
|
|
569
573
|
now: authBinding.now,
|
|
574
|
+
passwordPolicy: gateConfig.auth?.passwordPolicy,
|
|
575
|
+
password: gateConfig.auth?.password,
|
|
576
|
+
breachCheck: gateConfig.auth?.breachCheck,
|
|
570
577
|
});
|
|
571
578
|
} else {
|
|
572
579
|
setActiveGateAuthContext(undefined);
|
|
@@ -663,11 +670,14 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
663
670
|
const pluginSignals = pluginRegistry.signalContributions();
|
|
664
671
|
const pluginClocks = pluginRegistry.clockContributions();
|
|
665
672
|
const pluginChannelTemplates = pluginRegistry.channelTemplateContributions();
|
|
673
|
+
const pluginChannelCatalogs = pluginRegistry.channelCatalogContributions();
|
|
666
674
|
if (pluginSecrets.length > 0) available.add("vault");
|
|
667
675
|
if (pluginGates.length > 0) available.add("gate");
|
|
668
676
|
if (pluginSignals.length > 0) available.add("signal");
|
|
669
677
|
if (pluginClocks.length > 0) available.add("clock");
|
|
670
|
-
if (pluginChannelTemplates.length > 0
|
|
678
|
+
if (pluginChannelTemplates.length > 0 || pluginChannelCatalogs.length > 0) {
|
|
679
|
+
available.add("channel");
|
|
680
|
+
}
|
|
671
681
|
// gate.auth (auto-absorbed auth plugin) satisfies `.needs("auth")`.
|
|
672
682
|
if (gateConfig.auth) available.add("auth");
|
|
673
683
|
assertPluginNeeds(caps, { pluginNames, available });
|
|
@@ -676,6 +686,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
676
686
|
const baseSignals = overrides?.signals ?? options.signals ?? [];
|
|
677
687
|
const baseClocks = overrides?.clocks ?? options.clocks ?? [];
|
|
678
688
|
const baseChannel = overrides?.channel ?? options.channel;
|
|
689
|
+
const mergedCatalog = mergeTemplateCatalogs(baseChannel?.catalog, ...pluginChannelCatalogs);
|
|
679
690
|
|
|
680
691
|
const merged: BootOptions = {
|
|
681
692
|
env: bootEnv,
|
|
@@ -692,6 +703,11 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
692
703
|
channel: {
|
|
693
704
|
...(baseChannel ?? {}),
|
|
694
705
|
templates: [...(baseChannel?.templates ?? []), ...pluginChannelTemplates],
|
|
706
|
+
...(mergedCatalog ? { catalog: mergedCatalog } : {}),
|
|
707
|
+
defaultLocale:
|
|
708
|
+
baseChannel?.defaultLocale ??
|
|
709
|
+
(overrides?.config ?? options.config)?.i18n?.default ??
|
|
710
|
+
"en",
|
|
695
711
|
},
|
|
696
712
|
ai: overrides?.ai ?? options.ai,
|
|
697
713
|
runs: overrides?.runs ?? options.runs,
|
|
@@ -754,10 +770,52 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
754
770
|
readonly principal?: ResolvedPrincipal;
|
|
755
771
|
/** Frozen origin identity for {@link Fx.principal} across `fx.call`. */
|
|
756
772
|
readonly originPrincipal?: FxPrincipal;
|
|
773
|
+
/** Explicit locale override for {@link Fx.t} / channel sends. */
|
|
774
|
+
readonly locale?: string;
|
|
757
775
|
},
|
|
758
776
|
): Promise<ExecuteResult> {
|
|
759
777
|
registerFlow(flowDef);
|
|
760
778
|
|
|
779
|
+
const i18nConfig = options.config?.i18n;
|
|
780
|
+
const configuredLocales = i18nConfig?.locales ?? ["en", "ar"];
|
|
781
|
+
const defaultLocale = i18nConfig?.default ?? "en";
|
|
782
|
+
const acceptLanguage = extras?.request?.headers.get("accept-language") ?? undefined;
|
|
783
|
+
const resolvedLocale = matchConfiguredLocale(
|
|
784
|
+
extras?.locale ?? parseAcceptLanguage(acceptLanguage),
|
|
785
|
+
configuredLocales,
|
|
786
|
+
defaultLocale,
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
return runWithLocale({ locale: resolvedLocale, defaultLocale }, () =>
|
|
790
|
+
executeInLocale({
|
|
791
|
+
flowDef,
|
|
792
|
+
input,
|
|
793
|
+
trigger,
|
|
794
|
+
extras,
|
|
795
|
+
resolvedLocale,
|
|
796
|
+
defaultLocale,
|
|
797
|
+
}),
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
async function executeInLocale(args: {
|
|
802
|
+
readonly flowDef: AnyFlowDef;
|
|
803
|
+
readonly input: unknown;
|
|
804
|
+
readonly trigger: Trigger;
|
|
805
|
+
readonly extras?: {
|
|
806
|
+
readonly request?: Request;
|
|
807
|
+
readonly params?: Record<string, string>;
|
|
808
|
+
readonly validated?: boolean;
|
|
809
|
+
readonly auth?: ResolvedPrincipal | FxAuth;
|
|
810
|
+
readonly operator?: FxOperator;
|
|
811
|
+
readonly principal?: ResolvedPrincipal;
|
|
812
|
+
readonly originPrincipal?: FxPrincipal;
|
|
813
|
+
readonly locale?: string;
|
|
814
|
+
};
|
|
815
|
+
readonly resolvedLocale: string;
|
|
816
|
+
readonly defaultLocale: string;
|
|
817
|
+
}): Promise<ExecuteResult> {
|
|
818
|
+
const { flowDef, input, trigger, extras, resolvedLocale, defaultLocale } = args;
|
|
761
819
|
const booted = await ensureBoot();
|
|
762
820
|
|
|
763
821
|
const unitBag = flowDef.unit !== undefined ? unitHooks.get(flowDef.unit) : undefined;
|
|
@@ -860,6 +918,12 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
860
918
|
effects: flowDef.effects,
|
|
861
919
|
runTelemetry: telemetry,
|
|
862
920
|
now,
|
|
921
|
+
i18n: {
|
|
922
|
+
locale: resolvedLocale,
|
|
923
|
+
defaultLocale,
|
|
924
|
+
catalogs: getMessageCatalogs(),
|
|
925
|
+
...options.fx?.i18n,
|
|
926
|
+
},
|
|
863
927
|
...(principals ? { auth: principals.auth as FxAuth, operator: principals.operator } : {}),
|
|
864
928
|
...(extras?.originPrincipal ? { principal: extras.originPrincipal } : {}),
|
|
865
929
|
...(capability ? { capability } : {}),
|
|
@@ -882,7 +946,10 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
882
946
|
target,
|
|
883
947
|
callInput,
|
|
884
948
|
{ kind: "internal" } satisfies InternalTrigger,
|
|
885
|
-
{
|
|
949
|
+
{
|
|
950
|
+
originPrincipal: freezePrincipal(fx.principal),
|
|
951
|
+
locale: resolvedLocale,
|
|
952
|
+
},
|
|
886
953
|
);
|
|
887
954
|
if (inner.failure) return inner.failure;
|
|
888
955
|
return inner.output;
|
|
@@ -1300,5 +1367,28 @@ function archiveFromInput(
|
|
|
1300
1367
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
1301
1368
|
}
|
|
1302
1369
|
|
|
1370
|
+
/**
|
|
1371
|
+
* Deep-merge channel template catalogs (later parts win per locale).
|
|
1372
|
+
*
|
|
1373
|
+
* @param parts - Catalog fragments (undefined skipped)
|
|
1374
|
+
*/
|
|
1375
|
+
function mergeTemplateCatalogs(
|
|
1376
|
+
...parts: readonly (TemplateCatalog | undefined)[]
|
|
1377
|
+
): TemplateCatalog | undefined {
|
|
1378
|
+
const out: Record<
|
|
1379
|
+
string,
|
|
1380
|
+
Record<string, { readonly subject?: string; readonly text?: string; readonly html?: string }>
|
|
1381
|
+
> = {};
|
|
1382
|
+
let any = false;
|
|
1383
|
+
for (const part of parts) {
|
|
1384
|
+
if (!part) continue;
|
|
1385
|
+
any = true;
|
|
1386
|
+
for (const [template, locales] of Object.entries(part)) {
|
|
1387
|
+
out[template] = { ...(out[template] ?? {}), ...locales };
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
return any ? out : undefined;
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1303
1393
|
/** @internal expose smart router type for tests */
|
|
1304
1394
|
export type { HttpTrigger, SmartRouter };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI boot binder — shared driver switch + fail-loud resolution.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { mockAiDriver } from "../../drivers/ai-mock.ts";
|
|
7
|
+
import { ollamaAiDriver } from "../../drivers/ai-ollama.ts";
|
|
8
|
+
import { aiDriverFor, aiUrlFor, bindAi, resolveAiDriverId } from "./ai.ts";
|
|
9
|
+
|
|
10
|
+
describe("ai boot binder", () => {
|
|
11
|
+
test("aiDriverFor maps protocol ids; unknown / reserved throw", () => {
|
|
12
|
+
expect(aiDriverFor("mock").id).toBe("mock");
|
|
13
|
+
expect(aiDriverFor("ollama")).toBe(ollamaAiDriver);
|
|
14
|
+
expect(() => aiDriverFor("bedrock")).toThrow(/not implemented/);
|
|
15
|
+
expect(() => aiDriverFor("nope")).toThrow(/unknown AI driver/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("resolveAiDriverId defaults to mock; honours config + OKE_AI_DRIVER in docker", () => {
|
|
19
|
+
expect(resolveAiDriverId({}, "local")).toBe("mock");
|
|
20
|
+
expect(resolveAiDriverId({ config: { drivers: { ai: { local: "ollama" } } } }, "local")).toBe(
|
|
21
|
+
"ollama",
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const prev = process.env.OKE_AI_DRIVER;
|
|
25
|
+
process.env.OKE_AI_DRIVER = "ollama";
|
|
26
|
+
try {
|
|
27
|
+
expect(resolveAiDriverId({}, "local", true)).toBe("ollama");
|
|
28
|
+
} finally {
|
|
29
|
+
if (prev === undefined) delete process.env.OKE_AI_DRIVER;
|
|
30
|
+
else process.env.OKE_AI_DRIVER = prev;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("aiUrlFor fails loud in docker without OKE_AI_URL", () => {
|
|
35
|
+
const prevUrl = process.env.OKE_AI_URL;
|
|
36
|
+
const prevHost = process.env.OLLAMA_HOST;
|
|
37
|
+
delete process.env.OKE_AI_URL;
|
|
38
|
+
delete process.env.OLLAMA_HOST;
|
|
39
|
+
try {
|
|
40
|
+
expect(() => aiUrlFor(true)).toThrow(/OKE_AI_URL/);
|
|
41
|
+
expect(aiUrlFor(false)).toBe("http://127.0.0.1:11434");
|
|
42
|
+
} finally {
|
|
43
|
+
if (prevUrl !== undefined) process.env.OKE_AI_URL = prevUrl;
|
|
44
|
+
if (prevHost !== undefined) process.env.OLLAMA_HOST = prevHost;
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("bindAi uses injected defaultDriver over config (tests stay on mock)", () => {
|
|
49
|
+
const runtime = bindAi(
|
|
50
|
+
{
|
|
51
|
+
config: { drivers: { ai: { local: "ollama" } } },
|
|
52
|
+
ai: { defaultDriver: mockAiDriver },
|
|
53
|
+
},
|
|
54
|
+
undefined,
|
|
55
|
+
() => 0,
|
|
56
|
+
"local",
|
|
57
|
+
);
|
|
58
|
+
expect(runtime.autoCacheDisabled).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Channel binder — profile selection +
|
|
2
|
+
* Channel binder — profile selection + env parsing.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
msegatOptionsFromEnv,
|
|
8
|
+
resendOptionsFromEnv,
|
|
9
|
+
resolveEmailDriverId,
|
|
10
|
+
resolveSmsDriverId,
|
|
11
|
+
smtpOptionsFromEnv,
|
|
12
|
+
sndrOptionsFromEnv,
|
|
13
|
+
taqnyatOptionsFromEnv,
|
|
14
|
+
unifonicOptionsFromEnv,
|
|
15
|
+
} from "./channel.ts";
|
|
7
16
|
|
|
8
17
|
const previous = {
|
|
9
18
|
url: process.env.SMTP_URL,
|
|
10
19
|
user: process.env.SMTP_USER,
|
|
11
20
|
password: process.env.SMTP_PASSWORD,
|
|
12
21
|
okeUrl: process.env.OKE_CHANNEL_EMAIL_URL,
|
|
22
|
+
resend: process.env.RESEND_API_KEY,
|
|
23
|
+
sndr: process.env.SNDR_API_KEY,
|
|
24
|
+
sndrBase: process.env.SNDR_BASE_URL,
|
|
25
|
+
taqBearer: process.env.TAQNYAT_BEARER_TOKEN,
|
|
26
|
+
taqSender: process.env.TAQNYAT_SENDER,
|
|
27
|
+
msegatUser: process.env.MSEGAT_USERNAME,
|
|
28
|
+
msegatKey: process.env.MSEGAT_API_KEY,
|
|
29
|
+
msegatSender: process.env.MSEGAT_SENDER,
|
|
30
|
+
unifonicSid: process.env.UNIFONIC_APPSID,
|
|
31
|
+
unifonicSender: process.env.UNIFONIC_SENDER,
|
|
13
32
|
};
|
|
14
33
|
|
|
15
34
|
afterEach(() => {
|
|
@@ -17,6 +36,16 @@ afterEach(() => {
|
|
|
17
36
|
restoreEnv("SMTP_USER", previous.user);
|
|
18
37
|
restoreEnv("SMTP_PASSWORD", previous.password);
|
|
19
38
|
restoreEnv("OKE_CHANNEL_EMAIL_URL", previous.okeUrl);
|
|
39
|
+
restoreEnv("RESEND_API_KEY", previous.resend);
|
|
40
|
+
restoreEnv("SNDR_API_KEY", previous.sndr);
|
|
41
|
+
restoreEnv("SNDR_BASE_URL", previous.sndrBase);
|
|
42
|
+
restoreEnv("TAQNYAT_BEARER_TOKEN", previous.taqBearer);
|
|
43
|
+
restoreEnv("TAQNYAT_SENDER", previous.taqSender);
|
|
44
|
+
restoreEnv("MSEGAT_USERNAME", previous.msegatUser);
|
|
45
|
+
restoreEnv("MSEGAT_API_KEY", previous.msegatKey);
|
|
46
|
+
restoreEnv("MSEGAT_SENDER", previous.msegatSender);
|
|
47
|
+
restoreEnv("UNIFONIC_APPSID", previous.unifonicSid);
|
|
48
|
+
restoreEnv("UNIFONIC_SENDER", previous.unifonicSender);
|
|
20
49
|
});
|
|
21
50
|
|
|
22
51
|
describe("bindChannel driver resolution", () => {
|
|
@@ -24,7 +53,8 @@ describe("bindChannel driver resolution", () => {
|
|
|
24
53
|
config: {
|
|
25
54
|
drivers: {
|
|
26
55
|
channel: {
|
|
27
|
-
email: { local: "console", docker: "smtp", test: "console", prod: "
|
|
56
|
+
email: { local: "console", docker: "smtp", test: "console", prod: "sndr" },
|
|
57
|
+
sms: { local: "console", docker: "taqnyat", test: "msegat", prod: "taqnyat" },
|
|
28
58
|
},
|
|
29
59
|
},
|
|
30
60
|
},
|
|
@@ -33,6 +63,13 @@ describe("bindChannel driver resolution", () => {
|
|
|
33
63
|
test("uses console locally and smtp in docker", () => {
|
|
34
64
|
expect(resolveEmailDriverId(options, "local", false)).toBe("console");
|
|
35
65
|
expect(resolveEmailDriverId(options, "docker", true)).toBe("smtp");
|
|
66
|
+
expect(resolveEmailDriverId(options, "prod", false)).toBe("sndr");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("resolves sms driver ids", () => {
|
|
70
|
+
expect(resolveSmsDriverId(options, "local")).toBe("console");
|
|
71
|
+
expect(resolveSmsDriverId(options, "docker")).toBe("taqnyat");
|
|
72
|
+
expect(resolveSmsDriverId(options, "test")).toBe("msegat");
|
|
36
73
|
});
|
|
37
74
|
|
|
38
75
|
test("parses SMTP_URL and optional auth overrides", () => {
|
|
@@ -52,6 +89,34 @@ describe("bindChannel driver resolution", () => {
|
|
|
52
89
|
delete process.env.OKE_CHANNEL_EMAIL_URL;
|
|
53
90
|
expect(() => smtpOptionsFromEnv(true)).toThrow("docker/.env.docker");
|
|
54
91
|
});
|
|
92
|
+
|
|
93
|
+
test("resend and sndr env helpers", () => {
|
|
94
|
+
process.env.RESEND_API_KEY = "re_x";
|
|
95
|
+
expect(resendOptionsFromEnv()).toEqual({ apiKey: "re_x" });
|
|
96
|
+
process.env.SNDR_API_KEY = "sndr_test_x";
|
|
97
|
+
process.env.SNDR_BASE_URL = "https://api.example.test";
|
|
98
|
+
expect(sndrOptionsFromEnv()).toEqual({
|
|
99
|
+
apiKey: "sndr_test_x",
|
|
100
|
+
url: "https://api.example.test",
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("taqnyat, msegat, and unifonic env helpers", () => {
|
|
105
|
+
process.env.TAQNYAT_BEARER_TOKEN = "bearer";
|
|
106
|
+
process.env.TAQNYAT_SENDER = "Brand";
|
|
107
|
+
expect(taqnyatOptionsFromEnv()).toEqual({ bearerToken: "bearer", sender: "Brand" });
|
|
108
|
+
process.env.MSEGAT_USERNAME = "user";
|
|
109
|
+
process.env.MSEGAT_API_KEY = "key";
|
|
110
|
+
process.env.MSEGAT_SENDER = "Brand";
|
|
111
|
+
expect(msegatOptionsFromEnv()).toEqual({
|
|
112
|
+
userName: "user",
|
|
113
|
+
apiKey: "key",
|
|
114
|
+
sender: "Brand",
|
|
115
|
+
});
|
|
116
|
+
process.env.UNIFONIC_APPSID = "sid";
|
|
117
|
+
process.env.UNIFONIC_SENDER = "Brand";
|
|
118
|
+
expect(unifonicOptionsFromEnv()).toEqual({ appSid: "sid", sender: "Brand" });
|
|
119
|
+
});
|
|
55
120
|
});
|
|
56
121
|
|
|
57
122
|
function restoreEnv(key: string, value: string | undefined): void {
|
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
|
|
6
6
|
import { openConsoleChannel } from "../../drivers/channel-console.ts";
|
|
7
|
+
import { openMsegatChannel } from "../../drivers/channel-msegat.ts";
|
|
8
|
+
import { openResendChannel } from "../../drivers/channel-resend.ts";
|
|
7
9
|
import { openSmtpChannel } from "../../drivers/channel-smtp.ts";
|
|
10
|
+
import { openSndrChannel } from "../../drivers/channel-sndr.ts";
|
|
11
|
+
import { openTaqnyatChannel } from "../../drivers/channel-taqnyat.ts";
|
|
12
|
+
import { openUnifonicChannel } from "../../drivers/channel-unifonic.ts";
|
|
8
13
|
import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
|
|
9
14
|
import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
|
|
10
15
|
import type { BootOptions } from "../boot.ts";
|
|
@@ -12,6 +17,9 @@ import type { BootOptions } from "../boot.ts";
|
|
|
12
17
|
/**
|
|
13
18
|
* Construct a Channel runtime (console inbox default).
|
|
14
19
|
*
|
|
20
|
+
* When `options.channel.drivers` is omitted, opens the configured email driver
|
|
21
|
+
* and, if `drivers.channel.sms` resolves, appends that SMS driver to the chain.
|
|
22
|
+
*
|
|
15
23
|
* @param options - Boot options
|
|
16
24
|
* @param env - Active environment
|
|
17
25
|
* @param now - Clock
|
|
@@ -25,18 +33,36 @@ export function bindChannel(
|
|
|
25
33
|
): ChannelRuntime {
|
|
26
34
|
return createChannelRuntime({
|
|
27
35
|
...(options.channel ?? {}),
|
|
28
|
-
drivers: options.channel?.drivers ??
|
|
36
|
+
drivers: options.channel?.drivers ?? defaultDrivers(options, env, docker),
|
|
29
37
|
now,
|
|
30
38
|
});
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
function
|
|
41
|
+
function defaultDrivers(options: BootOptions, env: ConfigEnv, docker: boolean): ChannelDriver[] {
|
|
42
|
+
const drivers: ChannelDriver[] = [emailDriverFor(options, env, docker)];
|
|
43
|
+
const sms = smsDriverFor(options, env);
|
|
44
|
+
if (sms) drivers.push(sms);
|
|
45
|
+
return drivers;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function emailDriverFor(options: BootOptions, env: ConfigEnv, docker: boolean): ChannelDriver {
|
|
34
49
|
const id = resolveEmailDriverId(options, env, docker);
|
|
35
50
|
if (id === "console") return openConsoleChannel();
|
|
36
51
|
if (id === "smtp") return openSmtpChannel(smtpOptionsFromEnv(docker));
|
|
52
|
+
if (id === "resend") return openResendChannel(resendOptionsFromEnv());
|
|
53
|
+
if (id === "sndr") return openSndrChannel(sndrOptionsFromEnv());
|
|
37
54
|
throw new Error(`oke boot: unknown email channel driver "${id}"`);
|
|
38
55
|
}
|
|
39
56
|
|
|
57
|
+
function smsDriverFor(options: BootOptions, env: ConfigEnv): ChannelDriver | undefined {
|
|
58
|
+
const id = resolveSmsDriverId(options, env);
|
|
59
|
+
if (!id || id === "console") return undefined;
|
|
60
|
+
if (id === "taqnyat") return openTaqnyatChannel(taqnyatOptionsFromEnv());
|
|
61
|
+
if (id === "msegat") return openMsegatChannel(msegatOptionsFromEnv());
|
|
62
|
+
if (id === "unifonic") return openUnifonicChannel(unifonicOptionsFromEnv());
|
|
63
|
+
throw new Error(`oke boot: unknown sms channel driver "${id}"`);
|
|
64
|
+
}
|
|
65
|
+
|
|
40
66
|
/**
|
|
41
67
|
* Resolve the configured email driver for one environment.
|
|
42
68
|
*
|
|
@@ -54,6 +80,16 @@ export function resolveEmailDriverId(
|
|
|
54
80
|
);
|
|
55
81
|
}
|
|
56
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the configured SMS driver for one environment.
|
|
85
|
+
*
|
|
86
|
+
* @param options - Boot options
|
|
87
|
+
* @param env - Active environment
|
|
88
|
+
*/
|
|
89
|
+
export function resolveSmsDriverId(options: BootOptions, env: ConfigEnv): string | undefined {
|
|
90
|
+
return resolveDriverId(options.config?.drivers?.channel?.sms, env);
|
|
91
|
+
}
|
|
92
|
+
|
|
57
93
|
/**
|
|
58
94
|
* Resolve SMTP connection options from `SMTP_URL` plus optional auth overrides.
|
|
59
95
|
*
|
|
@@ -81,3 +117,58 @@ export function smtpOptionsFromEnv(docker = false): ChannelOpenOptions {
|
|
|
81
117
|
...(pass ? { pass } : {}),
|
|
82
118
|
};
|
|
83
119
|
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Resolve Resend options from env.
|
|
123
|
+
*/
|
|
124
|
+
export function resendOptionsFromEnv(): ChannelOpenOptions {
|
|
125
|
+
const apiKey = process.env.RESEND_API_KEY?.trim();
|
|
126
|
+
if (!apiKey) throw new Error("oke boot: resend channel needs RESEND_API_KEY");
|
|
127
|
+
return { apiKey };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve SNDR options from env.
|
|
132
|
+
*/
|
|
133
|
+
export function sndrOptionsFromEnv(): ChannelOpenOptions {
|
|
134
|
+
const apiKey = process.env.SNDR_API_KEY?.trim();
|
|
135
|
+
if (!apiKey) throw new Error("oke boot: sndr channel needs SNDR_API_KEY");
|
|
136
|
+
const url = process.env.SNDR_BASE_URL?.trim();
|
|
137
|
+
return { apiKey, ...(url ? { url } : {}) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Resolve Taqnyat SMS options from env.
|
|
142
|
+
*/
|
|
143
|
+
export function taqnyatOptionsFromEnv(): ChannelOpenOptions {
|
|
144
|
+
const bearerToken = process.env.TAQNYAT_BEARER_TOKEN?.trim() ?? process.env.TAQNYAT_TOKEN?.trim();
|
|
145
|
+
const sender = process.env.TAQNYAT_SENDER?.trim();
|
|
146
|
+
if (!bearerToken) {
|
|
147
|
+
throw new Error("oke boot: taqnyat channel needs TAQNYAT_BEARER_TOKEN");
|
|
148
|
+
}
|
|
149
|
+
if (!sender) throw new Error("oke boot: taqnyat channel needs TAQNYAT_SENDER");
|
|
150
|
+
return { bearerToken, sender };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve Msegat SMS options from env.
|
|
155
|
+
*/
|
|
156
|
+
export function msegatOptionsFromEnv(): ChannelOpenOptions {
|
|
157
|
+
const userName = process.env.MSEGAT_USERNAME?.trim();
|
|
158
|
+
const apiKey = process.env.MSEGAT_API_KEY?.trim();
|
|
159
|
+
const sender = process.env.MSEGAT_SENDER?.trim();
|
|
160
|
+
if (!userName) throw new Error("oke boot: msegat channel needs MSEGAT_USERNAME");
|
|
161
|
+
if (!apiKey) throw new Error("oke boot: msegat channel needs MSEGAT_API_KEY");
|
|
162
|
+
if (!sender) throw new Error("oke boot: msegat channel needs MSEGAT_SENDER");
|
|
163
|
+
return { userName, apiKey, sender };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Resolve Unifonic SMS options from env.
|
|
168
|
+
*/
|
|
169
|
+
export function unifonicOptionsFromEnv(): ChannelOpenOptions {
|
|
170
|
+
const appSid = process.env.UNIFONIC_APPSID?.trim() ?? process.env.UNIFONIC_APP_SID?.trim();
|
|
171
|
+
const sender = process.env.UNIFONIC_SENDER?.trim();
|
|
172
|
+
if (!appSid) throw new Error("oke boot: unifonic channel needs UNIFONIC_APPSID");
|
|
173
|
+
return { appSid, ...(sender ? { sender } : {}) };
|
|
174
|
+
}
|
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
|