@ducci/jarvis 1.0.34 → 1.0.35

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ducci/jarvis",
3
- "version": "1.0.34",
3
+ "version": "1.0.35",
4
4
  "description": "A fully automated agent system that lives on a server.",
5
5
  "main": "./src/index.js",
6
6
  "type": "module",
@@ -36,6 +36,7 @@
36
36
  },
37
37
  "license": "ISC",
38
38
  "dependencies": {
39
+ "@anthropic-ai/sdk": "^0.39.0",
39
40
  "@grammyjs/runner": "^2.0.3",
40
41
  "chalk": "^5.6.2",
41
42
  "commander": "^14.0.3",
@@ -1,6 +1,7 @@
1
1
  import { Bot } from 'grammy';
2
2
  import { run } from '@grammyjs/runner';
3
3
  import { handleChat } from '../../server/agent.js';
4
+ import { loadSession } from '../../server/sessions.js';
4
5
  import { load, save } from './sessions.js';
5
6
 
6
7
  export async function startTelegramChannel(config) {
@@ -13,8 +14,33 @@ export async function startTelegramChannel(config) {
13
14
 
14
15
  await bot.api.setMyCommands([
15
16
  { command: 'new', description: 'Start a fresh session' },
17
+ { command: 'usage', description: 'Show token usage for the current session' },
16
18
  ]);
17
19
 
20
+ bot.command('usage', async (ctx) => {
21
+ const userId = ctx.from?.id;
22
+ if (!allowedUserIds.includes(userId)) return;
23
+
24
+ const chatId = ctx.chat.id;
25
+ const sessionId = sessions[chatId];
26
+ if (!sessionId) {
27
+ await ctx.reply('No active session. Send a message to start one.');
28
+ return;
29
+ }
30
+
31
+ const session = await loadSession(sessionId);
32
+ const u = session?.metadata?.tokenUsage;
33
+ if (!u || (u.prompt === 0 && u.completion === 0)) {
34
+ await ctx.reply('No token usage recorded for this session yet.');
35
+ return;
36
+ }
37
+
38
+ const total = u.prompt + u.completion;
39
+ await ctx.reply(
40
+ `Token usage for current session:\nIn: ${u.prompt.toLocaleString()}\nOut: ${u.completion.toLocaleString()}\nTotal: ${total.toLocaleString()}`
41
+ );
42
+ });
43
+
18
44
  bot.command('new', async (ctx) => {
19
45
  const userId = ctx.from?.id;
20
46
  if (!allowedUserIds.includes(userId)) return;
@@ -52,17 +52,13 @@ function saveSettings(settings) {
52
52
  fs.writeFileSync(settingsFile, JSON.stringify(settings, null, 2), 'utf8');
53
53
  }
54
54
 
55
- async function fetchModels(apiKey) {
55
+ async function fetchOpenRouterModels(apiKey) {
56
56
  console.log(chalk.blue('Fetching models from OpenRouter...'));
57
57
  try {
58
58
  const response = await fetch('https://openrouter.ai/api/v1/models', {
59
- headers: {
60
- 'Authorization': `Bearer ${apiKey}`
61
- }
59
+ headers: { 'Authorization': `Bearer ${apiKey}` }
62
60
  });
63
- if (!response.ok) {
64
- throw new Error(`HTTP error! status: ${response.status}`);
65
- }
61
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
66
62
  const data = await response.json();
67
63
  return data.data;
68
64
  } catch (error) {
@@ -71,55 +67,114 @@ async function fetchModels(apiKey) {
71
67
  }
72
68
  }
73
69
 
70
+ const ANTHROPIC_MODELS_FALLBACK = [
71
+ { id: 'claude-opus-4-6', description: 'Most capable' },
72
+ { id: 'claude-sonnet-4-6', description: 'Balanced' },
73
+ { id: 'claude-3-5-sonnet-20241022', description: 'Balanced (stable)' },
74
+ { id: 'claude-haiku-4-5-20251001', description: 'Fast & cheap' },
75
+ { id: 'claude-3-5-haiku-20241022', description: 'Fast & cheap (stable)' },
76
+ ];
77
+
78
+ async function fetchAnthropicModels(apiKey) {
79
+ try {
80
+ const response = await fetch('https://api.anthropic.com/v1/models', {
81
+ headers: { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }
82
+ });
83
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
84
+ const data = await response.json();
85
+ return data.data.map(m => ({ id: m.id, description: '' }));
86
+ } catch {
87
+ return ANTHROPIC_MODELS_FALLBACK;
88
+ }
89
+ }
90
+
74
91
  async function run() {
75
92
  ensureDirectories();
76
93
 
77
94
  console.log(chalk.green.bold('\n=== Jarvis Setup ===\n'));
78
95
 
96
+ let settings = loadSettings();
97
+
98
+ // --- PROVIDER STEP ---
99
+ const { provider } = await inquirer.prompt([
100
+ {
101
+ type: 'list',
102
+ name: 'provider',
103
+ message: 'Which AI provider do you want to use?',
104
+ choices: [
105
+ { name: 'OpenRouter (access many models via one key)', value: 'openrouter' },
106
+ { name: 'Anthropic Direct (use your Anthropic API key)', value: 'anthropic' },
107
+ ],
108
+ default: settings.provider || 'openrouter',
109
+ }
110
+ ]);
111
+
79
112
  // --- API KEY STEP ---
80
- let existingKey = loadEnvVar('OPENROUTER_API_KEY');
81
- let apiKey = existingKey;
113
+ let apiKey;
82
114
 
83
- if (existingKey) {
84
- const { keepKey } = await inquirer.prompt([
85
- {
86
- type: 'confirm',
87
- name: 'keepKey',
88
- message: 'An OPENROUTER_API_KEY is already configured. Do you want to keep it?',
89
- default: true
90
- }
91
- ]);
115
+ if (provider === 'anthropic') {
116
+ const existingKey = loadEnvVar('ANTHROPIC_API_KEY');
117
+ apiKey = existingKey;
118
+
119
+ if (existingKey) {
120
+ const { keepKey } = await inquirer.prompt([
121
+ {
122
+ type: 'confirm',
123
+ name: 'keepKey',
124
+ message: 'An ANTHROPIC_API_KEY is already configured. Do you want to keep it?',
125
+ default: true,
126
+ }
127
+ ]);
128
+ if (!keepKey) apiKey = null;
129
+ }
130
+
131
+ if (!apiKey) {
132
+ const { newKey } = await inquirer.prompt([
133
+ {
134
+ type: 'password',
135
+ name: 'newKey',
136
+ message: 'Enter your Anthropic API key:',
137
+ validate: (input) => input.length >= 10 || 'API key must be at least 10 characters long.',
138
+ }
139
+ ]);
140
+ apiKey = newKey;
141
+ saveEnvVar('ANTHROPIC_API_KEY', apiKey);
142
+ console.log(chalk.green('Anthropic API key saved.'));
143
+ }
144
+ } else {
145
+ const existingKey = loadEnvVar('OPENROUTER_API_KEY');
146
+ apiKey = existingKey;
147
+
148
+ if (existingKey) {
149
+ const { keepKey } = await inquirer.prompt([
150
+ {
151
+ type: 'confirm',
152
+ name: 'keepKey',
153
+ message: 'An OPENROUTER_API_KEY is already configured. Do you want to keep it?',
154
+ default: true,
155
+ }
156
+ ]);
157
+ if (!keepKey) apiKey = null;
158
+ }
92
159
 
93
- if (!keepKey) {
160
+ if (!apiKey) {
94
161
  const { newKey } = await inquirer.prompt([
95
162
  {
96
163
  type: 'password',
97
164
  name: 'newKey',
98
165
  message: 'Enter your OpenRouter API key:',
99
- validate: (input) => input.length >= 10 || 'API key must be at least 10 characters long.'
166
+ validate: (input) => input.length >= 10 || 'API key must be at least 10 characters long.',
100
167
  }
101
168
  ]);
102
169
  apiKey = newKey;
103
170
  saveEnvVar('OPENROUTER_API_KEY', apiKey);
104
- console.log(chalk.green('API key updated.'));
171
+ console.log(chalk.green('OpenRouter API key saved.'));
105
172
  }
106
- } else {
107
- const { newKey } = await inquirer.prompt([
108
- {
109
- type: 'password',
110
- name: 'newKey',
111
- message: 'Enter your OpenRouter API key:',
112
- validate: (input) => input.length >= 10 || 'API key must be at least 10 characters long.'
113
- }
114
- ]);
115
- apiKey = newKey;
116
- saveEnvVar('OPENROUTER_API_KEY', apiKey);
117
- console.log(chalk.green('API key saved.'));
118
173
  }
119
174
 
120
175
  // --- MODEL SELECTION STEP ---
121
- let settings = loadSettings();
122
- let selectedModel = settings.selectedModel;
176
+ // Reset model selection when switching providers
177
+ let selectedModel = settings.provider === provider ? settings.selectedModel : null;
123
178
 
124
179
  if (selectedModel) {
125
180
  const { keepModel } = await inquirer.prompt([
@@ -129,88 +184,115 @@ async function run() {
129
184
  message: `Current model is ${chalk.yellow(selectedModel)}. Do you want to keep it or change it?`,
130
185
  choices: [
131
186
  { name: 'Keep current model', value: true },
132
- { name: 'Change model', value: false }
187
+ { name: 'Change model', value: false },
133
188
  ]
134
189
  }
135
190
  ]);
136
-
137
- if (!keepModel) {
138
- selectedModel = null;
139
- }
191
+ if (!keepModel) selectedModel = null;
140
192
  }
141
193
 
142
194
  if (!selectedModel) {
143
- const { modelSelectionMethod } = await inquirer.prompt([
144
- {
145
- type: 'list',
146
- name: 'modelSelectionMethod',
147
- message: 'How would you like to select a model?',
148
- choices: [
149
- { name: 'Browse OpenRouter models', value: 'browse' },
150
- { name: 'Enter model ID manually', value: 'manual' }
151
- ]
152
- }
153
- ]);
195
+ if (provider === 'anthropic') {
196
+ console.log(chalk.blue('Fetching available Claude models...'));
197
+ const models = await fetchAnthropicModels(apiKey);
198
+ const choices = models.map(m => ({
199
+ name: m.description ? `${m.id} ${chalk.dim(m.description)}` : m.id,
200
+ value: m.id,
201
+ }));
202
+ choices.push({ name: 'Enter model ID manually', value: '__manual__' });
154
203
 
155
- if (modelSelectionMethod === 'manual') {
156
- const { manualModel } = await inquirer.prompt([
204
+ const { browsedModel } = await inquirer.prompt([
157
205
  {
158
- type: 'input',
159
- name: 'manualModel',
160
- message: 'Enter OpenRouter model ID (e.g., anthropic/claude-3.5-sonnet):',
161
- validate: (input) => input.trim().length > 0 || 'Model ID cannot be empty.'
206
+ type: 'list',
207
+ name: 'browsedModel',
208
+ message: 'Select a Claude model:',
209
+ choices,
210
+ pageSize: 20,
162
211
  }
163
212
  ]);
164
- selectedModel = manualModel.trim();
165
- } else {
166
- const models = await fetchModels(apiKey);
167
- if (models.length === 0) {
168
- console.log(chalk.yellow('Falling back to manual entry due to fetch failure.'));
213
+
214
+ if (browsedModel === '__manual__') {
169
215
  const { manualModel } = await inquirer.prompt([
170
216
  {
171
217
  type: 'input',
172
218
  name: 'manualModel',
173
- message: 'Enter OpenRouter model ID:',
174
- validate: (input) => input.trim().length > 0 || 'Model ID cannot be empty.'
219
+ message: 'Enter Anthropic model ID (e.g., claude-sonnet-4-6):',
220
+ validate: (input) => input.trim().length > 0 || 'Model ID cannot be empty.',
175
221
  }
176
222
  ]);
177
223
  selectedModel = manualModel.trim();
178
224
  } else {
179
- // Sort models: free first, then alphabetical
180
- models.sort((a, b) => {
181
- const isFreeA = a.pricing && parseFloat(a.pricing.prompt) === 0 && parseFloat(a.pricing.completion) === 0;
182
- const isFreeB = b.pricing && parseFloat(b.pricing.prompt) === 0 && parseFloat(b.pricing.completion) === 0;
183
-
184
- if (isFreeA && !isFreeB) return -1;
185
- if (!isFreeA && isFreeB) return 1;
186
- return a.id.localeCompare(b.id);
187
- });
188
-
189
- const choices = models.map(m => {
190
- const isFree = m.pricing && parseFloat(m.pricing.prompt) === 0 && parseFloat(m.pricing.completion) === 0;
191
- return {
192
- name: `${m.id} ${isFree ? chalk.green('(Free)') : ''}`,
193
- value: m.id
194
- };
195
- });
196
-
197
- const { browsedModel } = await inquirer.prompt([
225
+ selectedModel = browsedModel;
226
+ }
227
+ } else {
228
+ const { modelSelectionMethod } = await inquirer.prompt([
229
+ {
230
+ type: 'list',
231
+ name: 'modelSelectionMethod',
232
+ message: 'How would you like to select a model?',
233
+ choices: [
234
+ { name: 'Browse OpenRouter models', value: 'browse' },
235
+ { name: 'Enter model ID manually', value: 'manual' },
236
+ ]
237
+ }
238
+ ]);
239
+
240
+ if (modelSelectionMethod === 'manual') {
241
+ const { manualModel } = await inquirer.prompt([
198
242
  {
199
- type: 'list',
200
- name: 'browsedModel',
201
- message: 'Select a model:',
202
- choices: choices,
203
- pageSize: 20
243
+ type: 'input',
244
+ name: 'manualModel',
245
+ message: 'Enter OpenRouter model ID (e.g., anthropic/claude-3.5-sonnet):',
246
+ validate: (input) => input.trim().length > 0 || 'Model ID cannot be empty.',
204
247
  }
205
248
  ]);
206
- selectedModel = browsedModel;
249
+ selectedModel = manualModel.trim();
250
+ } else {
251
+ const models = await fetchOpenRouterModels(apiKey);
252
+ if (models.length === 0) {
253
+ console.log(chalk.yellow('Falling back to manual entry due to fetch failure.'));
254
+ const { manualModel } = await inquirer.prompt([
255
+ {
256
+ type: 'input',
257
+ name: 'manualModel',
258
+ message: 'Enter OpenRouter model ID:',
259
+ validate: (input) => input.trim().length > 0 || 'Model ID cannot be empty.',
260
+ }
261
+ ]);
262
+ selectedModel = manualModel.trim();
263
+ } else {
264
+ models.sort((a, b) => {
265
+ const isFreeA = a.pricing && parseFloat(a.pricing.prompt) === 0 && parseFloat(a.pricing.completion) === 0;
266
+ const isFreeB = b.pricing && parseFloat(b.pricing.prompt) === 0 && parseFloat(b.pricing.completion) === 0;
267
+ if (isFreeA && !isFreeB) return -1;
268
+ if (!isFreeA && isFreeB) return 1;
269
+ return a.id.localeCompare(b.id);
270
+ });
271
+ const choices = models.map(m => {
272
+ const isFree = m.pricing && parseFloat(m.pricing.prompt) === 0 && parseFloat(m.pricing.completion) === 0;
273
+ return { name: `${m.id} ${isFree ? chalk.green('(Free)') : ''}`, value: m.id };
274
+ });
275
+ const { browsedModel } = await inquirer.prompt([
276
+ {
277
+ type: 'list',
278
+ name: 'browsedModel',
279
+ message: 'Select a model:',
280
+ choices,
281
+ pageSize: 20,
282
+ }
283
+ ]);
284
+ selectedModel = browsedModel;
285
+ }
207
286
  }
208
287
  }
209
288
  }
210
289
 
290
+ const previousProvider = settings.provider || 'openrouter';
291
+ settings.provider = provider;
211
292
  settings.selectedModel = selectedModel;
212
- if (!settings.fallbackModel) {
213
- settings.fallbackModel = 'openrouter/free';
293
+ // Reset fallback to provider-appropriate default when switching providers or on first run
294
+ if (!settings.fallbackModel || previousProvider !== provider) {
295
+ settings.fallbackModel = provider === 'anthropic' ? 'claude-haiku-4-5-20251001' : 'openrouter/free';
214
296
  }
215
297
  if (settings.maxIterations === undefined) {
216
298
  settings.maxIterations = 10;
@@ -1,5 +1,5 @@
1
1
  import crypto from 'crypto';
2
- import OpenAI from 'openai';
2
+ import { createClient } from './provider.js';
3
3
  import { loadSystemPrompt, resolveSystemPrompt } from './config.js';
4
4
  import { loadSession, saveSession, createSession } from './sessions.js';
5
5
  import { loadTools, getToolDefinitions, executeTool } from './tools.js';
@@ -32,6 +32,13 @@ The checkpoint field will be used to automatically resume the task in the next r
32
32
  // queued request finishes).
33
33
  const sessionQueues = new Map();
34
34
 
35
+ function accumulateUsage(accum, result) {
36
+ const u = result?.usage;
37
+ if (!u) return;
38
+ accum.prompt += u.prompt_tokens || 0;
39
+ accum.completion += u.completion_tokens || 0;
40
+ }
41
+
35
42
  async function callModel(client, model, messages, tools) {
36
43
  const params = { model, messages };
37
44
  if (tools && tools.length > 0) {
@@ -93,7 +100,7 @@ function hasConsecutiveModelErrors(messages) {
93
100
  * Runs a single agent loop up to maxIterations.
94
101
  * Returns { iteration, response, logSummary, status, runToolCalls, checkpoint }.
95
102
  */
96
- async function runAgentLoop(client, config, session, prepareMessages) {
103
+ async function runAgentLoop(client, config, session, prepareMessages, usageAccum) {
97
104
  let tools = await loadTools();
98
105
  let toolDefs = getToolDefinitions(tools);
99
106
  let iteration = 0;
@@ -117,6 +124,7 @@ async function runAgentLoop(client, config, session, prepareMessages) {
117
124
  : base;
118
125
  try {
119
126
  modelResult = await callModelWithFallback(client, config, preparedMessages, toolDefs);
127
+ accumulateUsage(usageAccum, modelResult);
120
128
  } catch (e) {
121
129
  return {
122
130
  iteration,
@@ -280,6 +288,7 @@ async function runAgentLoop(client, config, session, prepareMessages) {
280
288
  { role: 'user', content: 'You returned an empty response. ' + FORMAT_NUDGE },
281
289
  ];
282
290
  const nudgeResult = await callModelWithFallback(client, config, emptyNudge, []);
291
+ accumulateUsage(usageAccum, nudgeResult);
283
292
  const nudgeContent = nudgeResult.choices[0]?.message?.content || '';
284
293
  // Persist nudge text before parsing — if JSON parse throws, content still
285
294
  // carries the model's best-effort text so the !parsed handler can show it
@@ -298,6 +307,7 @@ async function runAgentLoop(client, config, session, prepareMessages) {
298
307
  // Step 1: retry with fallback model
299
308
  try {
300
309
  const fallbackResult = await callModel(client, config.fallbackModel, preparedMessages, toolDefs);
310
+ accumulateUsage(usageAccum, fallbackResult);
301
311
  const fallbackContent = fallbackResult.choices[0]?.message?.content || '';
302
312
  parsed = JSON.parse(fallbackContent);
303
313
  content = fallbackContent;
@@ -306,6 +316,7 @@ async function runAgentLoop(client, config, session, prepareMessages) {
306
316
  try {
307
317
  const nudgeMessages = [...preparedMessages, { role: 'user', content: FORMAT_NUDGE }];
308
318
  const nudgeResult = await callModelWithFallback(client, config, nudgeMessages, toolDefs);
319
+ accumulateUsage(usageAccum, nudgeResult);
309
320
  const nudgeContent = nudgeResult.choices[0]?.message?.content || '';
310
321
  parsed = JSON.parse(nudgeContent);
311
322
  content = nudgeContent;
@@ -346,6 +357,7 @@ async function runAgentLoop(client, config, session, prepareMessages) {
346
357
  let wrapUpResult;
347
358
  try {
348
359
  wrapUpResult = await callModelWithFallback(client, config, wrapUpMessages, []);
360
+ accumulateUsage(usageAccum, wrapUpResult);
349
361
  } catch (e) {
350
362
  return {
351
363
  iteration,
@@ -382,6 +394,7 @@ async function runAgentLoop(client, config, session, prepareMessages) {
382
394
  try {
383
395
  const nudgeMessages = [...wrapUpMessages, { role: 'user', content: FORMAT_NUDGE }];
384
396
  const nudgeResult = await callModelWithFallback(client, config, nudgeMessages, []);
397
+ accumulateUsage(usageAccum, nudgeResult);
385
398
  const nudgeContent = nudgeResult.choices[0]?.message?.content || '';
386
399
  parsedWrapUp = JSON.parse(nudgeContent);
387
400
  wrapUpContent = nudgeContent;
@@ -472,10 +485,7 @@ export async function handleChat(config, requestSessionId, userMessage) {
472
485
  * session lock.
473
486
  */
474
487
  async function _runHandleChat(config, sessionId, userMessage) {
475
- const client = new OpenAI({
476
- baseURL: 'https://openrouter.ai/api/v1',
477
- apiKey: config.apiKey,
478
- });
488
+ const client = createClient(config);
479
489
 
480
490
  const systemPromptTemplate = loadSystemPrompt();
481
491
  let session = await loadSession(sessionId);
@@ -526,6 +536,7 @@ async function _runHandleChat(config, sessionId, userMessage) {
526
536
  }
527
537
 
528
538
  const allToolCalls = [];
539
+ const usageAccum = { prompt: 0, completion: 0 };
529
540
  let finalResponse = '';
530
541
  let finalLogSummary = '';
531
542
  let finalStatus = 'ok';
@@ -558,7 +569,7 @@ async function _runHandleChat(config, sessionId, userMessage) {
558
569
  }
559
570
 
560
571
  const runStartIndex = session.messages.length;
561
- const run = await runAgentLoop(client, config, session, prepareMessages);
572
+ const run = await runAgentLoop(client, config, session, prepareMessages, usageAccum);
562
573
  allToolCalls.push(...run.runToolCalls);
563
574
 
564
575
  if (run.status !== 'checkpoint_reached') {
@@ -707,6 +718,11 @@ async function _runHandleChat(config, sessionId, userMessage) {
707
718
  });
708
719
  throw e;
709
720
  } finally {
721
+ // Accumulate token usage into session metadata so /usage can read it
722
+ if (!session.metadata.tokenUsage) session.metadata.tokenUsage = { prompt: 0, completion: 0 };
723
+ session.metadata.tokenUsage.prompt += usageAccum.prompt;
724
+ session.metadata.tokenUsage.completion += usageAccum.completion;
725
+
710
726
  // Always persist the session — even if an unexpected error occurred.
711
727
  // A failed save must not mask the original error.
712
728
  try {
@@ -31,21 +31,27 @@ export function ensureDirectories() {
31
31
  export function loadConfig() {
32
32
  dotenv.config({ path: PATHS.envFile });
33
33
 
34
- const apiKey = process.env.OPENROUTER_API_KEY;
35
- if (!apiKey) {
36
- throw new Error('OPENROUTER_API_KEY not found. Run `jarvis setup` first.');
37
- }
38
-
39
34
  if (!fs.existsSync(PATHS.settingsFile)) {
40
35
  throw new Error('settings.json not found. Run `jarvis setup` first.');
41
36
  }
42
37
 
43
38
  const settings = JSON.parse(fs.readFileSync(PATHS.settingsFile, 'utf8'));
39
+ const provider = settings.provider || 'openrouter';
40
+
41
+ let apiKey;
42
+ if (provider === 'anthropic') {
43
+ apiKey = process.env.ANTHROPIC_API_KEY;
44
+ if (!apiKey) throw new Error('ANTHROPIC_API_KEY not found. Run `jarvis setup` first.');
45
+ } else {
46
+ apiKey = process.env.OPENROUTER_API_KEY;
47
+ if (!apiKey) throw new Error('OPENROUTER_API_KEY not found. Run `jarvis setup` first.');
48
+ }
44
49
 
45
50
  return {
51
+ provider,
46
52
  apiKey,
47
53
  selectedModel: settings.selectedModel,
48
- fallbackModel: settings.fallbackModel || 'openrouter/free',
54
+ fallbackModel: settings.fallbackModel || (provider === 'anthropic' ? 'claude-haiku-4-5-20251001' : 'openrouter/free'),
49
55
  maxIterations: settings.maxIterations || 10,
50
56
  maxHandoffs: settings.maxHandoffs || 5,
51
57
  port: settings.port || 18008,
@@ -0,0 +1,149 @@
1
+ import OpenAI from 'openai';
2
+ import Anthropic from '@anthropic-ai/sdk';
3
+
4
+ // Convert OpenAI tool definitions to Anthropic format
5
+ function openAIToolsToAnthropic(tools) {
6
+ if (!tools || tools.length === 0) return [];
7
+ return tools.map(t => ({
8
+ name: t.function.name,
9
+ description: t.function.description || '',
10
+ input_schema: t.function.parameters || { type: 'object', properties: {}, required: [] },
11
+ }));
12
+ }
13
+
14
+ // Convert OpenAI message history to Anthropic format.
15
+ // Key differences:
16
+ // - system message becomes a separate `system` param, not part of messages
17
+ // - assistant tool_calls → content array with tool_use blocks
18
+ // - role:'tool' messages → content array with tool_result blocks inside a user message
19
+ // - Anthropic requires strict user/assistant alternation; consecutive user messages
20
+ // (e.g. tool results followed by a system note) are merged
21
+ function openAIMessagesToAnthropic(messages) {
22
+ let system;
23
+ let rest = messages;
24
+
25
+ if (messages[0]?.role === 'system') {
26
+ system = messages[0].content;
27
+ rest = messages.slice(1);
28
+ }
29
+
30
+ const result = [];
31
+
32
+ for (let i = 0; i < rest.length; i++) {
33
+ const msg = rest[i];
34
+
35
+ if (msg.role === 'user') {
36
+ const last = result[result.length - 1];
37
+ if (last && last.role === 'user') {
38
+ // Merge into previous user message to maintain strict alternation
39
+ const newPart = { type: 'text', text: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) };
40
+ if (typeof last.content === 'string') {
41
+ last.content = [{ type: 'text', text: last.content }, newPart];
42
+ } else {
43
+ last.content.push(newPart);
44
+ }
45
+ } else {
46
+ result.push({ role: 'user', content: msg.content });
47
+ }
48
+
49
+ } else if (msg.role === 'assistant') {
50
+ const content = [];
51
+ if (msg.content) content.push({ type: 'text', text: msg.content });
52
+ if (msg.tool_calls) {
53
+ for (const tc of msg.tool_calls) {
54
+ let input = {};
55
+ try { input = JSON.parse(tc.function.arguments || '{}'); } catch { /* ignore */ }
56
+ content.push({ type: 'tool_use', id: tc.id, name: tc.function.name, input });
57
+ }
58
+ }
59
+ result.push({ role: 'assistant', content: content.length > 0 ? content : [{ type: 'text', text: '' }] });
60
+
61
+ // Collect following tool-result messages into a single user message
62
+ const toolResults = [];
63
+ while (i + 1 < rest.length && rest[i + 1].role === 'tool') {
64
+ i++;
65
+ toolResults.push({
66
+ type: 'tool_result',
67
+ tool_use_id: rest[i].tool_call_id,
68
+ content: rest[i].content,
69
+ });
70
+ }
71
+ if (toolResults.length > 0) {
72
+ result.push({ role: 'user', content: toolResults });
73
+ }
74
+
75
+ }
76
+ // role:'tool' messages that were not consumed above are skipped (shouldn't happen)
77
+ }
78
+
79
+ return { system, messages: result };
80
+ }
81
+
82
+ // Normalize an Anthropic response to the shape agent.js expects from the OpenAI SDK
83
+ function anthropicResponseToOpenAI(response) {
84
+ const textParts = response.content.filter(c => c.type === 'text');
85
+ const toolParts = response.content.filter(c => c.type === 'tool_use');
86
+
87
+ const text = textParts.map(t => t.text).join('') || null;
88
+ const toolCalls = toolParts.length > 0
89
+ ? toolParts.map(t => ({
90
+ id: t.id,
91
+ type: 'function',
92
+ function: { name: t.name, arguments: JSON.stringify(t.input) },
93
+ }))
94
+ : undefined;
95
+
96
+ return {
97
+ choices: [{
98
+ message: {
99
+ role: 'assistant',
100
+ content: toolCalls ? null : text,
101
+ ...(toolCalls && { tool_calls: toolCalls }),
102
+ },
103
+ finish_reason: response.stop_reason === 'tool_use' ? 'tool_calls' : 'stop',
104
+ }],
105
+ usage: {
106
+ prompt_tokens: response.usage?.input_tokens ?? 0,
107
+ completion_tokens: response.usage?.output_tokens ?? 0,
108
+ total_tokens: (response.usage?.input_tokens ?? 0) + (response.usage?.output_tokens ?? 0),
109
+ },
110
+ };
111
+ }
112
+
113
+ // Build an Anthropic adapter that exposes the same interface as the OpenAI SDK client
114
+ function createAnthropicClient(apiKey) {
115
+ const anthropic = new Anthropic({ apiKey });
116
+
117
+ return {
118
+ chat: {
119
+ completions: {
120
+ create: async ({ model, messages, tools }) => {
121
+ const { system, messages: anthropicMessages } = openAIMessagesToAnthropic(messages);
122
+ const anthropicTools = openAIToolsToAnthropic(tools);
123
+
124
+ const params = {
125
+ model,
126
+ max_tokens: 8096,
127
+ messages: anthropicMessages,
128
+ };
129
+ if (system) params.system = system;
130
+ if (anthropicTools.length > 0) params.tools = anthropicTools;
131
+
132
+ const response = await anthropic.messages.create(params);
133
+ return anthropicResponseToOpenAI(response);
134
+ },
135
+ },
136
+ },
137
+ };
138
+ }
139
+
140
+ export function createClient(config) {
141
+ if (config.provider === 'anthropic') {
142
+ return createAnthropicClient(config.apiKey);
143
+ }
144
+ // Default: OpenRouter (OpenAI-compatible)
145
+ return new OpenAI({
146
+ baseURL: 'https://openrouter.ai/api/v1',
147
+ apiKey: config.apiKey,
148
+ });
149
+ }