@sulala/agent 0.1.7 → 0.1.8

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.
@@ -1,11 +1,10 @@
1
1
  import { log, saveAiResult } from '../db/index.js';
2
2
  const providers = new Map();
3
- const defaultProvider = process.env.AI_DEFAULT_PROVIDER || 'ollama';
4
3
  export function registerProvider(name, adapter) {
5
4
  providers.set(name, adapter);
6
5
  }
7
6
  export function getProvider(name) {
8
- const key = name ?? defaultProvider;
7
+ const key = name ?? process.env.AI_DEFAULT_PROVIDER ?? 'ollama';
9
8
  const p = providers.get(key);
10
9
  if (!p)
11
10
  throw new Error(`Unknown AI provider: ${key}. Registered: ${[...providers.keys()].join(', ')}`);
@@ -13,7 +12,7 @@ export function getProvider(name) {
13
12
  }
14
13
  /** Stream completion (OpenAI, OpenRouter, Ollama). Falls back to non-streaming for other providers. */
15
14
  export async function completeStream(options, onChunk) {
16
- const provider = options.provider ?? defaultProvider;
15
+ const provider = options.provider ?? process.env.AI_DEFAULT_PROVIDER ?? 'ollama';
17
16
  const messages = options.messages ?? [];
18
17
  const model = options.model;
19
18
  const max_tokens = options.max_tokens ?? 1024;
@@ -217,7 +216,7 @@ export async function completeStream(options, onChunk) {
217
216
  let openAiStreamClient = null;
218
217
  let openRouterStreamClient = null;
219
218
  export async function complete(options = {}) {
220
- const { provider = defaultProvider, model, messages, max_tokens = 1024, task_id = null, tools, signal, } = options;
219
+ const { provider = process.env.AI_DEFAULT_PROVIDER ?? 'ollama', model, messages, max_tokens = 1024, task_id = null, tools, signal, } = options;
221
220
  const start = Date.now();
222
221
  const adapter = getProvider(provider);
223
222
  const id = `ai_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
@@ -263,210 +262,7 @@ function stubAdapter(defaultModel = 'stub') {
263
262
  },
264
263
  };
265
264
  }
266
- const OPENAI_DEFAULT = process.env.AI_OPENAI_DEFAULT_MODEL || 'gpt-4o-mini';
267
- const OPENROUTER_DEFAULT = process.env.AI_OPENROUTER_DEFAULT_MODEL || 'openai/gpt-4o-mini';
268
- registerProvider('stub', stubAdapter('stub'));
269
- if (process.env.OPENAI_API_KEY) {
270
- try {
271
- const mod = await import('openai').catch(() => ({}));
272
- const OpenAI = mod.OpenAI;
273
- if (OpenAI) {
274
- const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
275
- openAiStreamClient = client;
276
- registerProvider('openai', {
277
- defaultModel: OPENAI_DEFAULT,
278
- async complete({ model, messages, max_tokens, tools: toolsOpt, signal }) {
279
- // OpenAI expects tool_calls as { id, type: "function", function: { name, arguments } }
280
- const openAiMessages = (messages ?? []).map((m) => {
281
- if (!m.tool_calls?.length)
282
- return m;
283
- return {
284
- ...m,
285
- tool_calls: m.tool_calls.map((tc) => ({
286
- id: tc.id,
287
- type: 'function',
288
- function: { name: tc.name, arguments: tc.arguments ?? '' },
289
- })),
290
- };
291
- });
292
- const createOpts = {
293
- model: model || OPENAI_DEFAULT,
294
- messages: openAiMessages,
295
- max_tokens: max_tokens || 1024,
296
- };
297
- if (toolsOpt?.length) {
298
- createOpts.tools = toolsOpt.map((t) => ({
299
- type: 'function',
300
- function: { name: t.name, description: t.description, parameters: t.parameters ?? {} },
301
- }));
302
- }
303
- const res = await client.chat.completions.create(createOpts, signal ? { signal } : undefined);
304
- const choice = res.choices?.[0];
305
- const msg = choice?.message;
306
- const tool_calls = msg?.tool_calls?.map((tc) => ({
307
- id: tc.id,
308
- name: tc.function?.name ?? '',
309
- arguments: tc.function?.arguments ?? '',
310
- }));
311
- return {
312
- content: msg?.content ?? '',
313
- usage: res.usage ?? {},
314
- ...(tool_calls?.length ? { tool_calls } : {}),
315
- };
316
- },
317
- });
318
- }
319
- }
320
- catch {
321
- // import failed
322
- }
323
- }
324
- if (process.env.OPENROUTER_API_KEY) {
325
- try {
326
- const mod = await import('openai').catch(() => ({}));
327
- const OpenAI = mod.OpenAI;
328
- if (OpenAI) {
329
- const client = new OpenAI({
330
- apiKey: process.env.OPENROUTER_API_KEY,
331
- baseURL: 'https://openrouter.ai/api/v1',
332
- });
333
- openRouterStreamClient = client;
334
- registerProvider('openrouter', {
335
- defaultModel: OPENROUTER_DEFAULT,
336
- async complete({ model, messages, max_tokens, tools: toolsOpt, signal }) {
337
- const openAiMessages = (messages ?? []).map((m) => {
338
- if (!m.tool_calls?.length)
339
- return m;
340
- return {
341
- ...m,
342
- tool_calls: m.tool_calls.map((tc) => ({
343
- id: tc.id,
344
- type: 'function',
345
- function: { name: tc.name, arguments: tc.arguments ?? '' },
346
- })),
347
- };
348
- });
349
- const createOpts = {
350
- model: model || OPENROUTER_DEFAULT,
351
- messages: openAiMessages,
352
- max_tokens: max_tokens || 1024,
353
- };
354
- if (toolsOpt?.length) {
355
- createOpts.tools = toolsOpt.map((t) => ({
356
- type: 'function',
357
- function: { name: t.name, description: t.description, parameters: t.parameters ?? {} },
358
- }));
359
- }
360
- const res = await client.chat.completions.create(createOpts, signal ? { signal } : undefined);
361
- const choice = res.choices?.[0];
362
- const msg = choice?.message;
363
- const tool_calls = msg?.tool_calls?.map((tc) => ({
364
- id: tc.id,
365
- name: tc.function?.name ?? '',
366
- arguments: tc.function?.arguments ?? '',
367
- }));
368
- return {
369
- content: msg?.content ?? '',
370
- usage: res.usage ?? {},
371
- ...(tool_calls?.length ? { tool_calls } : {}),
372
- };
373
- },
374
- });
375
- }
376
- }
377
- catch {
378
- // import failed
379
- }
380
- }
381
- if (!providers.has('openai')) {
382
- registerProvider('openai', stubAdapter(OPENAI_DEFAULT));
383
- }
384
- const CLAUDE_DEFAULT = process.env.AI_CLAUDE_DEFAULT_MODEL || 'claude-sonnet-4-6';
385
- if (process.env.ANTHROPIC_API_KEY) {
386
- registerProvider('claude', {
387
- defaultModel: CLAUDE_DEFAULT,
388
- async complete({ model, messages, max_tokens }) {
389
- const key = process.env.ANTHROPIC_API_KEY;
390
- const systemMsg = messages.find((m) => m.role === 'system');
391
- const chatMessages = messages.filter((m) => m.role !== 'system').map((m) => ({
392
- role: m.role === 'assistant' ? 'assistant' : 'user',
393
- content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
394
- }));
395
- const body = {
396
- model: model || CLAUDE_DEFAULT,
397
- max_tokens: max_tokens || 1024,
398
- messages: chatMessages,
399
- ...(systemMsg && { system: typeof systemMsg.content === 'string' ? systemMsg.content : JSON.stringify(systemMsg.content) }),
400
- };
401
- const res = await fetch('https://api.anthropic.com/v1/messages', {
402
- method: 'POST',
403
- headers: {
404
- 'Content-Type': 'application/json',
405
- 'x-api-key': key,
406
- 'anthropic-version': '2023-06-01',
407
- },
408
- body: JSON.stringify(body),
409
- });
410
- if (!res.ok) {
411
- const err = await res.text();
412
- throw new Error(`Anthropic API: ${res.status} ${err}`);
413
- }
414
- const data = (await res.json());
415
- const content = data.content?.find((c) => c.type === 'text')?.text || '';
416
- const usage = data.usage || {};
417
- return { content, usage: { prompt_tokens: usage.input_tokens ?? 0, completion_tokens: usage.output_tokens ?? 0 } };
418
- },
419
- });
420
- }
421
- else {
422
- registerProvider('claude', stubAdapter(CLAUDE_DEFAULT));
423
- }
424
- const GEMINI_DEFAULT = process.env.AI_GEMINI_DEFAULT_MODEL || 'gemini-2.5-flash';
425
- const GEMINI_KEY = process.env.GOOGLE_GEMINI_API_KEY || process.env.GEMINI_API_KEY;
426
- if (GEMINI_KEY) {
427
- registerProvider('gemini', {
428
- defaultModel: GEMINI_DEFAULT,
429
- async complete({ model, messages, max_tokens }) {
430
- const modelId = model || GEMINI_DEFAULT;
431
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${GEMINI_KEY}`;
432
- const contents = messages
433
- .filter((m) => m.role !== 'system')
434
- .map((m) => ({
435
- role: (m.role === 'assistant' ? 'model' : 'user'),
436
- parts: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }],
437
- }));
438
- const systemInstruction = messages.find((m) => m.role === 'system');
439
- const body = {
440
- contents: contents.length ? contents : [{ role: 'user', parts: [{ text: '' }] }],
441
- generationConfig: { maxOutputTokens: max_tokens || 1024 },
442
- ...(systemInstruction && {
443
- systemInstruction: { parts: [{ text: typeof systemInstruction.content === 'string' ? systemInstruction.content : JSON.stringify(systemInstruction.content) }] },
444
- }),
445
- };
446
- const res = await fetch(url, {
447
- method: 'POST',
448
- headers: { 'Content-Type': 'application/json' },
449
- body: JSON.stringify(body),
450
- });
451
- if (!res.ok) {
452
- const err = await res.text();
453
- throw new Error(`Gemini API: ${res.status} ${err}`);
454
- }
455
- const data = (await res.json());
456
- const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
457
- const usage = data.usageMetadata || {};
458
- return {
459
- content: text,
460
- usage: { prompt_tokens: usage.promptTokenCount ?? 0, completion_tokens: usage.candidatesTokenCount ?? 0 },
461
- };
462
- },
463
- });
464
- }
465
- else {
466
- registerProvider('gemini', stubAdapter(GEMINI_DEFAULT));
467
- }
468
265
  const OLLAMA_DEFAULT = process.env.AI_OLLAMA_DEFAULT_MODEL || 'llama3.2';
