@ducci/jarvis 1.0.34 → 1.0.36
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,72 @@
|
|
|
1
|
+
# Finding 018: Anthropic OAuth Token Not Supported
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-03-06
|
|
4
|
+
**Severity:** High — every request fails with 401; server completely non-functional with OAuth credentials
|
|
5
|
+
**Status:** Fixed
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Observed Session
|
|
10
|
+
|
|
11
|
+
Session `ee5ec010-667d-4964-92c6-d45106f72911`. Provider: Anthropic direct API. Key prefix: `sk-ant-oat01-` (OAuth token generated via `claude setup-token`).
|
|
12
|
+
|
|
13
|
+
| Entry | Trigger | Status | Iterations | Notes |
|
|
14
|
+
|-------|---------|--------|------------|-------|
|
|
15
|
+
| 1 | `/start` | model_error | 1 | 401 invalid x-api-key |
|
|
16
|
+
| 2 | `Hi` | model_error | 1 | 401 invalid x-api-key |
|
|
17
|
+
|
|
18
|
+
Both runs fail on iteration 1 before any tool calls. Zero useful work done.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## Root Cause
|
|
23
|
+
|
|
24
|
+
`createAnthropicClient` in `src/server/provider.js` always instantiated the Anthropic SDK with `{ apiKey }`:
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
const anthropic = new Anthropic({ apiKey });
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The SDK maps `apiKey` to the `x-api-key` request header. The Anthropic REST API rejects OAuth tokens on this header with:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{"type":"authentication_error","message":"invalid x-api-key"}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
OAuth tokens (`sk-ant-oat*`) are generated by `claude setup-token` for use with Claude Pro/Max subscriptions. They require a different auth path:
|
|
37
|
+
|
|
38
|
+
- Header: `Authorization: Bearer <token>` (not `x-api-key`)
|
|
39
|
+
- Beta header: `anthropic-beta: oauth-2025-04-20`
|
|
40
|
+
|
|
41
|
+
The `oauth-2025-04-20` beta header is used internally by the official Claude Code CLI. Without it, the API returns `"OAuth authentication is currently not supported."` even with the correct `Authorization: Bearer` header.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Fix
|
|
46
|
+
|
|
47
|
+
Detect the token type by prefix and instantiate the SDK accordingly:
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
const isOAuthToken = apiKey.startsWith('sk-ant-oat');
|
|
51
|
+
const anthropic = isOAuthToken
|
|
52
|
+
? new Anthropic({ authToken: apiKey, defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' } })
|
|
53
|
+
: new Anthropic({ apiKey });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The Anthropic SDK supports `authToken` natively — it sends `Authorization: Bearer <token>` instead of `x-api-key`. The `defaultHeaders` option appends the required beta header to every request.
|
|
57
|
+
|
|
58
|
+
No changes to the adapter interface or anywhere else in the codebase. Both paths produce identical output shape.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Background
|
|
63
|
+
|
|
64
|
+
Anthropic restricts OAuth tokens to their own products (Claude Code CLI, Claude.ai) via ToS. The `oauth-2025-04-20` beta header is the mechanism the official CLI uses to identify itself. Using it in Jarvis enables the same auth path.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Files Changed
|
|
69
|
+
|
|
70
|
+
| File | Change |
|
|
71
|
+
|------|--------|
|
|
72
|
+
| `src/server/provider.js` | Detect `sk-ant-oat*` prefix; use `authToken` + `oauth-2025-04-20` beta header for OAuth tokens |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ducci/jarvis",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.36",
|
|
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
|
|
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
|
|
81
|
-
let apiKey = existingKey;
|
|
113
|
+
let apiKey;
|
|
82
114
|
|
|
83
|
-
if (
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
156
|
-
const { manualModel } = await inquirer.prompt([
|
|
204
|
+
const { browsedModel } = await inquirer.prompt([
|
|
157
205
|
{
|
|
158
|
-
type: '
|
|
159
|
-
name: '
|
|
160
|
-
message: '
|
|
161
|
-
|
|
206
|
+
type: 'list',
|
|
207
|
+
name: 'browsedModel',
|
|
208
|
+
message: 'Select a Claude model:',
|
|
209
|
+
choices,
|
|
210
|
+
pageSize: 20,
|
|
162
211
|
}
|
|
163
212
|
]);
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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: '
|
|
200
|
-
name: '
|
|
201
|
-
message: '
|
|
202
|
-
|
|
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 =
|
|
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
|
-
|
|
213
|
-
|
|
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;
|
package/src/server/agent.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
|
-
import
|
|
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 =
|
|
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 {
|
package/src/server/config.js
CHANGED
|
@@ -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,152 @@
|
|
|
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 isOAuthToken = apiKey.startsWith('sk-ant-oat');
|
|
116
|
+
const anthropic = isOAuthToken
|
|
117
|
+
? new Anthropic({ authToken: apiKey, defaultHeaders: { 'anthropic-beta': 'oauth-2025-04-20' } })
|
|
118
|
+
: new Anthropic({ apiKey });
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
chat: {
|
|
122
|
+
completions: {
|
|
123
|
+
create: async ({ model, messages, tools }) => {
|
|
124
|
+
const { system, messages: anthropicMessages } = openAIMessagesToAnthropic(messages);
|
|
125
|
+
const anthropicTools = openAIToolsToAnthropic(tools);
|
|
126
|
+
|
|
127
|
+
const params = {
|
|
128
|
+
model,
|
|
129
|
+
max_tokens: 8096,
|
|
130
|
+
messages: anthropicMessages,
|
|
131
|
+
};
|
|
132
|
+
if (system) params.system = system;
|
|
133
|
+
if (anthropicTools.length > 0) params.tools = anthropicTools;
|
|
134
|
+
|
|
135
|
+
const response = await anthropic.messages.create(params);
|
|
136
|
+
return anthropicResponseToOpenAI(response);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function createClient(config) {
|
|
144
|
+
if (config.provider === 'anthropic') {
|
|
145
|
+
return createAnthropicClient(config.apiKey);
|
|
146
|
+
}
|
|
147
|
+
// Default: OpenRouter (OpenAI-compatible)
|
|
148
|
+
return new OpenAI({
|
|
149
|
+
baseURL: 'https://openrouter.ai/api/v1',
|
|
150
|
+
apiKey: config.apiKey,
|
|
151
|
+
});
|
|
152
|
+
}
|