@zerotal/ai 1.5.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.
@@ -0,0 +1,286 @@
1
+ import { AiCancelledError, AiRequestError, AiSchemaError } from "../errors.ts";
2
+ import { recheckAgainstSchema, translateSchema, type SchemaInput } from "../schema.ts";
3
+ import type {
4
+ AiMessage,
5
+ AiObjectResponse,
6
+ AiRequest,
7
+ AiResponse,
8
+ AiStopReason,
9
+ AiStreamChunk,
10
+ AiTool,
11
+ AiToolCall,
12
+ AiUsage,
13
+ OllamaConfigShape,
14
+ } from "../types.ts";
15
+ import { normalizeMessages, type AiDriver, type DriverStatus } from "./AiDriver.ts";
16
+ import { readNdjson } from "./sse.ts";
17
+
18
+ interface OllamaChatResponse {
19
+ model: string;
20
+ message?: {
21
+ content?: string;
22
+ tool_calls?: Array<{ function: { name: string; arguments: Record<string, unknown> } }>;
23
+ };
24
+ done?: boolean;
25
+ done_reason?: string;
26
+ prompt_eval_count?: number;
27
+ eval_count?: number;
28
+ }
29
+
30
+ /**
31
+ * The Ollama driver — a local model server, so no API key and no billing.
32
+ *
33
+ * Its value here is not production traffic; it is that every test of the shared
34
+ * surface can run against a real server someone has on their laptop, and that a
35
+ * contributor with no provider account can still exercise the whole path.
36
+ */
37
+ export class OllamaDriver implements AiDriver {
38
+ readonly name = "ollama";
39
+
40
+ constructor(
41
+ private readonly config: OllamaConfigShape,
42
+ /** Injected transport, for tests. Defaults to global `fetch`. */
43
+ private readonly fetchImpl: typeof fetch = fetch,
44
+ ) {}
45
+
46
+ get model(): string {
47
+ return this.config.model;
48
+ }
49
+
50
+ async text(request: AiRequest): Promise<AiResponse> {
51
+ const body = this._body(request);
52
+ body["stream"] = false;
53
+
54
+ const response = await this._send("/api/chat", body, request);
55
+ const chat = (await response.json()) as OllamaChatResponse;
56
+ return this._toResponse(chat);
57
+ }
58
+
59
+ async *stream(request: AiRequest): AsyncIterable<AiStreamChunk> {
60
+ const body = this._body(request);
61
+ body["stream"] = true;
62
+
63
+ const response = await this._send("/api/chat", body, request);
64
+
65
+ let text = "";
66
+ let last: OllamaChatResponse | undefined;
67
+
68
+ for await (const line of readNdjson(response, request.signal)) {
69
+ let chunk: OllamaChatResponse;
70
+ try {
71
+ chunk = JSON.parse(line) as OllamaChatResponse;
72
+ } catch {
73
+ continue;
74
+ }
75
+
76
+ last = chunk;
77
+ const delta = chunk.message?.content;
78
+ if (delta) {
79
+ text += delta;
80
+ yield { type: "text", text: delta };
81
+ }
82
+ if (chunk.done) break;
83
+ }
84
+
85
+ const toolCalls = toToolCalls(last?.message?.tool_calls);
86
+ const result: AiResponse = {
87
+ text,
88
+ model: last?.model ?? this.config.model,
89
+ usage: toUsage(last),
90
+ stopReason: toStopReason(last?.done_reason ?? null, toolCalls.length > 0),
91
+ toolCalls,
92
+ assistantTurn: { role: "assistant", content: text, toolCalls },
93
+ };
94
+
95
+ for (const call of toolCalls) yield { type: "tool_call", call };
96
+ yield { type: "done", response: result };
97
+ }
98
+
99
+ async object<T>(request: AiRequest, schema: SchemaInput): Promise<AiObjectResponse<T>> {
100
+ const body = this._body(request);
101
+ body["stream"] = false;
102
+ delete body["tools"];
103
+ // Ollama takes the JSON Schema directly as `format`, not wrapped.
104
+ body["format"] = translateSchema(schema);
105
+
106
+ const response = await this._send("/api/chat", body, request);
107
+ const chat = (await response.json()) as OllamaChatResponse;
108
+ const text = chat.message?.content ?? "";
109
+
110
+ let parsed: unknown;
111
+ try {
112
+ parsed = JSON.parse(text);
113
+ } catch {
114
+ throw new AiSchemaError(
115
+ `The model's answer was constrained to a schema but did not parse as JSON. Small local ` +
116
+ `models frequently ignore the format constraint — try a larger one, or a driver whose ` +
117
+ `provider enforces the schema server-side.`,
118
+ { model: chat.model, text: text.slice(0, 200) },
119
+ );
120
+ }
121
+
122
+ return {
123
+ object: recheckAgainstSchema<T>(schema, parsed),
124
+ model: chat.model,
125
+ usage: toUsage(chat),
126
+ raw: chat,
127
+ };
128
+ }
129
+
130
+ /** Ollama reports `prompt_eval_count` only after generating. 0 means unknown. */
131
+ async countTokens(_request: AiRequest): Promise<number> {
132
+ return 0;
133
+ }
134
+
135
+ async verify(): Promise<DriverStatus> {
136
+ try {
137
+ const response = await this.text({ prompt: "Reply with the single word: ok", maxTokens: 32 });
138
+ return {
139
+ ok: true,
140
+ model: response.model,
141
+ detail: `${response.text.trim().slice(0, 60)} · ${response.usage.inputTokens} in / ${response.usage.outputTokens} out`,
142
+ };
143
+ } catch (error) {
144
+ return {
145
+ ok: false,
146
+ model: this.config.model,
147
+ detail: error instanceof Error ? error.message : String(error),
148
+ };
149
+ }
150
+ }
151
+
152
+ // ── Wire ─────────────────────────────────────────────────────────────────
153
+
154
+ private _body(request: AiRequest): Record<string, unknown> {
155
+ const messages: Array<Record<string, unknown>> = [];
156
+ if (request.system) messages.push({ role: "system", content: request.system });
157
+ for (const message of normalizeMessages(request)) messages.push(...toOllamaMessages(message));
158
+
159
+ const options: Record<string, unknown> = {};
160
+ if (request.maxTokens !== undefined) options["num_predict"] = request.maxTokens;
161
+ if (request.temperature !== undefined) options["temperature"] = request.temperature;
162
+
163
+ const body: Record<string, unknown> = {
164
+ model: request.model ?? this.config.model,
165
+ messages,
166
+ };
167
+ if (Object.keys(options).length > 0) body["options"] = options;
168
+ if (request.tools?.length) body["tools"] = request.tools.map(toOllamaTool);
169
+
170
+ Object.assign(body, request.providerOptions?.["ollama"] ?? {});
171
+ return body;
172
+ }
173
+
174
+ private _toResponse(chat: OllamaChatResponse): AiResponse {
175
+ const text = chat.message?.content ?? "";
176
+ const toolCalls = toToolCalls(chat.message?.tool_calls);
177
+
178
+ return {
179
+ text,
180
+ model: chat.model,
181
+ usage: toUsage(chat),
182
+ stopReason: toStopReason(chat.done_reason ?? null, toolCalls.length > 0),
183
+ toolCalls,
184
+ assistantTurn: { role: "assistant", content: text, toolCalls },
185
+ raw: chat,
186
+ };
187
+ }
188
+
189
+ private async _send(
190
+ path: string,
191
+ body: Record<string, unknown>,
192
+ request: AiRequest,
193
+ ): Promise<Response> {
194
+ let response: Response;
195
+ try {
196
+ response = await this.fetchImpl(`${this.config.baseUrl}${path}`, {
197
+ method: "POST",
198
+ headers: { "Content-Type": "application/json" },
199
+ body: JSON.stringify(body),
200
+ signal: request.signal ?? AbortSignal.timeout(this.config.timeout),
201
+ });
202
+ } catch (error) {
203
+ if (request.signal?.aborted) throw new AiCancelledError();
204
+ throw new AiRequestError(
205
+ `Could not reach Ollama at ${this.config.baseUrl}: ` +
206
+ `${error instanceof Error ? error.message : String(error)}. Is \`ollama serve\` running?`,
207
+ 0,
208
+ );
209
+ }
210
+
211
+ if (response.ok) return response;
212
+
213
+ const detail = await response.text().catch(() => "");
214
+ throw new AiRequestError(
215
+ `Ollama error ${response.status}: ${detail || response.statusText}`,
216
+ response.status,
217
+ );
218
+ }
219
+ }
220
+
221
+ // ── Translation ─────────────────────────────────────────────────────────────
222
+
223
+ function toUsage(chat: OllamaChatResponse | undefined): AiUsage {
224
+ return {
225
+ inputTokens: chat?.prompt_eval_count ?? 0,
226
+ outputTokens: chat?.eval_count ?? 0,
227
+ cacheReadTokens: 0,
228
+ cacheWriteTokens: 0,
229
+ };
230
+ }
231
+
232
+ function toStopReason(reason: string | null, hasToolCalls: boolean): AiStopReason {
233
+ if (hasToolCalls) return "tool_use";
234
+ switch (reason) {
235
+ case "stop":
236
+ return "end_turn";
237
+ case "length":
238
+ return "max_tokens";
239
+ default:
240
+ return reason ? "unknown" : "end_turn";
241
+ }
242
+ }
243
+
244
+ /** One tool call as Ollama reports it — arguments already parsed, and no id. */
245
+ type OllamaToolCall = { function: { name: string; arguments: Record<string, unknown> } };
246
+
247
+ /** Ollama omits call ids, so one is synthesized to keep the pairing addressable. */
248
+ function toToolCalls(calls: OllamaToolCall[] | undefined): AiToolCall[] {
249
+ if (!calls) return [];
250
+ return calls.map((call, index) => ({
251
+ id: `ollama-${index}-${call.function.name}`,
252
+ name: call.function.name,
253
+ input: call.function.arguments ?? {},
254
+ }));
255
+ }
256
+
257
+ function toOllamaTool(tool: AiTool): Record<string, unknown> {
258
+ return {
259
+ type: "function",
260
+ function: {
261
+ name: tool.name,
262
+ description: tool.description,
263
+ parameters: tool.inputSchema,
264
+ },
265
+ };
266
+ }
267
+
268
+ function toOllamaMessages(message: AiMessage): Array<Record<string, unknown>> {
269
+ if (message.toolResults?.length) {
270
+ return message.toolResults.map((result) => ({ role: "tool", content: result.content }));
271
+ }
272
+
273
+ if (message.toolCalls?.length) {
274
+ return [
275
+ {
276
+ role: "assistant",
277
+ content: message.content,
278
+ tool_calls: message.toolCalls.map((call) => ({
279
+ function: { name: call.name, arguments: call.input },
280
+ })),
281
+ },
282
+ ];
283
+ }
284
+
285
+ return [{ role: message.role, content: message.content }];
286
+ }
@@ -0,0 +1,344 @@
1
+ import { AiCancelledError, AiRateLimitError, AiRequestError, AiSchemaError } from "../errors.ts";
2
+ import { recheckAgainstSchema, translateSchema, type SchemaInput } from "../schema.ts";
3
+ import type {
4
+ AiMessage,
5
+ AiObjectResponse,
6
+ AiRequest,
7
+ AiResponse,
8
+ AiStopReason,
9
+ AiStreamChunk,
10
+ AiTool,
11
+ AiToolCall,
12
+ AiUsage,
13
+ OpenAiConfigShape,
14
+ } from "../types.ts";
15
+ import { normalizeMessages, type AiDriver, type DriverStatus } from "./AiDriver.ts";
16
+ import { readSse } from "./sse.ts";
17
+
18
+ /** One choice from a Chat Completions response. */
19
+ interface OpenAiChoice {
20
+ message?: {
21
+ content?: string | null;
22
+ tool_calls?: Array<{ id: string; function: { name: string; arguments: string } }>;
23
+ };
24
+ finish_reason?: string | null;
25
+ }
26
+
27
+ interface OpenAiCompletion {
28
+ model: string;
29
+ choices: OpenAiChoice[];
30
+ usage?: {
31
+ prompt_tokens?: number;
32
+ completion_tokens?: number;
33
+ prompt_tokens_details?: { cached_tokens?: number };
34
+ };
35
+ }
36
+
37
+ /**
38
+ * The OpenAI driver — Chat Completions over `fetch`, no SDK.
39
+ *
40
+ * It exists to keep the abstraction honest. A provider-agnostic surface with one
41
+ * implementation is an Anthropic client with extra indirection; the useful
42
+ * questions ("does `object()` mean the same thing twice?", "does the agent loop
43
+ * depend on Anthropic's block shapes?") only get asked when a second driver has
44
+ * to answer them.
45
+ */
46
+ export class OpenAiDriver implements AiDriver {
47
+ readonly name = "openai";
48
+
49
+ constructor(
50
+ private readonly config: OpenAiConfigShape,
51
+ /** Injected transport, for tests. Defaults to global `fetch`. */
52
+ private readonly fetchImpl: typeof fetch = fetch,
53
+ ) {}
54
+
55
+ get model(): string {
56
+ return this.config.model;
57
+ }
58
+
59
+ async text(request: AiRequest): Promise<AiResponse> {
60
+ const body = this._body(request, false);
61
+ const completion = await this._post<OpenAiCompletion>("/chat/completions", body, request);
62
+ return this._toResponse(completion);
63
+ }
64
+
65
+ async *stream(request: AiRequest): AsyncIterable<AiStreamChunk> {
66
+ const body = this._body(request, true);
67
+ body["stream"] = true;
68
+ body["stream_options"] = { include_usage: true };
69
+
70
+ const response = await this._send("/chat/completions", body, request);
71
+
72
+ let text = "";
73
+ let model = this.config.model;
74
+ let finishReason: string | null = null;
75
+ let usage: AiUsage = {
76
+ inputTokens: 0,
77
+ outputTokens: 0,
78
+ cacheReadTokens: 0,
79
+ cacheWriteTokens: 0,
80
+ };
81
+
82
+ for await (const event of readSse(response, request.signal)) {
83
+ if (event === "[DONE]") break;
84
+
85
+ let parsed: {
86
+ model?: string;
87
+ choices?: Array<{ delta?: { content?: string }; finish_reason?: string | null }>;
88
+ usage?: OpenAiCompletion["usage"];
89
+ };
90
+ try {
91
+ parsed = JSON.parse(event);
92
+ } catch {
93
+ continue; // A keep-alive comment or a partial frame; the next one carries it.
94
+ }
95
+
96
+ if (parsed.model) model = parsed.model;
97
+ if (parsed.usage) usage = toUsage(parsed.usage);
98
+
99
+ const choice = parsed.choices?.[0];
100
+ if (choice?.finish_reason) finishReason = choice.finish_reason;
101
+
102
+ const delta = choice?.delta?.content;
103
+ if (delta) {
104
+ text += delta;
105
+ yield { type: "text", text: delta };
106
+ }
107
+ }
108
+
109
+ const result: AiResponse = {
110
+ text,
111
+ model,
112
+ usage,
113
+ stopReason: toStopReason(finishReason),
114
+ toolCalls: [],
115
+ assistantTurn: { role: "assistant", content: text },
116
+ };
117
+ yield { type: "done", response: result };
118
+ }
119
+
120
+ async object<T>(request: AiRequest, schema: SchemaInput): Promise<AiObjectResponse<T>> {
121
+ const body = this._body(request, false);
122
+ delete body["tools"];
123
+ body["response_format"] = {
124
+ type: "json_schema",
125
+ json_schema: { name: "response", strict: true, schema: translateSchema(schema) },
126
+ };
127
+
128
+ const completion = await this._post<OpenAiCompletion>("/chat/completions", body, request);
129
+ const text = completion.choices[0]?.message?.content ?? "";
130
+
131
+ let parsed: unknown;
132
+ try {
133
+ parsed = JSON.parse(text);
134
+ } catch {
135
+ throw new AiSchemaError(
136
+ `The model's answer was constrained to a schema but did not parse as JSON. This usually ` +
137
+ `means the response was truncated (finish_reason ` +
138
+ `${completion.choices[0]?.finish_reason ?? "null"}) — raise maxTokens.`,
139
+ { text: text.slice(0, 200) },
140
+ );
141
+ }
142
+
143
+ return {
144
+ object: recheckAgainstSchema<T>(schema, parsed),
145
+ model: completion.model,
146
+ usage: toUsage(completion.usage),
147
+ raw: completion,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Not implemented — there is no counting endpoint, and the only offline
153
+ * tokenizer would be a guess dressed as a number. 0 means "unknown"; the spend
154
+ * guard falls back to a labelled character approximation.
155
+ */
156
+ async countTokens(_request: AiRequest): Promise<number> {
157
+ return 0;
158
+ }
159
+
160
+ async verify(): Promise<DriverStatus> {
161
+ try {
162
+ const response = await this.text({ prompt: "Reply with the single word: ok", maxTokens: 32 });
163
+ return {
164
+ ok: true,
165
+ model: response.model,
166
+ detail: `${response.text.trim().slice(0, 60)} · ${response.usage.inputTokens} in / ${response.usage.outputTokens} out`,
167
+ };
168
+ } catch (error) {
169
+ return {
170
+ ok: false,
171
+ model: this.config.model,
172
+ detail: error instanceof Error ? error.message : String(error),
173
+ };
174
+ }
175
+ }
176
+
177
+ // ── Wire ─────────────────────────────────────────────────────────────────
178
+
179
+ private _body(request: AiRequest, streaming: boolean): Record<string, unknown> {
180
+ const messages: Array<Record<string, unknown>> = [];
181
+ if (request.system) messages.push({ role: "system", content: request.system });
182
+ for (const message of normalizeMessages(request)) messages.push(...toOpenAiMessages(message));
183
+
184
+ const body: Record<string, unknown> = {
185
+ model: request.model ?? this.config.model,
186
+ messages,
187
+ max_completion_tokens: request.maxTokens ?? this.config.maxTokens,
188
+ };
189
+
190
+ if (request.temperature !== undefined) body["temperature"] = request.temperature;
191
+ if (request.effort) body["reasoning_effort"] = request.effort;
192
+ if (request.tools?.length) body["tools"] = request.tools.map(toOpenAiTool);
193
+ if (streaming) body["stream"] = true;
194
+
195
+ Object.assign(body, request.providerOptions?.["openai"] ?? {});
196
+ return body;
197
+ }
198
+
199
+ private _toResponse(completion: OpenAiCompletion): AiResponse {
200
+ const choice = completion.choices[0];
201
+ const text = choice?.message?.content ?? "";
202
+ const toolCalls: AiToolCall[] = (choice?.message?.tool_calls ?? []).map((call) => ({
203
+ id: call.id,
204
+ name: call.function.name,
205
+ input: parseArguments(call.function.arguments),
206
+ }));
207
+
208
+ return {
209
+ text,
210
+ model: completion.model,
211
+ usage: toUsage(completion.usage),
212
+ stopReason: toStopReason(choice?.finish_reason ?? null),
213
+ toolCalls,
214
+ assistantTurn: { role: "assistant", content: text, toolCalls },
215
+ raw: completion,
216
+ };
217
+ }
218
+
219
+ private async _post<T>(
220
+ path: string,
221
+ body: Record<string, unknown>,
222
+ request: AiRequest,
223
+ ): Promise<T> {
224
+ const response = await this._send(path, body, request);
225
+ return (await response.json()) as T;
226
+ }
227
+
228
+ private async _send(
229
+ path: string,
230
+ body: Record<string, unknown>,
231
+ request: AiRequest,
232
+ ): Promise<Response> {
233
+ const init: RequestInit = {
234
+ method: "POST",
235
+ headers: {
236
+ Authorization: `Bearer ${this.config.apiKey}`,
237
+ "Content-Type": "application/json",
238
+ },
239
+ body: JSON.stringify(body),
240
+ signal: request.signal ?? AbortSignal.timeout(this.config.timeout),
241
+ };
242
+
243
+ let response: Response;
244
+ try {
245
+ response = await this.fetchImpl(`${this.config.baseUrl}${path}`, init);
246
+ } catch (error) {
247
+ if (request.signal?.aborted) throw new AiCancelledError();
248
+ throw new AiRequestError(
249
+ `Could not reach the OpenAI API: ${error instanceof Error ? error.message : String(error)}`,
250
+ 0,
251
+ );
252
+ }
253
+
254
+ if (response.ok) return response;
255
+
256
+ const detail = await response.text().catch(() => "");
257
+ if (response.status === 429) {
258
+ const retryAfter = Number(response.headers.get("retry-after"));
259
+ const message = `OpenAI rate limit: ${detail || response.statusText}`;
260
+ throw Number.isFinite(retryAfter) && retryAfter > 0
261
+ ? new AiRateLimitError(message, retryAfter)
262
+ : new AiRateLimitError(message);
263
+ }
264
+ throw new AiRequestError(
265
+ `OpenAI API error ${response.status}: ${detail || response.statusText}`,
266
+ response.status,
267
+ );
268
+ }
269
+ }
270
+
271
+ // ── Translation ─────────────────────────────────────────────────────────────
272
+
273
+ function toUsage(usage: OpenAiCompletion["usage"]): AiUsage {
274
+ return {
275
+ inputTokens: usage?.prompt_tokens ?? 0,
276
+ outputTokens: usage?.completion_tokens ?? 0,
277
+ cacheReadTokens: usage?.prompt_tokens_details?.cached_tokens ?? 0,
278
+ cacheWriteTokens: 0,
279
+ };
280
+ }
281
+
282
+ function toStopReason(reason: string | null): AiStopReason {
283
+ switch (reason) {
284
+ case "stop":
285
+ return "end_turn";
286
+ case "length":
287
+ return "max_tokens";
288
+ case "tool_calls":
289
+ return "tool_use";
290
+ case "content_filter":
291
+ return "refusal";
292
+ default:
293
+ return reason ? "unknown" : "end_turn";
294
+ }
295
+ }
296
+
297
+ function toOpenAiTool(tool: AiTool): Record<string, unknown> {
298
+ return {
299
+ type: "function",
300
+ function: {
301
+ name: tool.name,
302
+ description: tool.description,
303
+ parameters: tool.inputSchema,
304
+ strict: true,
305
+ },
306
+ };
307
+ }
308
+
309
+ /** One of ours becomes one or many of theirs — tool results are separate turns. */
310
+ function toOpenAiMessages(message: AiMessage): Array<Record<string, unknown>> {
311
+ if (message.toolResults?.length) {
312
+ return message.toolResults.map((result) => ({
313
+ role: "tool",
314
+ tool_call_id: result.id,
315
+ content: result.content,
316
+ }));
317
+ }
318
+
319
+ if (message.toolCalls?.length) {
320
+ return [
321
+ {
322
+ role: "assistant",
323
+ content: message.content || null,
324
+ tool_calls: message.toolCalls.map((call) => ({
325
+ id: call.id,
326
+ type: "function",
327
+ function: { name: call.name, arguments: JSON.stringify(call.input) },
328
+ })),
329
+ },
330
+ ];
331
+ }
332
+
333
+ return [{ role: message.role, content: message.content }];
334
+ }
335
+
336
+ /** Tool arguments arrive as a JSON *string*; a malformed one must not throw. */
337
+ function parseArguments(raw: string): Record<string, unknown> {
338
+ try {
339
+ const parsed: unknown = JSON.parse(raw);
340
+ return typeof parsed === "object" && parsed !== null ? (parsed as Record<string, unknown>) : {};
341
+ } catch {
342
+ return {};
343
+ }
344
+ }