469
- const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
470
266
  const OLLAMA_THINKING_MODELS = ['deepseek-r1', 'qwen3', 'deepseek-v3.1', 'gpt-oss'];
471
267
  function ollamaSupportsThinking(model) {
472
268
  const m = (model || OLLAMA_DEFAULT).toLowerCase();
@@ -514,79 +310,338 @@ function fromOllamaToolCalls(toolCalls) {
514
310
  return { id: `ollama_${i}_${Date.now()}`, name, arguments: argsStr };
515
311
  });
516
312
  }
517
- registerProvider('ollama', {
518
- defaultModel: OLLAMA_DEFAULT,
519
- async complete({ model, messages, max_tokens, think, tools: toolsOpt }) {
520
- const url = `${OLLAMA_BASE}/api/chat`;
521
- const ollamaMessages = toOllamaMessages(messages ?? []);
522
- const body = {
523
- model: model || OLLAMA_DEFAULT,
524
- messages: ollamaMessages,
525
- options: { num_predict: max_tokens || 1024 },
526
- stream: false,
527
- think: think !== false && ollamaSupportsThinking(model),
528
- };
529
- if (toolsOpt?.length) {
530
- body.tools = toolsOpt.map((t) => ({
531
- type: 'function',
532
- function: { name: t.name, description: t.description, parameters: t.parameters ?? {} },
533
- }));
534
- }
535
- console.log('[Ollama] POST', url, 'model:', model || OLLAMA_DEFAULT, toolsOpt?.length ? `tools=${toolsOpt.length}` : '');
536
- let res;
313
+ const OPENAI_DEFAULT = process.env.AI_OPENAI_DEFAULT_MODEL || 'gpt-4o-mini';
314
+ const OPENROUTER_DEFAULT = process.env.AI_OPENROUTER_DEFAULT_MODEL || 'openai/gpt-4o-mini';
315
+ async function registerAllProviders() {
316
+ providers.clear();
317
+ registerProvider('stub', stubAdapter('stub'));
318
+ if (process.env.OPENAI_API_KEY) {
537
319
  try {
538
- res = await fetch(url, {
539
- method: 'POST',
540
- headers: { 'Content-Type': 'application/json' },
541
- body: JSON.stringify(body),
542
- });
320
+ const mod = await import('openai').catch(() => ({}));
321
+ const OpenAI = mod.OpenAI;
322
+ if (OpenAI) {
323
+ const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
324
+ openAiStreamClient = client;
325
+ registerProvider('openai', {
326
+ defaultModel: OPENAI_DEFAULT,
327
+ async complete({ model, messages, max_tokens, tools: toolsOpt, signal }) {
328
+ // OpenAI expects tool_calls as { id, type: "function", function: { name, arguments } }
329
+ const openAiMessages = (messages ?? []).map((m) => {
330
+ if (!m.tool_calls?.length)
331
+ return m;
332
+ return {
333
+ ...m,
334
+ tool_calls: m.tool_calls.map((tc) => ({
335
+ id: tc.id,
336
+ type: 'function',
337
+ function: { name: tc.name, arguments: tc.arguments ?? '' },
338
+ })),
339
+ };
340
+ });
341
+ const createOpts = {
342
+ model: model || OPENAI_DEFAULT,
343
+ messages: openAiMessages,
344
+ max_tokens: max_tokens || 1024,
345
+ };
346
+ if (toolsOpt?.length) {
347
+ createOpts.tools = toolsOpt.map((t) => ({
348
+ type: 'function',
349
+ function: { name: t.name, description: t.description, parameters: t.parameters ?? {} },
350
+ }));
351
+ }
352
+ const res = await client.chat.completions.create(createOpts, signal ? { signal } : undefined);
353
+ const choice = res.choices?.[0];
354
+ const msg = choice?.message;
355
+ const tool_calls = msg?.tool_calls?.map((tc) => ({
356
+ id: tc.id,
357
+ name: tc.function?.name ?? '',
358
+ arguments: tc.function?.arguments ?? '',
359
+ }));
360
+ return {
361
+ content: msg?.content ?? '',
362
+ usage: res.usage ?? {},
363
+ ...(tool_calls?.length ? { tool_calls } : {}),
364
+ };
365
+ },
366
+ });
367
+ }
368
+ }
369
+ catch {
370
+ // import failed
543
371
  }
544
- catch (e) {
545
- console.error('[Ollama] fetch failed:', e);
546
- const cause = e?.cause;
547
- if (cause?.code === 'ECONNREFUSED') {
548
- throw new Error('Ollama is not running. Start it with: ollama serve (or open the Ollama app).');
372
+ }
373
+ if (process.env.OPENROUTER_API_KEY) {
374
+ try {
375
+ const mod = await import('openai').catch(() => ({}));
376
+ const OpenAI = mod.OpenAI;
377
+ if (OpenAI) {
378
+ const client = new OpenAI({
379
+ apiKey: process.env.OPENROUTER_API_KEY,
380
+ baseURL: 'https://openrouter.ai/api/v1',
381
+ });
382
+ openRouterStreamClient = client;
383
+ registerProvider('openrouter', {
384
+ defaultModel: OPENROUTER_DEFAULT,
385
+ async complete({ model, messages, max_tokens, tools: toolsOpt, signal }) {
386
+ const openAiMessages = (messages ?? []).map((m) => {
387
+ if (!m.tool_calls?.length)
388
+ return m;
389
+ return {
390
+ ...m,
391
+ tool_calls: m.tool_calls.map((tc) => ({
392
+ id: tc.id,
393
+ type: 'function',
394
+ function: { name: tc.name, arguments: tc.arguments ?? '' },
395
+ })),
396
+ };
397
+ });
398
+ const createOpts = {
399
+ model: model || OPENROUTER_DEFAULT,
400
+ messages: openAiMessages,
401
+ max_tokens: max_tokens || 1024,
402
+ };
403
+ if (toolsOpt?.length) {
404
+ createOpts.tools = toolsOpt.map((t) => ({
405
+ type: 'function',
406
+ function: { name: t.name, description: t.description, parameters: t.parameters ?? {} },
407
+ }));
408
+ }
409
+ const res = await client.chat.completions.create(createOpts, signal ? { signal } : undefined);
410
+ const choice = res.choices?.[0];
411
+ const msg = choice?.message;
412
+ const tool_calls = msg?.tool_calls?.map((tc) => ({
413
+ id: tc.id,
414
+ name: tc.function?.name ?? '',
415
+ arguments: tc.function?.arguments ?? '',
416
+ }));
417
+ return {
418
+ content: msg?.content ?? '',
419
+ usage: res.usage ?? {},
420
+ ...(tool_calls?.length ? { tool_calls } : {}),
421
+ };
422
+ },
423
+ });
549
424
  }
550
- throw e;
551
425
  }
552
- if (!res.ok) {
553
- const errText = await res.text();
554
- let didRetry = false;
555
- if (res.status === 400 && errText.includes('does not support tools') && body.tools) {
556
- delete body.tools;
557
- console.log('[Ollama] Model does not support tools; retrying without tools');
426
+ catch {
427
+ // import failed
428
+ }
429
+ }
430
+ if (!providers.has('openai')) {
431
+ registerProvider('openai', stubAdapter(OPENAI_DEFAULT));
432
+ }
433
+ const CLAUDE_DEFAULT = process.env.AI_CLAUDE_DEFAULT_MODEL || 'claude-sonnet-4-6';
434
+ if (process.env.ANTHROPIC_API_KEY) {
435
+ registerProvider('claude', {
436
+ defaultModel: CLAUDE_DEFAULT,
437
+ async complete({ model, messages, max_tokens }) {
438
+ const key = process.env.ANTHROPIC_API_KEY;
439
+ const systemMsg = messages.find((m) => m.role === 'system');
440
+ const chatMessages = messages.filter((m) => m.role !== 'system').map((m) => ({
441
+ role: m.role === 'assistant' ? 'assistant' : 'user',
442
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
443
+ }));
444
+ const body = {
445
+ model: model || CLAUDE_DEFAULT,
446
+ max_tokens: max_tokens || 1024,
447
+ messages: chatMessages,
448
+ ...(systemMsg && { system: typeof systemMsg.content === 'string' ? systemMsg.content : JSON.stringify(systemMsg.content) }),
449
+ };
450
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
451
+ method: 'POST',
452
+ headers: {
453
+ 'Content-Type': 'application/json',
454
+ 'x-api-key': key,
455
+ 'anthropic-version': '2023-06-01',
456
+ },
457
+ body: JSON.stringify(body),
458
+ });
459
+ if (!res.ok) {
460
+ const err = await res.text();
461
+ throw new Error(`Anthropic API: ${res.status} ${err}`);
462
+ }
463
+ const data = (await res.json());
464
+ const content = data.content?.find((c) => c.type === 'text')?.text || '';
465
+ const usage = data.usage || {};
466
+ return { content, usage: { prompt_tokens: usage.input_tokens ?? 0, completion_tokens: usage.output_tokens ?? 0 } };
467
+ },
468
+ });
469
+ }
470
+ else {
471
+ registerProvider('claude', stubAdapter(CLAUDE_DEFAULT));
472
+ }
473
+ const GEMINI_DEFAULT = process.env.AI_GEMINI_DEFAULT_MODEL || 'gemini-2.5-flash';
474
+ const GEMINI_KEY = process.env.GOOGLE_GEMINI_API_KEY || process.env.GEMINI_API_KEY;
475
+ if (GEMINI_KEY) {
476
+ registerProvider('gemini', {
477
+ defaultModel: GEMINI_DEFAULT,
478
+ async complete({ model, messages, max_tokens }) {
479
+ const modelId = model || GEMINI_DEFAULT;
480
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${GEMINI_KEY}`;
481
+ const contents = messages
482
+ .filter((m) => m.role !== 'system')
483
+ .map((m) => ({
484
+ role: (m.role === 'assistant' ? 'model' : 'user'),
485
+ parts: [{ text: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }],
486
+ }));
487
+ const systemInstruction = messages.find((m) => m.role === 'system');
488
+ const body = {
489
+ contents: contents.length ? contents : [{ role: 'user', parts: [{ text: '' }] }],
490
+ generationConfig: { maxOutputTokens: max_tokens || 1024 },
491
+ ...(systemInstruction && {
492
+ systemInstruction: { parts: [{ text: typeof systemInstruction.content === 'string' ? systemInstruction.content : JSON.stringify(systemInstruction.content) }] },
493
+ }),
494
+ };
495
+ const res = await fetch(url, {
496
+ method: 'POST',
497
+ headers: { 'Content-Type': 'application/json' },
498
+ body: JSON.stringify(body),
499
+ });
500
+ if (!res.ok) {
501
+ const err = await res.text();
502
+ throw new Error(`Gemini API: ${res.status} ${err}`);
503
+ }
504
+ const data = (await res.json());
505
+ const text = data.candidates?.[0]?.content?.parts?.[0]?.text ?? '';
506
+ const usage = data.usageMetadata || {};
507
+ return {
508
+ content: text,
509
+ usage: { prompt_tokens: usage.promptTokenCount ?? 0, completion_tokens: usage.candidatesTokenCount ?? 0 },
510
+ };
511
+ },
512
+ });
513
+ }
514
+ else {
515
+ registerProvider('gemini', stubAdapter(GEMINI_DEFAULT));
516
+ }
517
+ const OLLAMA_DEFAULT = process.env.AI_OLLAMA_DEFAULT_MODEL || 'llama3.2';
518
+ const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || 'http://localhost:11434';
519
+ const OLLAMA_THINKING_MODELS = ['deepseek-r1', 'qwen3', 'deepseek-v3.1', 'gpt-oss'];
520
+ function ollamaSupportsThinking(model) {
521
+ const m = (model || OLLAMA_DEFAULT).toLowerCase();
522
+ return OLLAMA_THINKING_MODELS.some((t) => m === t || m.startsWith(t + ':'));
523
+ }
524
+ /** Convert agent messages to Ollama format (tool_calls use index/object args; tool results use tool_name). */
525
+ function toOllamaMessages(messages) {
526
+ return messages.map((m) => {
527
+ const base = {
528
+ role: m.role,
529
+ content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content ?? ''),
530
+ };
531
+ if (m.role === 'assistant' && m.tool_calls?.length) {
532
+ base.tool_calls = m.tool_calls.map((tc, i) => ({
533
+ type: 'function',
534
+ function: {
535
+ index: i,
536
+ name: tc.name,
537
+ arguments: (() => {
538
+ try {
539
+ return typeof tc.arguments === 'string' ? JSON.parse(tc.arguments) : (tc.arguments ?? {});
540
+ }
541
+ catch {
542
+ return {};
543
+ }
544
+ })(),
545
+ },
546
+ }));
547
+ }
548
+ if (m.role === 'tool') {
549
+ base.tool_name = m.name ?? '';
550
+ }
551
+ return base;
552
+ });
553
+ }
554
+ /** Parse Ollama tool_calls (arguments may be object or string) into agent format. */
555
+ function fromOllamaToolCalls(toolCalls) {
556
+ if (!toolCalls?.length)
557
+ return undefined;
558
+ return toolCalls.map((tc, i) => {
559
+ const fn = tc.function;
560
+ const name = fn?.name ?? '';
561
+ const args = fn?.arguments;
562
+ const argsStr = typeof args === 'string' ? args : JSON.stringify(args ?? {});
563
+ return { id: `ollama_${i}_${Date.now()}`, name, arguments: argsStr };
564
+ });
565
+ }
566
+ registerProvider('ollama', {
567
+ defaultModel: OLLAMA_DEFAULT,
568
+ async complete({ model, messages, max_tokens, think, tools: toolsOpt }) {
569
+ const url = `${OLLAMA_BASE}/api/chat`;
570
+ const ollamaMessages = toOllamaMessages(messages ?? []);
571
+ const body = {
572
+ model: model || OLLAMA_DEFAULT,
573
+ messages: ollamaMessages,
574
+ options: { num_predict: max_tokens || 1024 },
575
+ stream: false,
576
+ think: think !== false && ollamaSupportsThinking(model),
577
+ };
578
+ if (toolsOpt?.length) {
579
+ body.tools = toolsOpt.map((t) => ({
580
+ type: 'function',
581
+ function: { name: t.name, description: t.description, parameters: t.parameters ?? {} },
582
+ }));
583
+ }
584
+ console.log('[Ollama] POST', url, 'model:', model || OLLAMA_DEFAULT, toolsOpt?.length ? `tools=${toolsOpt.length}` : '');
585
+ let res;
586
+ try {
558
587
  res = await fetch(url, {
559
588
  method: 'POST',
560
589
  headers: { 'Content-Type': 'application/json' },
561
590
  body: JSON.stringify(body),
562
591
  });
563
- didRetry = true;
592
+ }
593
+ catch (e) {
594
+ console.error('[Ollama] fetch failed:', e);
595
+ const cause = e?.cause;
596
+ if (cause?.code === 'ECONNREFUSED') {
597
+ throw new Error('Ollama is not running. Start it with: ollama serve (or open the Ollama app).');
598
+ }
599
+ throw e;
564
600
  }
565
601
  if (!res.ok) {
566
- const err = didRetry ? await res.text() : errText;
567
- const modelName = model || OLLAMA_DEFAULT;
568
- if (res.status === 404 && (err.includes('not found') || err.includes('model'))) {
569
- const { pullOllamaModel } = await import('../ollama-setup.js');
570
- pullOllamaModel(modelName);
571
- throw new Error(`Model "${modelName}" not found. Pulling it now (see terminal); try again in 1–2 min. Or run: ollama pull ${modelName}`);
602
+ const errText = await res.text();
603
+ let didRetry = false;
604
+ if (res.status === 400 && errText.includes('does not support tools') && body.tools) {
605
+ delete body.tools;
606
+ console.log('[Ollama] Model does not support tools; retrying without tools');
607
+ res = await fetch(url, {
608
+ method: 'POST',
609
+ headers: { 'Content-Type': 'application/json' },
610
+ body: JSON.stringify(body),
611
+ });
612
+ didRetry = true;
613
+ }
614
+ if (!res.ok) {
615
+ const err = didRetry ? await res.text() : errText;
616
+ const modelName = model || OLLAMA_DEFAULT;
617
+ if (res.status === 404 && (err.includes('not found') || err.includes('model'))) {
618
+ const { pullOllamaModel } = await import('../ollama-setup.js');
619
+ pullOllamaModel(modelName);
620
+ throw new Error(`Model "${modelName}" not found. Pulling it now (see terminal); try again in 1–2 min. Or run: ollama pull ${modelName}`);
621
+ }
622
+ console.error('[Ollama]', res.status, err);
623
+ throw new Error(`Ollama: ${res.status} ${err}`);
572
624
  }
573
- console.error('[Ollama]', res.status, err);
574
- throw new Error(`Ollama: ${res.status} ${err}`);
575
625
  }
576
- }
577
- const data = (await res.json());
578
- const msg = data.message;
579
- const thinking = msg?.thinking ?? '';
580
- const content = msg?.content ?? '';
581
- const fullContent = thinking ? thinking + (content ? '\n\n' + content : '') : content;
582
- const tool_calls = fromOllamaToolCalls(msg?.tool_calls);
583
- return {
584
- content: fullContent,
585
- usage: data.eval_count != null ? { completion_tokens: data.eval_count } : undefined,
586
- ...(tool_calls?.length ? { tool_calls } : {}),
587
- };
588
- },
589
- });
590
- if (!providers.has('llama'))
591
- registerProvider('llama', getProvider('ollama'));
626
+ const data = (await res.json());
627
+ const msg = data.message;
628
+ const thinking = msg?.thinking ?? '';
629
+ const content = msg?.content ?? '';
630
+ const fullContent = thinking ? thinking + (content ? '\n\n' + content : '') : content;
631
+ const tool_calls = fromOllamaToolCalls(msg?.tool_calls);
632
+ return {
633
+ content: fullContent,
634
+ usage: data.eval_count != null ? { completion_tokens: data.eval_count } : undefined,
635
+ ...(tool_calls?.length ? { tool_calls } : {}),
636
+ };
637
+ },
638
+ });
639
+ if (!providers.has('llama'))
640
+ registerProvider('llama', getProvider('ollama'));
641
+ }
642
+ await registerAllProviders();
643
+ /** Reload AI providers from current process.env (e.g. after onboarding saves new API keys). No restart needed. */
644
+ export async function reloadProviders() {
645
+ await registerAllProviders();
646
+ }
592
647
  //# sourceMappingURL=orchestrator.js.map