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
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `openai-compatible` AI driver — thin HTTP client for OpenAI-shaped APIs.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* One protocol driver for OpenAI, Groq, Together, OpenRouter, vLLM, LM Studio,
|
|
5
|
+
* Ollama `/v1`, and other chat/completions endpoints. Configure via
|
|
6
|
+
* `baseUrl` + `apiKey` + `model` (+ optional `headers`). Native Ollama stays
|
|
7
|
+
* on the separate `ollama` driver. Injectable `fetch` for tests. Never a
|
|
6
8
|
* production default — prod must declare.
|
|
7
9
|
*/
|
|
8
10
|
|
|
@@ -14,23 +16,67 @@ import type {
|
|
|
14
16
|
AiEmbedResult,
|
|
15
17
|
AiModelClient,
|
|
16
18
|
AiOpenOptions,
|
|
19
|
+
AiStreamChunk,
|
|
20
|
+
AiToolCall,
|
|
17
21
|
} from "./ai-types.ts";
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
/** Default OpenAI cloud base — apiKey is required for this origin. */
|
|
24
|
+
export const OPENAI_COMPAT_DEFAULT_BASE = "https://api.openai.com/v1";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Normalize a chat/embeddings base URL (strip trailing slash).
|
|
28
|
+
*
|
|
29
|
+
* @param raw - Base URL
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeOpenaiCompatibleBaseUrl(raw: string): string {
|
|
32
|
+
return raw.trim().replace(/\/$/, "");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether this base is the OpenAI cloud default (key required).
|
|
37
|
+
*
|
|
38
|
+
* @param baseUrl - Normalized base
|
|
39
|
+
*/
|
|
40
|
+
export function isOpenaiCloudBase(baseUrl: string): boolean {
|
|
41
|
+
return normalizeOpenaiCompatibleBaseUrl(baseUrl) === OPENAI_COMPAT_DEFAULT_BASE;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build request headers: content-type, optional Bearer, optional extras.
|
|
46
|
+
*
|
|
47
|
+
* @param apiKey - Bearer token when present
|
|
48
|
+
* @param extra - Caller headers
|
|
49
|
+
*/
|
|
50
|
+
export function openaiCompatibleHeaders(
|
|
51
|
+
apiKey: string | undefined,
|
|
52
|
+
extra?: Readonly<Record<string, string>>,
|
|
53
|
+
): Record<string, string> {
|
|
54
|
+
const headers: Record<string, string> = {
|
|
55
|
+
"content-type": "application/json",
|
|
56
|
+
...(extra ?? {}),
|
|
57
|
+
};
|
|
58
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
59
|
+
return headers;
|
|
60
|
+
}
|
|
20
61
|
|
|
21
62
|
/**
|
|
22
63
|
* Open an OpenAI-compatible chat / embeddings client.
|
|
23
64
|
*
|
|
24
|
-
*
|
|
65
|
+
* apiKey is required for the default OpenAI cloud base. Custom `baseUrl`
|
|
66
|
+
* (LM Studio, Ollama `/v1`, Groq, …) may omit the key — Authorization is
|
|
67
|
+
* then omitted. HTTP failures always throw (never silent mock fallback).
|
|
68
|
+
*
|
|
69
|
+
* @param options - API key / model / base URL / headers / injectable fetch
|
|
25
70
|
*/
|
|
26
71
|
export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise<AiModelClient> {
|
|
72
|
+
const baseUrl = normalizeOpenaiCompatibleBaseUrl(options.baseUrl ?? OPENAI_COMPAT_DEFAULT_BASE);
|
|
27
73
|
const apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
|
|
28
|
-
if (!apiKey) {
|
|
74
|
+
if (isOpenaiCloudBase(baseUrl) && !apiKey) {
|
|
29
75
|
throw new Error("openai-compatible: apiKey is required (or OPENAI_API_KEY)");
|
|
30
76
|
}
|
|
31
77
|
const model = options.model ?? "gpt-4o-mini";
|
|
32
|
-
const baseUrl = (options.baseUrl ?? DEFAULT_BASE).replace(/\/$/, "");
|
|
33
78
|
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
79
|
+
const extraHeaders = options.headers;
|
|
34
80
|
|
|
35
81
|
return {
|
|
36
82
|
driverId: "openai-compatible",
|
|
@@ -39,52 +85,82 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
|
|
|
39
85
|
const resolvedModel = opts.model ?? model;
|
|
40
86
|
const body: Record<string, unknown> = {
|
|
41
87
|
model: resolvedModel,
|
|
42
|
-
messages: opts.messages.map(
|
|
43
|
-
role: m.role,
|
|
44
|
-
content: m.content,
|
|
45
|
-
...(m.name !== undefined ? { name: m.name } : {}),
|
|
46
|
-
})),
|
|
88
|
+
messages: opts.messages.map(serializeMessage),
|
|
47
89
|
};
|
|
48
90
|
if (opts.temperature !== undefined) body.temperature = opts.temperature;
|
|
49
91
|
if (opts.maxTokens !== undefined) body.max_tokens = opts.maxTokens;
|
|
50
92
|
if (opts.responseFormat !== undefined) {
|
|
51
93
|
body.response_format = opts.responseFormat;
|
|
52
94
|
}
|
|
95
|
+
if (opts.tools !== undefined && opts.tools.length > 0) {
|
|
96
|
+
body.tools = opts.tools.map((t) => ({
|
|
97
|
+
type: "function",
|
|
98
|
+
function: {
|
|
99
|
+
name: t.name,
|
|
100
|
+
...(t.description !== undefined ? { description: t.description } : {}),
|
|
101
|
+
...(t.parameters !== undefined ? { parameters: t.parameters } : {}),
|
|
102
|
+
},
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
53
105
|
|
|
54
106
|
const res = await fetchFn(`${baseUrl}/chat/completions`, {
|
|
55
107
|
method: "POST",
|
|
56
|
-
headers:
|
|
57
|
-
"content-type": "application/json",
|
|
58
|
-
Authorization: `Bearer ${apiKey}`,
|
|
59
|
-
},
|
|
108
|
+
headers: openaiCompatibleHeaders(apiKey, extraHeaders),
|
|
60
109
|
body: JSON.stringify(body),
|
|
110
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
61
111
|
});
|
|
62
112
|
const raw = (await res.json().catch(() => ({}))) as OpenAiChatResponse;
|
|
63
113
|
if (!res.ok) {
|
|
64
114
|
const msg = raw.error?.message ?? `openai-compatible HTTP ${res.status}`;
|
|
65
115
|
throw new Error(`openai-compatible: ${msg}`);
|
|
66
116
|
}
|
|
67
|
-
const
|
|
117
|
+
const message = raw.choices?.[0]?.message;
|
|
118
|
+
const text = message?.content ?? "";
|
|
119
|
+
const toolCalls = parseToolCalls(message?.tool_calls);
|
|
68
120
|
return {
|
|
69
121
|
text,
|
|
70
122
|
raw,
|
|
71
123
|
model: raw.model ?? resolvedModel,
|
|
72
124
|
driverId: "openai-compatible",
|
|
125
|
+
...(toolCalls !== undefined ? { toolCalls } : {}),
|
|
73
126
|
usage: {
|
|
74
127
|
inputTokens: raw.usage?.prompt_tokens,
|
|
75
128
|
outputTokens: raw.usage?.completion_tokens,
|
|
76
129
|
},
|
|
77
130
|
};
|
|
78
131
|
},
|
|
132
|
+
async *stream(opts: AiCompleteOptions): AsyncIterable<AiStreamChunk> {
|
|
133
|
+
const resolvedModel = opts.model ?? model;
|
|
134
|
+
const body: Record<string, unknown> = {
|
|
135
|
+
model: resolvedModel,
|
|
136
|
+
messages: opts.messages.map(serializeMessage),
|
|
137
|
+
stream: true,
|
|
138
|
+
};
|
|
139
|
+
if (opts.temperature !== undefined) body.temperature = opts.temperature;
|
|
140
|
+
if (opts.maxTokens !== undefined) body.max_tokens = opts.maxTokens;
|
|
141
|
+
|
|
142
|
+
const res = await fetchFn(`${baseUrl}/chat/completions`, {
|
|
143
|
+
method: "POST",
|
|
144
|
+
headers: openaiCompatibleHeaders(apiKey, extraHeaders),
|
|
145
|
+
body: JSON.stringify(body),
|
|
146
|
+
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
147
|
+
});
|
|
148
|
+
if (!res.ok) {
|
|
149
|
+
const raw = (await res.json().catch(() => ({}))) as OpenAiChatResponse;
|
|
150
|
+
const msg = raw.error?.message ?? `openai-compatible HTTP ${res.status}`;
|
|
151
|
+
throw new Error(`openai-compatible: ${msg}`);
|
|
152
|
+
}
|
|
153
|
+
if (!res.body) {
|
|
154
|
+
throw new Error("openai-compatible: stream response has no body");
|
|
155
|
+
}
|
|
156
|
+
yield* readOpenaiSse(res.body, opts.signal);
|
|
157
|
+
},
|
|
79
158
|
async embed(opts: AiEmbedOptions): Promise<AiEmbedResult> {
|
|
80
159
|
const resolvedModel = opts.model ?? model;
|
|
81
160
|
const input = opts.input;
|
|
82
161
|
const res = await fetchFn(`${baseUrl}/embeddings`, {
|
|
83
162
|
method: "POST",
|
|
84
|
-
headers:
|
|
85
|
-
"content-type": "application/json",
|
|
86
|
-
Authorization: `Bearer ${apiKey}`,
|
|
87
|
-
},
|
|
163
|
+
headers: openaiCompatibleHeaders(apiKey, extraHeaders),
|
|
88
164
|
body: JSON.stringify({ model: resolvedModel, input }),
|
|
89
165
|
});
|
|
90
166
|
const raw = (await res.json().catch(() => ({}))) as OpenAiEmbedResponse;
|
|
@@ -111,10 +187,124 @@ export const openaiCompatibleAiDriver: AiDriver = {
|
|
|
111
187
|
open: openOpenaiCompatible,
|
|
112
188
|
};
|
|
113
189
|
|
|
190
|
+
function serializeMessage(m: AiCompleteOptions["messages"][number]): Record<string, unknown> {
|
|
191
|
+
const out: Record<string, unknown> = {
|
|
192
|
+
role: m.role,
|
|
193
|
+
content: m.content,
|
|
194
|
+
};
|
|
195
|
+
if (m.name !== undefined) out.name = m.name;
|
|
196
|
+
if (m.toolCallId !== undefined) out.tool_call_id = m.toolCallId;
|
|
197
|
+
if (m.toolCalls !== undefined && m.toolCalls.length > 0) {
|
|
198
|
+
out.tool_calls = m.toolCalls.map((tc) => ({
|
|
199
|
+
id: tc.id,
|
|
200
|
+
type: "function",
|
|
201
|
+
function: {
|
|
202
|
+
name: tc.name,
|
|
203
|
+
arguments:
|
|
204
|
+
typeof tc.arguments === "string" ? tc.arguments : JSON.stringify(tc.arguments ?? {}),
|
|
205
|
+
},
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function parseToolCalls(
|
|
212
|
+
raw: readonly OpenAiToolCall[] | undefined,
|
|
213
|
+
): readonly AiToolCall[] | undefined {
|
|
214
|
+
if (!raw || raw.length === 0) return undefined;
|
|
215
|
+
return raw.map((tc, i) => {
|
|
216
|
+
const name = tc.function?.name ?? "";
|
|
217
|
+
const argStr = tc.function?.arguments ?? "{}";
|
|
218
|
+
let args: unknown = argStr;
|
|
219
|
+
try {
|
|
220
|
+
args = JSON.parse(argStr) as unknown;
|
|
221
|
+
} catch {
|
|
222
|
+
args = { _raw: argStr };
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
id: tc.id ?? `call_${i}`,
|
|
226
|
+
name,
|
|
227
|
+
arguments: args,
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Parse OpenAI SSE chat.completion.chunk stream.
|
|
234
|
+
*
|
|
235
|
+
* @param body - Response body
|
|
236
|
+
* @param signal - Optional abort
|
|
237
|
+
*/
|
|
238
|
+
async function* readOpenaiSse(
|
|
239
|
+
body: ReadableStream<Uint8Array>,
|
|
240
|
+
signal?: AbortSignal,
|
|
241
|
+
): AsyncGenerator<AiStreamChunk> {
|
|
242
|
+
const reader = body.getReader();
|
|
243
|
+
const decoder = new TextDecoder();
|
|
244
|
+
let buffer = "";
|
|
245
|
+
try {
|
|
246
|
+
while (true) {
|
|
247
|
+
if (signal?.aborted) {
|
|
248
|
+
throw abortAsError(signal.reason);
|
|
249
|
+
}
|
|
250
|
+
const { done, value } = await reader.read();
|
|
251
|
+
if (done) break;
|
|
252
|
+
buffer += decoder.decode(value, { stream: true });
|
|
253
|
+
const lines = buffer.split("\n");
|
|
254
|
+
buffer = lines.pop() ?? "";
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
const trimmed = line.trim();
|
|
257
|
+
if (!trimmed.startsWith("data:")) continue;
|
|
258
|
+
const data = trimmed.slice(5).trim();
|
|
259
|
+
if (data === "[DONE]") {
|
|
260
|
+
yield { text: "", done: true };
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
try {
|
|
264
|
+
const chunk = JSON.parse(data) as {
|
|
265
|
+
choices?: readonly {
|
|
266
|
+
delta?: { content?: string | null };
|
|
267
|
+
finish_reason?: string | null;
|
|
268
|
+
}[];
|
|
269
|
+
};
|
|
270
|
+
const delta = chunk.choices?.[0]?.delta?.content;
|
|
271
|
+
if (typeof delta === "string" && delta.length > 0) {
|
|
272
|
+
yield { text: delta };
|
|
273
|
+
}
|
|
274
|
+
if (chunk.choices?.[0]?.finish_reason) {
|
|
275
|
+
yield { text: "", done: true };
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
} catch {
|
|
279
|
+
// ignore malformed SSE lines
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
yield { text: "", done: true };
|
|
284
|
+
} finally {
|
|
285
|
+
reader.releaseLock();
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function abortAsError(reason?: unknown): Error {
|
|
290
|
+
if (reason instanceof Error) return reason;
|
|
291
|
+
const err = new Error(reason !== undefined ? String(reason) : "This operation was aborted");
|
|
292
|
+
err.name = "AbortError";
|
|
293
|
+
return err;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
interface OpenAiToolCall {
|
|
297
|
+
readonly id?: string;
|
|
298
|
+
readonly function?: { readonly name?: string; readonly arguments?: string };
|
|
299
|
+
}
|
|
300
|
+
|
|
114
301
|
interface OpenAiChatResponse {
|
|
115
302
|
readonly model?: string;
|
|
116
303
|
readonly choices?: readonly {
|
|
117
|
-
readonly message?: {
|
|
304
|
+
readonly message?: {
|
|
305
|
+
readonly content?: string | null;
|
|
306
|
+
readonly tool_calls?: readonly OpenAiToolCall[];
|
|
307
|
+
};
|
|
118
308
|
}[];
|
|
119
309
|
readonly usage?: {
|
|
120
310
|
readonly prompt_tokens?: number;
|
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
6
|
import { anthropicAiDriver, openAnthropic } from "./ai-anthropic.ts";
|
|
7
|
-
import { openaiCompatibleAiDriver, openOpenaiCompatible } from "./ai-openai-compatible.ts";
|
|
8
7
|
import { mockAiDriver } from "./ai-mock.ts";
|
|
8
|
+
import {
|
|
9
|
+
normalizeOllamaBaseUrl,
|
|
10
|
+
ollamaAiDriver,
|
|
11
|
+
OLLAMA_DEFAULT_MODEL,
|
|
12
|
+
openOllama,
|
|
13
|
+
OllamaUnavailableError,
|
|
14
|
+
resolveOllamaModel,
|
|
15
|
+
} from "./ai-ollama.ts";
|
|
16
|
+
import { openaiCompatibleAiDriver, openOpenaiCompatible } from "./ai-openai-compatible.ts";
|
|
9
17
|
|
|
10
18
|
describe("anthropic driver", () => {
|
|
11
19
|
test("id is anthropic; mock remains the only default elsewhere", () => {
|
|
@@ -144,7 +152,7 @@ describe("openai-compatible driver", () => {
|
|
|
144
152
|
expect(paths.some((p) => p.endsWith("/embeddings"))).toBe(true);
|
|
145
153
|
});
|
|
146
154
|
|
|
147
|
-
test("requires apiKey", async () => {
|
|
155
|
+
test("requires apiKey for default OpenAI cloud base", async () => {
|
|
148
156
|
const prev = process.env.OPENAI_API_KEY;
|
|
149
157
|
delete process.env.OPENAI_API_KEY;
|
|
150
158
|
try {
|
|
@@ -153,4 +161,173 @@ describe("openai-compatible driver", () => {
|
|
|
153
161
|
if (prev !== undefined) process.env.OPENAI_API_KEY = prev;
|
|
154
162
|
}
|
|
155
163
|
});
|
|
164
|
+
|
|
165
|
+
test("custom baseUrl may omit apiKey (local / self-hosted)", async () => {
|
|
166
|
+
const prev = process.env.OPENAI_API_KEY;
|
|
167
|
+
delete process.env.OPENAI_API_KEY;
|
|
168
|
+
const headersSeen: Array<Record<string, string> | undefined> = [];
|
|
169
|
+
try {
|
|
170
|
+
const fetchFn: typeof fetch = Object.assign(
|
|
171
|
+
async (_input: string | URL | Request, init?: RequestInit) => {
|
|
172
|
+
headersSeen.push(init?.headers as Record<string, string> | undefined);
|
|
173
|
+
return new Response(
|
|
174
|
+
JSON.stringify({
|
|
175
|
+
model: "local",
|
|
176
|
+
choices: [{ message: { content: "ok" } }],
|
|
177
|
+
}),
|
|
178
|
+
{ status: 200 },
|
|
179
|
+
);
|
|
180
|
+
},
|
|
181
|
+
{ preconnect: () => {} },
|
|
182
|
+
) as typeof fetch;
|
|
183
|
+
const client = await openOpenaiCompatible({
|
|
184
|
+
baseUrl: "http://127.0.0.1:1234/v1",
|
|
185
|
+
model: "local",
|
|
186
|
+
fetch: fetchFn,
|
|
187
|
+
});
|
|
188
|
+
const result = await client.complete({
|
|
189
|
+
messages: [{ role: "user", content: "hi" }],
|
|
190
|
+
});
|
|
191
|
+
expect(result.text).toBe("ok");
|
|
192
|
+
expect(headersSeen[0]?.Authorization).toBeUndefined();
|
|
193
|
+
} finally {
|
|
194
|
+
if (prev !== undefined) process.env.OPENAI_API_KEY = prev;
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("same driver serves Groq-shaped baseUrl + key + OpenRouter headers", async () => {
|
|
199
|
+
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
|
|
200
|
+
const fetchFn: typeof fetch = Object.assign(
|
|
201
|
+
async (input: string | URL | Request, init?: RequestInit) => {
|
|
202
|
+
calls.push({
|
|
203
|
+
url: String(input),
|
|
204
|
+
headers: init?.headers as Record<string, string>,
|
|
205
|
+
});
|
|
206
|
+
return new Response(
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
model: "llama-3.1-8b-instant",
|
|
209
|
+
choices: [{ message: { content: "groq-ok" } }],
|
|
210
|
+
}),
|
|
211
|
+
{ status: 200 },
|
|
212
|
+
);
|
|
213
|
+
},
|
|
214
|
+
{ preconnect: () => {} },
|
|
215
|
+
) as typeof fetch;
|
|
216
|
+
|
|
217
|
+
const groq = await openOpenaiCompatible({
|
|
218
|
+
apiKey: "gsk-test",
|
|
219
|
+
baseUrl: "https://api.groq.com/openai/v1",
|
|
220
|
+
model: "llama-3.1-8b-instant",
|
|
221
|
+
fetch: fetchFn,
|
|
222
|
+
});
|
|
223
|
+
expect((await groq.complete({ messages: [{ role: "user", content: "x" }] })).text).toBe(
|
|
224
|
+
"groq-ok",
|
|
225
|
+
);
|
|
226
|
+
expect(calls[0]!.url).toBe("https://api.groq.com/openai/v1/chat/completions");
|
|
227
|
+
expect(calls[0]!.headers.Authorization).toBe("Bearer gsk-test");
|
|
228
|
+
|
|
229
|
+
calls.length = 0;
|
|
230
|
+
const openrouter = await openOpenaiCompatible({
|
|
231
|
+
apiKey: "or-test",
|
|
232
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
233
|
+
model: "meta-llama/llama-3.1-8b-instruct",
|
|
234
|
+
headers: {
|
|
235
|
+
"HTTP-Referer": "https://example.com",
|
|
236
|
+
"X-Title": "oke-test",
|
|
237
|
+
},
|
|
238
|
+
fetch: fetchFn,
|
|
239
|
+
});
|
|
240
|
+
expect((await openrouter.complete({ messages: [{ role: "user", content: "x" }] })).text).toBe(
|
|
241
|
+
"groq-ok",
|
|
242
|
+
);
|
|
243
|
+
expect(calls[0]!.headers["HTTP-Referer"]).toBe("https://example.com");
|
|
244
|
+
expect(calls[0]!.headers["X-Title"]).toBe("oke-test");
|
|
245
|
+
expect(calls[0]!.headers.Authorization).toBe("Bearer or-test");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("HTTP error fails loud (no silent mock fallback)", async () => {
|
|
249
|
+
const fetchFn = Object.assign(
|
|
250
|
+
async () =>
|
|
251
|
+
new Response(JSON.stringify({ error: { message: "quota exceeded" } }), {
|
|
252
|
+
status: 429,
|
|
253
|
+
}),
|
|
254
|
+
{ preconnect: () => {} },
|
|
255
|
+
) as typeof fetch;
|
|
256
|
+
const client = await openOpenaiCompatible({
|
|
257
|
+
apiKey: "sk-test",
|
|
258
|
+
baseUrl: "https://api.together.xyz/v1",
|
|
259
|
+
fetch: fetchFn,
|
|
260
|
+
});
|
|
261
|
+
await expect(client.complete({ messages: [{ role: "user", content: "x" }] })).rejects.toThrow(
|
|
262
|
+
"quota exceeded",
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
describe("ollama driver", () => {
|
|
268
|
+
test("id is ollama; documented default model is qwen3.5:9b (overridable)", () => {
|
|
269
|
+
expect(ollamaAiDriver.id).toBe("ollama");
|
|
270
|
+
expect(OLLAMA_DEFAULT_MODEL).toBe("qwen3.5:9b");
|
|
271
|
+
expect(resolveOllamaModel({ model: "llama3.2:1b" })).toBe("llama3.2:1b");
|
|
272
|
+
expect(normalizeOllamaBaseUrl("localhost:11434")).toBe("http://localhost:11434");
|
|
273
|
+
expect(normalizeOllamaBaseUrl("http://127.0.0.1:11434/v1")).toBe("http://127.0.0.1:11434");
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("complete via injectable fetch — native /api/chat, any model name", async () => {
|
|
277
|
+
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
|
|
278
|
+
const fetchFn: typeof fetch = Object.assign(
|
|
279
|
+
async (input: string | URL | Request, init?: RequestInit) => {
|
|
280
|
+
const url = String(input);
|
|
281
|
+
if (url.endsWith("/api/tags")) {
|
|
282
|
+
return new Response(JSON.stringify({ models: [] }), { status: 200 });
|
|
283
|
+
}
|
|
284
|
+
const body = JSON.parse(String(init?.body)) as Record<string, unknown>;
|
|
285
|
+
calls.push({ url, body });
|
|
286
|
+
return new Response(
|
|
287
|
+
JSON.stringify({
|
|
288
|
+
model: "llama3.2:1b",
|
|
289
|
+
message: { role: "assistant", content: "pong" },
|
|
290
|
+
prompt_eval_count: 4,
|
|
291
|
+
eval_count: 1,
|
|
292
|
+
}),
|
|
293
|
+
{ status: 200 },
|
|
294
|
+
);
|
|
295
|
+
},
|
|
296
|
+
{ preconnect: () => {} },
|
|
297
|
+
) as typeof fetch;
|
|
298
|
+
|
|
299
|
+
const client = await openOllama({
|
|
300
|
+
model: "llama3.2:1b",
|
|
301
|
+
baseUrl: "http://ollama.test:11434",
|
|
302
|
+
fetch: fetchFn,
|
|
303
|
+
});
|
|
304
|
+
const result = await client.complete({
|
|
305
|
+
messages: [{ role: "user", content: "ping" }],
|
|
306
|
+
temperature: 0,
|
|
307
|
+
maxTokens: 16,
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(result.driverId).toBe("ollama");
|
|
311
|
+
expect(result.text).toBe("pong");
|
|
312
|
+
expect(result.usage?.inputTokens).toBe(4);
|
|
313
|
+
expect(calls).toHaveLength(1);
|
|
314
|
+
expect(calls[0]!.url).toBe("http://ollama.test:11434/api/chat");
|
|
315
|
+
expect(calls[0]!.body.model).toBe("llama3.2:1b");
|
|
316
|
+
expect(calls[0]!.body.stream).toBe(false);
|
|
317
|
+
expect(calls[0]!.body.think).toBe(false);
|
|
318
|
+
expect(calls[0]!.body.options).toEqual({ temperature: 0, num_predict: 16 });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("unreachable server throws OllamaUnavailableError (no silent fallback)", async () => {
|
|
322
|
+
const fetchFn: typeof fetch = Object.assign(
|
|
323
|
+
async () => {
|
|
324
|
+
throw new TypeError("connection refused");
|
|
325
|
+
},
|
|
326
|
+
{ preconnect: () => {} },
|
|
327
|
+
) as typeof fetch;
|
|
328
|
+
|
|
329
|
+
await expect(
|
|
330
|
+
openOllama({ baseUrl: "http://127.0.0.1:9", fetch: fetchFn }),
|
|
331
|
+
).rejects.toBeInstanceOf(OllamaUnavailableError);
|
|
332
|
+
});
|
|
156
333
|
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real streaming + ambient AbortSignal cancellation (no second mechanism).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { withAbortSignal } from "../kernel/abort-scope.ts";
|
|
7
|
+
import { createFx, createFxContext } from "../kernel/fx.ts";
|
|
8
|
+
import { ai, createAiRuntime } from "../elements/ai.ts";
|
|
9
|
+
import { openOpenaiCompatible } from "./ai-openai-compatible.ts";
|
|
10
|
+
import { openOllama } from "./ai-ollama.ts";
|
|
11
|
+
import { mockAiDriver } from "./ai-mock.ts";
|
|
12
|
+
|
|
13
|
+
describe("openai-compatible SSE stream", () => {
|
|
14
|
+
test("parses data: chunks and honours abort", async () => {
|
|
15
|
+
const sse =
|
|
16
|
+
'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n' +
|
|
17
|
+
'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n' +
|
|
18
|
+
"data: [DONE]\n\n";
|
|
19
|
+
let aborted = false;
|
|
20
|
+
const fetchFn: typeof fetch = Object.assign(
|
|
21
|
+
async (_input: string | URL | Request, init?: RequestInit) => {
|
|
22
|
+
const signal = init?.signal;
|
|
23
|
+
if (signal) {
|
|
24
|
+
signal.addEventListener("abort", () => {
|
|
25
|
+
aborted = true;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return new Response(sse, {
|
|
29
|
+
status: 200,
|
|
30
|
+
headers: { "content-type": "text/event-stream" },
|
|
31
|
+
});
|
|
32
|
+
},
|
|
33
|
+
{ preconnect: () => {} },
|
|
34
|
+
) as typeof fetch;
|
|
35
|
+
|
|
36
|
+
const client = await openOpenaiCompatible({
|
|
37
|
+
apiKey: "sk-test",
|
|
38
|
+
baseUrl: "https://example.test/v1",
|
|
39
|
+
fetch: fetchFn,
|
|
40
|
+
});
|
|
41
|
+
const parts: string[] = [];
|
|
42
|
+
for await (const chunk of client.stream!({
|
|
43
|
+
messages: [{ role: "user", content: "hi" }],
|
|
44
|
+
})) {
|
|
45
|
+
if (chunk.text) parts.push(chunk.text);
|
|
46
|
+
}
|
|
47
|
+
expect(parts.join("")).toBe("Hello");
|
|
48
|
+
|
|
49
|
+
const ac = new AbortController();
|
|
50
|
+
const iter = client.stream!({
|
|
51
|
+
messages: [{ role: "user", content: "hi" }],
|
|
52
|
+
signal: ac.signal,
|
|
53
|
+
})[Symbol.asyncIterator]();
|
|
54
|
+
await iter.next();
|
|
55
|
+
ac.abort();
|
|
56
|
+
// Consumer abort should mark the signal; driver checks between reads.
|
|
57
|
+
expect(ac.signal.aborted).toBe(true);
|
|
58
|
+
void aborted;
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("ollama NDJSON stream", () => {
|
|
63
|
+
test("yields message.content deltas", async () => {
|
|
64
|
+
const ndjson =
|
|
65
|
+
JSON.stringify({ message: { content: "A" }, done: false }) +
|
|
66
|
+
"\n" +
|
|
67
|
+
JSON.stringify({ message: { content: "B" }, done: true }) +
|
|
68
|
+
"\n";
|
|
69
|
+
const fetchFn: typeof fetch = Object.assign(
|
|
70
|
+
async (input: string | URL | Request) => {
|
|
71
|
+
const url = String(input);
|
|
72
|
+
if (url.endsWith("/api/tags")) {
|
|
73
|
+
return new Response(JSON.stringify({ models: [] }), { status: 200 });
|
|
74
|
+
}
|
|
75
|
+
return new Response(ndjson, { status: 200 });
|
|
76
|
+
},
|
|
77
|
+
{ preconnect: () => {} },
|
|
78
|
+
) as typeof fetch;
|
|
79
|
+
|
|
80
|
+
const client = await openOllama({
|
|
81
|
+
model: "llama3.2:1b",
|
|
82
|
+
baseUrl: "http://ollama.test:11434",
|
|
83
|
+
fetch: fetchFn,
|
|
84
|
+
});
|
|
85
|
+
const parts: string[] = [];
|
|
86
|
+
for await (const chunk of client.stream!({
|
|
87
|
+
messages: [{ role: "user", content: "x" }],
|
|
88
|
+
})) {
|
|
89
|
+
if (chunk.text) parts.push(chunk.text);
|
|
90
|
+
}
|
|
91
|
+
expect(parts.join("")).toBe("AB");
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("fx.stream uses ambient AbortSignal", () => {
|
|
96
|
+
test("race abort cancels in-flight stream fetch", async () => {
|
|
97
|
+
let sawAbort = false;
|
|
98
|
+
const smart = ai.model("smart");
|
|
99
|
+
const runtime = createAiRuntime({
|
|
100
|
+
models: [smart],
|
|
101
|
+
defaultDriver: {
|
|
102
|
+
id: "mock",
|
|
103
|
+
async open() {
|
|
104
|
+
return {
|
|
105
|
+
driverId: "mock" as const,
|
|
106
|
+
model: "smart",
|
|
107
|
+
async complete() {
|
|
108
|
+
return { text: "", model: "smart", driverId: "mock" as const };
|
|
109
|
+
},
|
|
110
|
+
async *stream(opts) {
|
|
111
|
+
const signal = opts.signal;
|
|
112
|
+
yield { text: "start" };
|
|
113
|
+
await new Promise<void>((resolve, reject) => {
|
|
114
|
+
const t = setTimeout(resolve, 5_000);
|
|
115
|
+
signal?.addEventListener("abort", () => {
|
|
116
|
+
sawAbort = true;
|
|
117
|
+
clearTimeout(t);
|
|
118
|
+
const err = new Error("aborted");
|
|
119
|
+
err.name = "AbortError";
|
|
120
|
+
reject(err);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
yield { text: "never" };
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const fx = createFx({
|
|
131
|
+
flow: "stream-host",
|
|
132
|
+
effects: { asks: ["smart"] },
|
|
133
|
+
aiRuntime: runtime,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await expect(
|
|
137
|
+
fx.race([
|
|
138
|
+
async () => {
|
|
139
|
+
for await (const _c of fx.stream("smart", { prompt: "long" })) {
|
|
140
|
+
// consume until aborted by the race sibling
|
|
141
|
+
}
|
|
142
|
+
return "stream";
|
|
143
|
+
},
|
|
144
|
+
async () => {
|
|
145
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
146
|
+
return "winner";
|
|
147
|
+
},
|
|
148
|
+
]),
|
|
149
|
+
).resolves.toBe("winner");
|
|
150
|
+
|
|
151
|
+
// Give the abort listener a tick.
|
|
152
|
+
await new Promise((r) => setTimeout(r, 30));
|
|
153
|
+
expect(sawAbort).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("mock driver streams through fx.stream", async () => {
|
|
157
|
+
const smart = ai.model("smart");
|
|
158
|
+
const runtime = createAiRuntime({
|
|
159
|
+
models: [smart],
|
|
160
|
+
defaultDriver: mockAiDriver,
|
|
161
|
+
clients: {
|
|
162
|
+
smart: await mockAiDriver.open({
|
|
163
|
+
model: "smart",
|
|
164
|
+
mockResponses: { "*": "abcdef" },
|
|
165
|
+
}),
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
const { fx } = createFxContext({
|
|
169
|
+
flow: "s",
|
|
170
|
+
effects: { asks: ["smart"] },
|
|
171
|
+
aiRuntime: runtime,
|
|
172
|
+
});
|
|
173
|
+
const parts: string[] = [];
|
|
174
|
+
for await (const c of fx.stream("smart", { prompt: "hi" })) {
|
|
175
|
+
parts.push(c);
|
|
176
|
+
}
|
|
177
|
+
expect(parts.join("")).toBe("abcdef");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("withAbortSignal aborts cooperative mock stream", async () => {
|
|
181
|
+
const client = await mockAiDriver.open({
|
|
182
|
+
mockResponses: { "*": "abcdefghij" },
|
|
183
|
+
});
|
|
184
|
+
const ac = new AbortController();
|
|
185
|
+
await withAbortSignal(ac.signal, async () => {
|
|
186
|
+
const iter = client.stream!({
|
|
187
|
+
messages: [{ role: "user", content: "x" }],
|
|
188
|
+
signal: ac.signal,
|
|
189
|
+
})[Symbol.asyncIterator]();
|
|
190
|
+
await iter.next();
|
|
191
|
+
ac.abort();
|
|
192
|
+
await expect(iter.next()).rejects.toThrow();
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|