acdev 1.0.10 → 1.0.11
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/.acdev/.env.example +3 -0
- package/bin/acdev.js +12 -4
- package/package.json +4 -2
- package/public/app.js +138 -6
- package/public/index.html +29 -2
- package/public/styles.css +15 -1
- package/src/agent.js +68 -21
- package/src/config.js +122 -10
- package/src/models.js +131 -18
- package/src/openrouter-agent.js +142 -0
- package/src/openrouter-auth.js +41 -0
- package/src/openrouter-tools.js +291 -0
- package/src/server.js +45 -53
- package/src/usage.js +35 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFile } from 'node:child_process';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { tool } from '@openrouter/agent';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
const SKIP_DIR_NAMES = new Set([
|
|
11
|
+
'.git',
|
|
12
|
+
'node_modules',
|
|
13
|
+
'.acdev',
|
|
14
|
+
'.acdev-worktrees',
|
|
15
|
+
'.codepilot',
|
|
16
|
+
'.codepilot-worktrees',
|
|
17
|
+
'.agent-mcp',
|
|
18
|
+
'.agent-mcp-worktrees',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convert a glob (with `*` / `**` / `?`) to a RegExp.
|
|
23
|
+
* @param {string} pattern
|
|
24
|
+
*/
|
|
25
|
+
export function globToRegExp(pattern) {
|
|
26
|
+
const src = String(pattern || '').replace(/\\/g, '/');
|
|
27
|
+
let i = 0;
|
|
28
|
+
let out = '^';
|
|
29
|
+
while (i < src.length) {
|
|
30
|
+
if (src[i] === '*' && src[i + 1] === '*') {
|
|
31
|
+
if (src[i + 2] === '/') {
|
|
32
|
+
out += '(?:.*/)?';
|
|
33
|
+
i += 3;
|
|
34
|
+
} else {
|
|
35
|
+
out += '.*';
|
|
36
|
+
i += 2;
|
|
37
|
+
}
|
|
38
|
+
} else if (src[i] === '*') {
|
|
39
|
+
out += '[^/]*';
|
|
40
|
+
i += 1;
|
|
41
|
+
} else if (src[i] === '?') {
|
|
42
|
+
out += '[^/]';
|
|
43
|
+
i += 1;
|
|
44
|
+
} else {
|
|
45
|
+
out += src[i].replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
46
|
+
i += 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return new RegExp(`${out}$`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a user path inside the worktree. Rejects escapes.
|
|
54
|
+
* @param {string} worktreePath
|
|
55
|
+
* @param {string} rel
|
|
56
|
+
*/
|
|
57
|
+
export function resolveInWorktree(worktreePath, rel) {
|
|
58
|
+
const root = path.resolve(worktreePath);
|
|
59
|
+
const target = path.resolve(root, String(rel || '.'));
|
|
60
|
+
if (target !== root && !target.startsWith(root + path.sep)) {
|
|
61
|
+
throw new Error(`Path escapes worktree: ${rel}`);
|
|
62
|
+
}
|
|
63
|
+
return target;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {string} root
|
|
68
|
+
* @param {string} [sub]
|
|
69
|
+
* @returns {string[]} absolute file paths
|
|
70
|
+
*/
|
|
71
|
+
export function listWorktreeFiles(root, sub) {
|
|
72
|
+
const start = sub ? resolveInWorktree(root, sub) : path.resolve(root);
|
|
73
|
+
/** @type {string[]} */
|
|
74
|
+
const files = [];
|
|
75
|
+
const walk = (dir) => {
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
79
|
+
} catch {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const ent of entries) {
|
|
83
|
+
if (SKIP_DIR_NAMES.has(ent.name)) continue;
|
|
84
|
+
const full = path.join(dir, ent.name);
|
|
85
|
+
if (ent.isDirectory()) walk(full);
|
|
86
|
+
else if (ent.isFile() || ent.isSymbolicLink()) files.push(full);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
if (fs.existsSync(start) && fs.statSync(start).isDirectory()) walk(start);
|
|
90
|
+
else if (fs.existsSync(start)) files.push(start);
|
|
91
|
+
return files;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function relToRoot(root, abs) {
|
|
95
|
+
return path.relative(root, abs).replace(/\\/g, '/');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* @param {string} worktreePath
|
|
100
|
+
* @param {string[]} allowedTools
|
|
101
|
+
*/
|
|
102
|
+
export function buildOpenRouterCodingTools(worktreePath, allowedTools) {
|
|
103
|
+
const allowed = new Set(allowedTools || []);
|
|
104
|
+
const root = path.resolve(worktreePath);
|
|
105
|
+
/** @type {ReturnType<typeof tool>[]} */
|
|
106
|
+
const tools = [];
|
|
107
|
+
|
|
108
|
+
if (allowed.has('Read')) {
|
|
109
|
+
tools.push(
|
|
110
|
+
tool({
|
|
111
|
+
name: 'Read',
|
|
112
|
+
description: 'Read a file from the worktree. Optional 1-based offset/limit for line slices.',
|
|
113
|
+
inputSchema: z.object({
|
|
114
|
+
path: z.string().describe('Path relative to the worktree root'),
|
|
115
|
+
offset: z.number().int().positive().optional(),
|
|
116
|
+
limit: z.number().int().positive().optional(),
|
|
117
|
+
}),
|
|
118
|
+
execute: async ({ path: rel, offset, limit }) => {
|
|
119
|
+
const full = resolveInWorktree(root, rel);
|
|
120
|
+
const text = fs.readFileSync(full, 'utf8');
|
|
121
|
+
const lines = text.split('\n');
|
|
122
|
+
const start = offset ? Math.max(0, offset - 1) : 0;
|
|
123
|
+
const slice = limit ? lines.slice(start, start + limit) : lines.slice(start);
|
|
124
|
+
const numbered = slice.map((line, i) => `${String(start + i + 1).padStart(6)}\t${line}`);
|
|
125
|
+
return numbered.join('\n') || '(empty file)';
|
|
126
|
+
},
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (allowed.has('Write')) {
|
|
132
|
+
tools.push(
|
|
133
|
+
tool({
|
|
134
|
+
name: 'Write',
|
|
135
|
+
description: 'Write a file in the worktree, creating parent directories as needed.',
|
|
136
|
+
inputSchema: z.object({
|
|
137
|
+
path: z.string().describe('Path relative to the worktree root'),
|
|
138
|
+
content: z.string().describe('Full file contents'),
|
|
139
|
+
}),
|
|
140
|
+
execute: async ({ path: rel, content }) => {
|
|
141
|
+
const full = resolveInWorktree(root, rel);
|
|
142
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
143
|
+
fs.writeFileSync(full, content, 'utf8');
|
|
144
|
+
return `Wrote ${relToRoot(root, full)} (${Buffer.byteLength(content, 'utf8')} bytes)`;
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (allowed.has('Edit')) {
|
|
151
|
+
tools.push(
|
|
152
|
+
tool({
|
|
153
|
+
name: 'Edit',
|
|
154
|
+
description:
|
|
155
|
+
'Replace exact text in a file. old_string must match uniquely unless replace_all is true.',
|
|
156
|
+
inputSchema: z.object({
|
|
157
|
+
path: z.string(),
|
|
158
|
+
old_string: z.string(),
|
|
159
|
+
new_string: z.string(),
|
|
160
|
+
replace_all: z.boolean().optional(),
|
|
161
|
+
}),
|
|
162
|
+
execute: async ({ path: rel, old_string, new_string, replace_all }) => {
|
|
163
|
+
const full = resolveInWorktree(root, rel);
|
|
164
|
+
const before = fs.readFileSync(full, 'utf8');
|
|
165
|
+
const count = before.split(old_string).length - 1;
|
|
166
|
+
if (count === 0) {
|
|
167
|
+
throw new Error(`old_string not found in ${rel}`);
|
|
168
|
+
}
|
|
169
|
+
if (count > 1 && !replace_all) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`old_string found ${count} times in ${rel}. Pass replace_all true or include more context.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
const after = replace_all
|
|
175
|
+
? before.split(old_string).join(new_string)
|
|
176
|
+
: before.replace(old_string, new_string);
|
|
177
|
+
fs.writeFileSync(full, after, 'utf8');
|
|
178
|
+
return `Edited ${relToRoot(root, full)} (${count} replacement${count === 1 ? '' : 's'})`;
|
|
179
|
+
},
|
|
180
|
+
})
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (allowed.has('Glob')) {
|
|
185
|
+
tools.push(
|
|
186
|
+
tool({
|
|
187
|
+
name: 'Glob',
|
|
188
|
+
description: 'Find files in the worktree matching a glob pattern (e.g. **/*.js).',
|
|
189
|
+
inputSchema: z.object({
|
|
190
|
+
pattern: z.string(),
|
|
191
|
+
path: z.string().optional().describe('Subdirectory to search from'),
|
|
192
|
+
}),
|
|
193
|
+
execute: async ({ pattern, path: sub }) => {
|
|
194
|
+
const re = globToRegExp(pattern);
|
|
195
|
+
const files = listWorktreeFiles(root, sub)
|
|
196
|
+
.map((abs) => relToRoot(root, abs))
|
|
197
|
+
.filter((rel) => re.test(rel) || re.test(rel.split('/').pop() || rel));
|
|
198
|
+
if (files.length === 0) return '(no matches)';
|
|
199
|
+
return files.sort().join('\n');
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (allowed.has('Grep')) {
|
|
206
|
+
tools.push(
|
|
207
|
+
tool({
|
|
208
|
+
name: 'Grep',
|
|
209
|
+
description: 'Search file contents in the worktree with a regular expression.',
|
|
210
|
+
inputSchema: z.object({
|
|
211
|
+
pattern: z.string(),
|
|
212
|
+
path: z.string().optional(),
|
|
213
|
+
glob: z.string().optional(),
|
|
214
|
+
}),
|
|
215
|
+
execute: async ({ pattern, path: sub, glob }) => {
|
|
216
|
+
let re;
|
|
217
|
+
try {
|
|
218
|
+
re = new RegExp(pattern);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
throw new Error(`Invalid regex: ${err instanceof Error ? err.message : String(err)}`);
|
|
221
|
+
}
|
|
222
|
+
const globRe = glob ? globToRegExp(glob) : null;
|
|
223
|
+
/** @type {string[]} */
|
|
224
|
+
const hits = [];
|
|
225
|
+
for (const abs of listWorktreeFiles(root, sub)) {
|
|
226
|
+
const rel = relToRoot(root, abs);
|
|
227
|
+
if (globRe && !globRe.test(rel) && !globRe.test(path.basename(rel))) continue;
|
|
228
|
+
let text;
|
|
229
|
+
try {
|
|
230
|
+
text = fs.readFileSync(abs, 'utf8');
|
|
231
|
+
} catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const lines = text.split('\n');
|
|
235
|
+
for (let i = 0; i < lines.length; i++) {
|
|
236
|
+
if (re.test(lines[i])) {
|
|
237
|
+
hits.push(`${rel}:${i + 1}:${lines[i]}`);
|
|
238
|
+
if (hits.length >= 200) {
|
|
239
|
+
hits.push('… truncated at 200 matches');
|
|
240
|
+
return hits.join('\n');
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return hits.length ? hits.join('\n') : '(no matches)';
|
|
246
|
+
},
|
|
247
|
+
})
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (allowed.has('Bash')) {
|
|
252
|
+
tools.push(
|
|
253
|
+
tool({
|
|
254
|
+
name: 'Bash',
|
|
255
|
+
description: 'Run a shell command in the worktree. Returns stdout and stderr.',
|
|
256
|
+
inputSchema: z.object({
|
|
257
|
+
command: z.string(),
|
|
258
|
+
}),
|
|
259
|
+
execute: async ({ command }) => {
|
|
260
|
+
const cmd = String(command || '').trim();
|
|
261
|
+
if (!cmd) throw new Error('command is required');
|
|
262
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
263
|
+
try {
|
|
264
|
+
const { stdout, stderr } = await execFileAsync(shell, ['-lc', cmd], {
|
|
265
|
+
cwd: root,
|
|
266
|
+
timeout: 120_000,
|
|
267
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
268
|
+
env: { ...process.env },
|
|
269
|
+
});
|
|
270
|
+
const out = [stdout, stderr].filter((s) => String(s || '').trim()).join('\n');
|
|
271
|
+
return out.trim() || '(no output)';
|
|
272
|
+
} catch (err) {
|
|
273
|
+
const e = /** @type {NodeJS.ErrnoException & { stdout?: string, stderr?: string }} */ (
|
|
274
|
+
err
|
|
275
|
+
);
|
|
276
|
+
const bits = [
|
|
277
|
+
e.stderr,
|
|
278
|
+
e.stdout,
|
|
279
|
+
e.message,
|
|
280
|
+
]
|
|
281
|
+
.map((s) => String(s || '').trim())
|
|
282
|
+
.filter(Boolean);
|
|
283
|
+
throw new Error(bits.join('\n') || 'Command failed');
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
})
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return tools;
|
|
291
|
+
}
|
package/src/server.js
CHANGED
|
@@ -33,14 +33,15 @@ import {
|
|
|
33
33
|
runAgentOnReviewFeedback,
|
|
34
34
|
stripAiAttribution,
|
|
35
35
|
} from './agent.js';
|
|
36
|
-
import { publicConfig, updateConfig } from './config.js';
|
|
36
|
+
import { publicConfig, updateConfig, normalizeLlmProvider } from './config.js';
|
|
37
37
|
import { upsertEnvVars } from './env.js';
|
|
38
38
|
import { listModels } from './models.js';
|
|
39
39
|
import { splitIssueUrls } from './urls.js';
|
|
40
40
|
import { usageFromLogs, withJobUsage } from './usage.js';
|
|
41
41
|
import { checkGhAuth } from './gh-auth.js';
|
|
42
42
|
import { checkClaudeAuth } from './claude-auth.js';
|
|
43
|
-
import {
|
|
43
|
+
import { checkOpenRouterAuth } from './openrouter-auth.js';
|
|
44
|
+
import { isValidModelId, isNoModel, NO_MODEL, isModelIdForProvider } from './models.js';
|
|
44
45
|
|
|
45
46
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
46
47
|
|
|
@@ -233,15 +234,39 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
233
234
|
};
|
|
234
235
|
}
|
|
235
236
|
|
|
236
|
-
const
|
|
237
|
-
if (!
|
|
237
|
+
const provider = normalizeLlmProvider(config.llmProvider);
|
|
238
|
+
if (!isModelIdForProvider(model, provider)) {
|
|
238
239
|
return {
|
|
239
240
|
status: 400,
|
|
240
241
|
error:
|
|
241
|
-
|
|
242
|
-
|
|
242
|
+
provider === 'openrouter'
|
|
243
|
+
? 'Invalid OpenRouter model. Choose a model from the OpenRouter catalog in Settings → Configuration.'
|
|
244
|
+
: 'Invalid Claude model. Choose a model in Settings → Configuration.',
|
|
245
|
+
code: 'model_invalid',
|
|
243
246
|
};
|
|
244
247
|
}
|
|
248
|
+
|
|
249
|
+
if (provider === 'openrouter') {
|
|
250
|
+
const orAuth = checkOpenRouterAuth();
|
|
251
|
+
if (!orAuth.ok) {
|
|
252
|
+
return {
|
|
253
|
+
status: 400,
|
|
254
|
+
error:
|
|
255
|
+
'OpenRouter is not authenticated. Add an API key in Settings → Authentication, or start with --stub-agent.',
|
|
256
|
+
code: 'openrouter_auth_required',
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
const claude = doCheckClaudeAuth();
|
|
261
|
+
if (!claude.ok) {
|
|
262
|
+
return {
|
|
263
|
+
status: 400,
|
|
264
|
+
error:
|
|
265
|
+
'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, run claude auth login, or start with --stub-agent.',
|
|
266
|
+
code: 'claude_auth_required',
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
245
270
|
}
|
|
246
271
|
|
|
247
272
|
return null;
|
|
@@ -348,29 +373,6 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
348
373
|
issueType,
|
|
349
374
|
issueTitle
|
|
350
375
|
);
|
|
351
|
-
// #region agent log
|
|
352
|
-
try {
|
|
353
|
-
fs.appendFileSync(
|
|
354
|
-
'/Users/giancarlogarcia/Documents/Personal/Projects-2026/agent-mcp/.cursor/debug-473a78.log',
|
|
355
|
-
`${JSON.stringify({
|
|
356
|
-
sessionId: '473a78',
|
|
357
|
-
runId: 'pre-fix',
|
|
358
|
-
hypothesisId: 'C',
|
|
359
|
-
location: 'src/server.js:resolveDesiredBranchName',
|
|
360
|
-
message: 'resolved worktree branch name',
|
|
361
|
-
data: {
|
|
362
|
-
preferred: job.preferredBranchName ?? null,
|
|
363
|
-
issueType,
|
|
364
|
-
usedCustom: Boolean(job.preferredBranchName),
|
|
365
|
-
desiredBranchName,
|
|
366
|
-
},
|
|
367
|
-
timestamp: Date.now(),
|
|
368
|
-
})}\n`
|
|
369
|
-
);
|
|
370
|
-
} catch {
|
|
371
|
-
// ignore debug log failures
|
|
372
|
-
}
|
|
373
|
-
// #endregion
|
|
374
376
|
const worktreeId = worktreeIdForJob(job);
|
|
375
377
|
if (worktreeId == null) {
|
|
376
378
|
throw new Error('Job is missing issueNumber / jiraKey for worktree path');
|
|
@@ -599,7 +601,18 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
599
601
|
req.query.refresh === '1' ||
|
|
600
602
|
req.query.refresh === 'true' ||
|
|
601
603
|
req.query.force === '1';
|
|
602
|
-
const
|
|
604
|
+
const provider = normalizeLlmProvider(
|
|
605
|
+
typeof req.query.provider === 'string' ? req.query.provider : config.llmProvider
|
|
606
|
+
);
|
|
607
|
+
const selected =
|
|
608
|
+
provider === normalizeLlmProvider(config.llmProvider)
|
|
609
|
+
? config.model
|
|
610
|
+
: config.lastModelsByProvider?.[provider];
|
|
611
|
+
const result = await listModels({
|
|
612
|
+
selected,
|
|
613
|
+
force,
|
|
614
|
+
provider,
|
|
615
|
+
});
|
|
603
616
|
res.json(result);
|
|
604
617
|
} catch (err) {
|
|
605
618
|
res.status(500).json({ error: err.message });
|
|
@@ -623,6 +636,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
623
636
|
applySecretField(envPatch, 'GH_TOKEN', patch.ghToken);
|
|
624
637
|
applySecretField(envPatch, 'ANTHROPIC_API_KEY', patch.anthropicApiKey);
|
|
625
638
|
applySecretField(envPatch, 'CLAUDE_CODE_OAUTH_TOKEN', patch.claudeOauthToken);
|
|
639
|
+
applySecretField(envPatch, 'OPENROUTER_API_KEY', patch.openrouterApiKey);
|
|
626
640
|
if (patch.jiraBaseUrl !== undefined && typeof patch.jiraBaseUrl === 'string') {
|
|
627
641
|
// Also mirror base URL into env for convenience when set via Settings
|
|
628
642
|
const trimmed = patch.jiraBaseUrl.trim();
|
|
@@ -640,6 +654,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
640
654
|
ghToken: _gh,
|
|
641
655
|
anthropicApiKey: _ak,
|
|
642
656
|
claudeOauthToken: _oa,
|
|
657
|
+
openrouterApiKey: _or,
|
|
643
658
|
...configPatch
|
|
644
659
|
} = patch;
|
|
645
660
|
updateConfig(repoRoot, config, configPatch);
|
|
@@ -691,29 +706,6 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
691
706
|
return res.status(400).json({ error: preferredParsed.error });
|
|
692
707
|
}
|
|
693
708
|
const preferredBranchName = preferredParsed.value;
|
|
694
|
-
// #region agent log
|
|
695
|
-
try {
|
|
696
|
-
fs.appendFileSync(
|
|
697
|
-
'/Users/giancarlogarcia/Documents/Personal/Projects-2026/agent-mcp/.cursor/debug-473a78.log',
|
|
698
|
-
`${JSON.stringify({
|
|
699
|
-
sessionId: '473a78',
|
|
700
|
-
runId: 'pre-fix',
|
|
701
|
-
hypothesisId: 'A',
|
|
702
|
-
location: 'src/server.js:POST /api/issues',
|
|
703
|
-
message: 'enqueue preferred branch parse',
|
|
704
|
-
data: {
|
|
705
|
-
rawProvided: req.body?.branchName != null,
|
|
706
|
-
rawType: typeof req.body?.branchName,
|
|
707
|
-
preferredBranchName: preferredBranchName ?? null,
|
|
708
|
-
urlCount: urls.length,
|
|
709
|
-
},
|
|
710
|
-
timestamp: Date.now(),
|
|
711
|
-
})}\n`
|
|
712
|
-
);
|
|
713
|
-
} catch {
|
|
714
|
-
// ignore debug log failures
|
|
715
|
-
}
|
|
716
|
-
// #endregion
|
|
717
709
|
|
|
718
710
|
const ticketSource =
|
|
719
711
|
req.body?.ticketSource === 'jira' || req.body?.ticketSource === 'github'
|
package/src/usage.js
CHANGED
|
@@ -70,6 +70,41 @@ export function extractUsageFromResult(message) {
|
|
|
70
70
|
return out;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Map OpenRouter Agent SDK getUsage() totals onto JobUsage.
|
|
75
|
+
* @param {object | null | undefined} totals
|
|
76
|
+
* @param {{ durationMs?: number, numTurns?: number }} [extra]
|
|
77
|
+
* @returns {JobUsage | null}
|
|
78
|
+
*/
|
|
79
|
+
export function extractUsageFromOpenRouter(totals, extra = {}) {
|
|
80
|
+
if (!totals || typeof totals !== 'object') return null;
|
|
81
|
+
const totalCostUsd = asFiniteNumber(totals.cost);
|
|
82
|
+
const inputTokens = asFiniteNumber(totals.inputTokens);
|
|
83
|
+
const outputTokens = asFiniteNumber(totals.outputTokens);
|
|
84
|
+
const cacheReadInputTokens = asFiniteNumber(totals.cachedTokens);
|
|
85
|
+
const numTurns = asFiniteNumber(extra.numTurns ?? totals.modelCalls);
|
|
86
|
+
const durationMs = asFiniteNumber(extra.durationMs);
|
|
87
|
+
|
|
88
|
+
const hasSignal =
|
|
89
|
+
totalCostUsd !== undefined ||
|
|
90
|
+
inputTokens !== undefined ||
|
|
91
|
+
outputTokens !== undefined ||
|
|
92
|
+
cacheReadInputTokens !== undefined ||
|
|
93
|
+
numTurns !== undefined;
|
|
94
|
+
|
|
95
|
+
if (!hasSignal) return null;
|
|
96
|
+
|
|
97
|
+
/** @type {JobUsage} */
|
|
98
|
+
const out = {};
|
|
99
|
+
if (totalCostUsd !== undefined) out.totalCostUsd = totalCostUsd;
|
|
100
|
+
if (inputTokens !== undefined) out.inputTokens = inputTokens;
|
|
101
|
+
if (outputTokens !== undefined) out.outputTokens = outputTokens;
|
|
102
|
+
if (cacheReadInputTokens !== undefined) out.cacheReadInputTokens = cacheReadInputTokens;
|
|
103
|
+
if (numTurns !== undefined) out.numTurns = numTurns;
|
|
104
|
+
if (durationMs !== undefined) out.durationMs = durationMs;
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
73
108
|
/**
|
|
74
109
|
* Scan job logs for the last `agent_event` whose payload is a `result` message
|
|
75
110
|
* that carries usage/cost fields. Used to backfill older jobs.
|