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.
- 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/get-started/why.mdx +10 -10
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/headers.mdx +10 -10
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/plugins/passkey.mdx +36 -24
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- 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/constant-time.ts +22 -0
- package/src/auth/index.ts +2 -0
- package/src/auth/method-context.ts +12 -2
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- 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 +764 -0
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +11 -11
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +27 -5
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +99 -33
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +6 -2
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/measure.ts +8 -1
- package/src/plugins/security-headers.ts +0 -255
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Ollama round-trip — real prompt, real response, documented default model.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in (never auto-pull a multi-GB model in ordinary `bun test`):
|
|
5
|
+
* 1. `OKE_TEST_OLLAMA_URL` — point at any reachable Ollama
|
|
6
|
+
* 2. Ollama already listening on `http://127.0.0.1:11434`
|
|
7
|
+
* 3. `OKE_TEST_OLLAMA_DOCKER=1` — start `ollama/ollama` via the recipe and pull
|
|
8
|
+
* `qwen3.5:9b` (slow first run)
|
|
9
|
+
*
|
|
10
|
+
* Without one of those, the suite skips with a visible reason (never an empty pass).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterAll, describe, expect, test } from "bun:test";
|
|
14
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { deriveInfrastructure, writeDerivedFiles } from "../docker/index.ts";
|
|
18
|
+
import { ai } from "../elements/ai.ts";
|
|
19
|
+
import { bindAi } from "../kernel/boot-bind/ai.ts";
|
|
20
|
+
import { OLLAMA_DEFAULT_MODEL, openOllama } from "./ai-ollama.ts";
|
|
21
|
+
|
|
22
|
+
const OLLAMA_IMAGE = "ollama/ollama:latest";
|
|
23
|
+
const DEFAULT_LOCAL = "http://127.0.0.1:11434";
|
|
24
|
+
const ENV_URL = process.env.OKE_TEST_OLLAMA_URL?.trim();
|
|
25
|
+
const WANT_DOCKER = process.env.OKE_TEST_OLLAMA_DOCKER === "1";
|
|
26
|
+
|
|
27
|
+
function dockerAvailable(): boolean {
|
|
28
|
+
try {
|
|
29
|
+
return Bun.spawnSync(["docker", "info"], { stdout: "pipe", stderr: "pipe" }).exitCode === 0;
|
|
30
|
+
} catch {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function probeOllama(url: string): Promise<boolean> {
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(`${url}/api/tags`, { signal: AbortSignal.timeout(1_500) });
|
|
38
|
+
return res.ok;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const DOCKER = dockerAvailable();
|
|
45
|
+
const localUp = ENV_URL ? false : await probeOllama(DEFAULT_LOCAL);
|
|
46
|
+
const canLive = Boolean(ENV_URL) || localUp || (WANT_DOCKER && DOCKER);
|
|
47
|
+
|
|
48
|
+
if (!canLive) {
|
|
49
|
+
const reasons: string[] = [];
|
|
50
|
+
if (!ENV_URL) reasons.push("OKE_TEST_OLLAMA_URL not set");
|
|
51
|
+
if (!localUp) reasons.push("no Ollama on :11434");
|
|
52
|
+
if (!WANT_DOCKER) reasons.push("OKE_TEST_OLLAMA_DOCKER≠1");
|
|
53
|
+
else if (!DOCKER) reasons.push("docker daemon not available");
|
|
54
|
+
console.log(`skip: live ollama e2e (${reasons.join("; ")})`);
|
|
55
|
+
}
|
|
56
|
+
const live = canLive ? test : test.skip;
|
|
57
|
+
|
|
58
|
+
let cleanup: (() => Promise<void>) | undefined;
|
|
59
|
+
|
|
60
|
+
afterAll(async () => {
|
|
61
|
+
if (cleanup) await cleanup();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
async function waitForOllama(url: string, timeoutMs: number, requireModel: boolean): Promise<void> {
|
|
65
|
+
const deadline = Date.now() + timeoutMs;
|
|
66
|
+
while (Date.now() < deadline) {
|
|
67
|
+
try {
|
|
68
|
+
const res = await fetch(`${url}/api/tags`);
|
|
69
|
+
if (res.ok) {
|
|
70
|
+
if (!requireModel) return;
|
|
71
|
+
const body = (await res.json()) as { models?: readonly { name?: string }[] };
|
|
72
|
+
const names = (body.models ?? []).map((m) => m.name ?? "");
|
|
73
|
+
if (
|
|
74
|
+
names.some((n) => n === OLLAMA_DEFAULT_MODEL || n.startsWith(`${OLLAMA_DEFAULT_MODEL}`))
|
|
75
|
+
) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// still starting
|
|
81
|
+
}
|
|
82
|
+
await Bun.sleep(1_000);
|
|
83
|
+
}
|
|
84
|
+
throw new Error(
|
|
85
|
+
`ollama not ready at ${url} within ${timeoutMs}ms${
|
|
86
|
+
requireModel ? ` (need model ${OLLAMA_DEFAULT_MODEL})` : ""
|
|
87
|
+
}`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resolveLiveUrl(): Promise<{ url: string; model: string }> {
|
|
92
|
+
if (ENV_URL) {
|
|
93
|
+
await waitForOllama(ENV_URL, 30_000, false);
|
|
94
|
+
return { url: ENV_URL, model: process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (localUp) {
|
|
98
|
+
return { url: DEFAULT_LOCAL, model: process.env.OKE_AI_MODEL?.trim() || OLLAMA_DEFAULT_MODEL };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-ollama-"));
|
|
102
|
+
const dockerDir = join(dir, "docker");
|
|
103
|
+
const project = `oke-ollama-${Date.now()}`;
|
|
104
|
+
const instanceId = crypto.randomUUID().replace(/-/g, "").slice(0, 6);
|
|
105
|
+
const derived = deriveInfrastructure({
|
|
106
|
+
images: { ai: OLLAMA_IMAGE },
|
|
107
|
+
app: "ollama-e2e",
|
|
108
|
+
host: "127.0.0.1",
|
|
109
|
+
includeApp: false,
|
|
110
|
+
composeDir: "docker",
|
|
111
|
+
instanceId,
|
|
112
|
+
controls: { OKE_AI_MODEL: OLLAMA_DEFAULT_MODEL },
|
|
113
|
+
});
|
|
114
|
+
await writeDerivedFiles(derived, dockerDir, { writeStackEnv: true });
|
|
115
|
+
|
|
116
|
+
const composeFiles = ["compose.yml", "compose.ai.yml"];
|
|
117
|
+
const up = Bun.spawn(
|
|
118
|
+
["docker", "compose", "-p", project, ...composeFiles.flatMap((f) => ["-f", f]), "up", "-d"],
|
|
119
|
+
{
|
|
120
|
+
cwd: dockerDir,
|
|
121
|
+
stdout: "pipe",
|
|
122
|
+
stderr: "pipe",
|
|
123
|
+
env: { ...process.env, ...derived.stackEnv, OKE_AI_MODEL: OLLAMA_DEFAULT_MODEL },
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
const [upErr, upCode] = await Promise.all([new Response(up.stderr).text(), up.exited]);
|
|
127
|
+
if (upCode !== 0) {
|
|
128
|
+
await rm(dir, { recursive: true, force: true });
|
|
129
|
+
throw new Error(`docker compose up failed: ${upErr}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const url = derived.stackEnv.OKE_AI_URL!;
|
|
133
|
+
cleanup = async () => {
|
|
134
|
+
const down = Bun.spawn(
|
|
135
|
+
["docker", "compose", "-p", project, ...composeFiles.flatMap((f) => ["-f", f]), "down", "-v"],
|
|
136
|
+
{ cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
|
|
137
|
+
);
|
|
138
|
+
await down.exited;
|
|
139
|
+
await rm(dir, { recursive: true, force: true });
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Model pull can take a long time on first run.
|
|
143
|
+
await waitForOllama(url, 20 * 60_000, true);
|
|
144
|
+
return { url, model: OLLAMA_DEFAULT_MODEL };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe("ollama live — real completion", () => {
|
|
148
|
+
live(
|
|
149
|
+
"boots against a real Ollama, completes with a real model, returns a real response",
|
|
150
|
+
async () => {
|
|
151
|
+
const { url, model } = await resolveLiveUrl();
|
|
152
|
+
const prevUrl = process.env.OKE_AI_URL;
|
|
153
|
+
const prevModel = process.env.OKE_AI_MODEL;
|
|
154
|
+
process.env.OKE_AI_URL = url;
|
|
155
|
+
process.env.OKE_AI_MODEL = model;
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const smart = ai.model("smart", { provider: "ollama", model });
|
|
159
|
+
const ping = smart.prompt("ollama-ping", { version: 1 });
|
|
160
|
+
|
|
161
|
+
const runtime = bindAi(
|
|
162
|
+
{
|
|
163
|
+
config: { drivers: { ai: { local: "ollama", test: "ollama" } } },
|
|
164
|
+
ai: { models: [smart], prompts: [ping] },
|
|
165
|
+
},
|
|
166
|
+
undefined,
|
|
167
|
+
() => Date.now(),
|
|
168
|
+
"local",
|
|
169
|
+
false,
|
|
170
|
+
);
|
|
171
|
+
expect(runtime.prompts.has("ollama-ping")).toBe(true);
|
|
172
|
+
|
|
173
|
+
const client = await openOllama({ baseUrl: url, model });
|
|
174
|
+
const result = await client.complete({
|
|
175
|
+
messages: [
|
|
176
|
+
{
|
|
177
|
+
role: "user",
|
|
178
|
+
content: "Reply with exactly the single word: pong",
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
temperature: 0,
|
|
182
|
+
maxTokens: 32,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(result.driverId).toBe("ollama");
|
|
186
|
+
expect(result.text.trim().length).toBeGreaterThan(0);
|
|
187
|
+
expect(result.text.toLowerCase()).toContain("pong");
|
|
188
|
+
} finally {
|
|
189
|
+
if (prevUrl === undefined) delete process.env.OKE_AI_URL;
|
|
190
|
+
else process.env.OKE_AI_URL = prevUrl;
|
|
191
|
+
if (prevModel === undefined) delete process.env.OKE_AI_MODEL;
|
|
192
|
+
else process.env.OKE_AI_MODEL = prevModel;
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
25 * 60_000,
|
|
196
|
+
);
|
|
197
|
+
});
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ollama` AI driver — thin fetch client for the native Ollama HTTP API.
|
|
3
|
+
*
|
|
4
|
+
* Any pulled model works via `model` / `OKE_AI_MODEL`. The documented local-dev
|
|
5
|
+
* default is `qwen3.5:9b` (balanced starting point — override freely; on Apple
|
|
6
|
+
* Silicon consider `qwen3.5:9b-mlx`). Fail-loud:
|
|
7
|
+
* configured but unreachable throws {@link OllamaUnavailableError} — never a
|
|
8
|
+
* silent mock fallback.
|
|
9
|
+
*
|
|
10
|
+
* Native API: `POST /api/chat` (not the OpenAI-compat shim). Default base URL
|
|
11
|
+
* `http://127.0.0.1:11434`. Supports `complete`, `stream` (NDJSON), and tools.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
AiCompleteOptions,
|
|
16
|
+
AiCompleteResult,
|
|
17
|
+
AiDriver,
|
|
18
|
+
AiModelClient,
|
|
19
|
+
AiOpenOptions,
|
|
20
|
+
AiStreamChunk,
|
|
21
|
+
AiToolCall,
|
|
22
|
+
} from "./ai-types.ts";
|
|
23
|
+
|
|
24
|
+
/** Documented local-dev default — override via `model` / `OKE_AI_MODEL`. */
|
|
25
|
+
export const OLLAMA_DEFAULT_MODEL = "qwen3.5:9b";
|
|
26
|
+
|
|
27
|
+
/** Default Ollama listen URL (host installs + compose host port). */
|
|
28
|
+
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
|
|
29
|
+
|
|
30
|
+
/** Error thrown when Ollama is unreachable / unhealthy / rejects a call. */
|
|
31
|
+
export class OllamaUnavailableError extends Error {
|
|
32
|
+
constructor(message: string) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = "OllamaUnavailableError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Normalize a base URL or bare `host:port` (as in `OLLAMA_HOST`) to an origin.
|
|
40
|
+
*
|
|
41
|
+
* @param raw - URL or host:port
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeOllamaBaseUrl(raw: string): string {
|
|
44
|
+
const trimmed = raw.trim().replace(/\/$/, "");
|
|
45
|
+
if (!trimmed) return OLLAMA_DEFAULT_BASE_URL;
|
|
46
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed.replace(/\/v1\/?$/i, "");
|
|
47
|
+
return `http://${trimmed}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the Ollama base URL from options / env (never invent a cloud endpoint).
|
|
52
|
+
*
|
|
53
|
+
* @param options - Open options
|
|
54
|
+
*/
|
|
55
|
+
export function resolveOllamaBaseUrl(options: AiOpenOptions = {}): string {
|
|
56
|
+
const raw =
|
|
57
|
+
options.baseUrl?.trim() ||
|
|
58
|
+
process.env.OKE_AI_URL?.trim() ||
|
|
59
|
+
process.env.OLLAMA_HOST?.trim() ||
|
|
60
|
+
OLLAMA_DEFAULT_BASE_URL;
|
|
61
|
+
return normalizeOllamaBaseUrl(raw);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the model name — fully configurable; documented default only.
|
|
66
|
+
*
|
|
67
|
+
* @param options - Open options
|
|
68
|
+
* @param override - Per-call model override
|
|
69
|
+
*/
|
|
70
|
+
export function resolveOllamaModel(options: AiOpenOptions = {}, override?: string): string {
|
|
71
|
+
return (
|
|
72
|
+
override?.trim() ||
|
|
73
|
+
options.model?.trim() ||
|
|
74
|
+
process.env.OKE_AI_MODEL?.trim() ||
|
|
75
|
+
OLLAMA_DEFAULT_MODEL
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Open an Ollama chat client. Health-checks `/api/tags` before returning.
|
|
81
|
+
*
|
|
82
|
+
* @param options - model / baseUrl / injectable fetch
|
|
83
|
+
*/
|
|
84
|
+
export async function openOllama(options: AiOpenOptions = {}): Promise<AiModelClient> {
|
|
85
|
+
const baseUrl = resolveOllamaBaseUrl(options);
|
|
86
|
+
const model = resolveOllamaModel(options);
|
|
87
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
88
|
+
|
|
89
|
+
await healthCheck(baseUrl, fetchFn);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
driverId: "ollama",
|
|
93
|
+
model,
|
|
94
|
+
async complete(opts: AiCompleteOptions): Promise<AiCompleteResult> {
|
|
95
|
+
const resolvedModel = resolveOllamaModel(options, opts.model);
|
|
96
|
+
const body = buildChatBody(resolvedModel, opts, false);
|
|
97
|
+
|
|
98
|
+
let res: Response;
|
|
99
|
+
try {
|
|
100
|
+
res = await fetchFn(`${baseUrl}/api/chat`, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
headers: { "content-type": "application/json" },
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
105
|
+
});
|
|
106
|
+
} catch (err) {
|
|
107
|
+
if (isAbortError(err)) throw err;
|
|
108
|
+
throw new OllamaUnavailableError(
|
|
109
|
+
`ollama: unreachable at ${baseUrl} — ${err instanceof Error ? err.message : String(err)}`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const raw = (await res.json().catch(() => ({}))) as OllamaChatResponse;
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
const msg = raw.error ?? `ollama HTTP ${res.status}`;
|
|
116
|
+
throw new OllamaUnavailableError(`ollama: ${msg}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const text = raw.message?.content ?? "";
|
|
120
|
+
const toolCalls = parseOllamaToolCalls(raw.message?.tool_calls);
|
|
121
|
+
return {
|
|
122
|
+
text,
|
|
123
|
+
raw,
|
|
124
|
+
model: raw.model ?? resolvedModel,
|
|
125
|
+
driverId: "ollama",
|
|
126
|
+
...(toolCalls !== undefined ? { toolCalls } : {}),
|
|
127
|
+
usage: {
|
|
128
|
+
inputTokens: raw.prompt_eval_count,
|
|
129
|
+
outputTokens: raw.eval_count,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
},
|
|
133
|
+
async *stream(opts: AiCompleteOptions): AsyncIterable<AiStreamChunk> {
|
|
134
|
+
const resolvedModel = resolveOllamaModel(options, opts.model);
|
|
135
|
+
const body = buildChatBody(resolvedModel, opts, true);
|
|
136
|
+
|
|
137
|
+
let res: Response;
|
|
138
|
+
try {
|
|
139
|
+
res = await fetchFn(`${baseUrl}/api/chat`, {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "content-type": "application/json" },
|
|
142
|
+
body: JSON.stringify(body),
|
|
143
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
144
|
+
});
|
|
145
|
+
} catch (err) {
|
|
146
|
+
if (isAbortError(err)) throw err;
|
|
147
|
+
throw new OllamaUnavailableError(
|
|
148
|
+
`ollama: unreachable at ${baseUrl} — ${err instanceof Error ? err.message : String(err)}`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
if (!res.ok) {
|
|
152
|
+
const raw = (await res.json().catch(() => ({}))) as OllamaChatResponse;
|
|
153
|
+
const msg = raw.error ?? `ollama HTTP ${res.status}`;
|
|
154
|
+
throw new OllamaUnavailableError(`ollama: ${msg}`);
|
|
155
|
+
}
|
|
156
|
+
if (!res.body) {
|
|
157
|
+
throw new OllamaUnavailableError("ollama: stream response has no body");
|
|
158
|
+
}
|
|
159
|
+
yield* readOllamaNdjson(res.body, opts.signal);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Protocol-named ollama driver. */
|
|
165
|
+
export const ollamaAiDriver: AiDriver = {
|
|
166
|
+
id: "ollama",
|
|
167
|
+
open: openOllama,
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
function buildChatBody(
|
|
171
|
+
resolvedModel: string,
|
|
172
|
+
opts: AiCompleteOptions,
|
|
173
|
+
stream: boolean,
|
|
174
|
+
): Record<string, unknown> {
|
|
175
|
+
const body: Record<string, unknown> = {
|
|
176
|
+
model: resolvedModel,
|
|
177
|
+
messages: opts.messages.map((m) => {
|
|
178
|
+
const msg: Record<string, unknown> = {
|
|
179
|
+
role: m.role,
|
|
180
|
+
content: m.content,
|
|
181
|
+
};
|
|
182
|
+
if (m.name !== undefined) msg.name = m.name;
|
|
183
|
+
if (m.toolCalls !== undefined && m.toolCalls.length > 0) {
|
|
184
|
+
msg.tool_calls = m.toolCalls.map((tc) => ({
|
|
185
|
+
type: "function",
|
|
186
|
+
function: {
|
|
187
|
+
name: tc.name,
|
|
188
|
+
arguments: tc.arguments,
|
|
189
|
+
},
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
return msg;
|
|
193
|
+
}),
|
|
194
|
+
stream,
|
|
195
|
+
// Thinking models (e.g. qwen3 / qwen3.5) otherwise spend the token budget in
|
|
196
|
+
// `message.thinking` and leave `content` empty — baseline complete
|
|
197
|
+
// wants the answer text.
|
|
198
|
+
think: false,
|
|
199
|
+
};
|
|
200
|
+
const modelOptions: Record<string, unknown> = {};
|
|
201
|
+
if (opts.temperature !== undefined) modelOptions.temperature = opts.temperature;
|
|
202
|
+
if (opts.maxTokens !== undefined) modelOptions.num_predict = opts.maxTokens;
|
|
203
|
+
if (Object.keys(modelOptions).length > 0) body.options = modelOptions;
|
|
204
|
+
if (opts.responseFormat !== undefined) body.format = opts.responseFormat;
|
|
205
|
+
if (opts.tools !== undefined && opts.tools.length > 0) {
|
|
206
|
+
body.tools = opts.tools.map((t) => ({
|
|
207
|
+
type: "function",
|
|
208
|
+
function: {
|
|
209
|
+
name: t.name,
|
|
210
|
+
...(t.description !== undefined ? { description: t.description } : {}),
|
|
211
|
+
...(t.parameters !== undefined ? { parameters: t.parameters } : {}),
|
|
212
|
+
},
|
|
213
|
+
}));
|
|
214
|
+
}
|
|
215
|
+
return body;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function parseOllamaToolCalls(
|
|
219
|
+
raw: readonly OllamaToolCall[] | undefined,
|
|
220
|
+
): readonly AiToolCall[] | undefined {
|
|
221
|
+
if (!raw || raw.length === 0) return undefined;
|
|
222
|
+
return raw.map((tc, i) => ({
|
|
223
|
+
id: `ollama_call_${i}`,
|
|
224
|
+
name: tc.function?.name ?? "",
|
|
225
|
+
arguments: tc.function?.arguments ?? {},
|
|
226
|
+
}));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Parse Ollama NDJSON stream lines.
|
|
231
|
+
*
|
|
232
|
+
* @param body - Response body
|
|
233
|
+
* @param signal - Optional abort
|
|
234
|
+
*/
|
|
235
|
+
async function* readOllamaNdjson(
|
|
236
|
+
body: ReadableStream<Uint8Array>,
|
|
237
|
+
signal?: AbortSignal,
|
|
238
|
+
): AsyncGenerator<AiStreamChunk> {
|
|
239
|
+
const reader = body.getReader();
|
|
240
|
+
const decoder = new TextDecoder();
|
|
241
|
+
let buffer = "";
|
|
242
|
+
try {
|
|
243
|
+
while (true) {
|
|
244
|
+
if (signal?.aborted) {
|
|
245
|
+
throw abortAsError(signal.reason);
|
|
246
|
+
}
|
|
247
|
+
const { done, value } = await reader.read();
|
|
248
|
+
if (done) break;
|
|
249
|
+
buffer += decoder.decode(value, { stream: true });
|
|
250
|
+
const lines = buffer.split("\n");
|
|
251
|
+
buffer = lines.pop() ?? "";
|
|
252
|
+
for (const line of lines) {
|
|
253
|
+
const trimmed = line.trim();
|
|
254
|
+
if (!trimmed) continue;
|
|
255
|
+
try {
|
|
256
|
+
const chunk = JSON.parse(trimmed) as OllamaChatResponse;
|
|
257
|
+
const delta = chunk.message?.content;
|
|
258
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
259
|
+
yield { text: delta };
|
|
260
|
+
}
|
|
261
|
+
if (chunk.done) {
|
|
262
|
+
yield { text: "", done: true };
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
// ignore malformed lines
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
yield { text: "", done: true };
|
|
271
|
+
} finally {
|
|
272
|
+
reader.releaseLock();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Probe Ollama — fail loud before the first completion.
|
|
278
|
+
*
|
|
279
|
+
* @param baseUrl - Origin
|
|
280
|
+
* @param fetchFn - Injectable fetch
|
|
281
|
+
*/
|
|
282
|
+
async function healthCheck(baseUrl: string, fetchFn: typeof globalThis.fetch): Promise<void> {
|
|
283
|
+
let res: Response;
|
|
284
|
+
try {
|
|
285
|
+
res = await fetchFn(`${baseUrl}/api/tags`, { method: "GET" });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
throw new OllamaUnavailableError(
|
|
288
|
+
`ollama: unreachable at ${baseUrl} — ${err instanceof Error ? err.message : String(err)}`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (!res.ok) {
|
|
292
|
+
const detail = await res.text().catch(() => "");
|
|
293
|
+
throw new OllamaUnavailableError(
|
|
294
|
+
`ollama: health check failed at ${baseUrl}/api/tags (${res.status})${
|
|
295
|
+
detail ? ` — ${detail.slice(0, 200)}` : ""
|
|
296
|
+
}`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isAbortError(err: unknown): boolean {
|
|
302
|
+
return err instanceof Error && err.name === "AbortError";
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function abortAsError(reason?: unknown): Error {
|
|
306
|
+
if (reason instanceof Error) return reason;
|
|
307
|
+
const err = new Error(reason !== undefined ? String(reason) : "This operation was aborted");
|
|
308
|
+
err.name = "AbortError";
|
|
309
|
+
return err;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface OllamaToolCall {
|
|
313
|
+
readonly function?: { readonly name?: string; readonly arguments?: unknown };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
interface OllamaChatResponse {
|
|
317
|
+
readonly model?: string;
|
|
318
|
+
readonly message?: {
|
|
319
|
+
readonly role?: string;
|
|
320
|
+
readonly content?: string;
|
|
321
|
+
readonly tool_calls?: readonly OllamaToolCall[];
|
|
322
|
+
};
|
|
323
|
+
readonly done?: boolean;
|
|
324
|
+
readonly prompt_eval_count?: number;
|
|
325
|
+
readonly eval_count?: number;
|
|
326
|
+
readonly error?: string;
|
|
327
|
+
}
|