acdev 1.0.10 → 1.0.12

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/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
- import { usageFromLogs, withJobUsage } from './usage.js';
40
+ import { usageFromLogs, withJobUsage, snapshotJobLlm, tagUsageProvider } from './usage.js';
41
41
  import { checkGhAuth } from './gh-auth.js';
42
42
  import { checkClaudeAuth } from './claude-auth.js';
43
- import { isValidModelId, isNoModel, NO_MODEL } from './models.js';
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 claude = doCheckClaudeAuth();
237
- if (!claude.ok) {
237
+ const provider = normalizeLlmProvider(config.llmProvider);
238
+ if (!isModelIdForProvider(model, provider)) {
238
239
  return {
239
240
  status: 400,
240
241
  error:
241
- 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, run claude auth login, or start with --stub-agent.',
242
- code: 'claude_auth_required',
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;
@@ -279,6 +304,25 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
279
304
  return store.updateJob(job.id, { logs });
280
305
  }
281
306
 
307
+ /**
308
+ * Persist current LLM provider/model on the job and emit a log line.
309
+ * @param {string} jobId
310
+ */
311
+ function stampJobLlm(jobId) {
312
+ const snap = snapshotJobLlm(config);
313
+ let job = store.getJob(jobId);
314
+ if (!job) return undefined;
315
+ job = store.updateJob(jobId, snap);
316
+ job = appendLog(job, 'llm', {
317
+ provider: snap.llmProvider,
318
+ model: snap.model,
319
+ });
320
+ if (job?.logs?.length) {
321
+ emitEvent(jobId, job.logs[job.logs.length - 1]);
322
+ }
323
+ return job;
324
+ }
325
+
282
326
  function setStatus(jobId, status, extra = {}) {
283
327
  if (!store.getJob(jobId)) return undefined;
284
328
  const job = store.updateJob(jobId, { status, ...extra });
@@ -305,6 +349,9 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
305
349
  return;
306
350
  }
307
351
 
352
+ job = stampJobLlm(jobId);
353
+ if (!job) return;
354
+
308
355
  try {
309
356
  job = setStatus(jobId, 'syncing');
310
357
  if (!job) return;
@@ -348,29 +395,6 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
348
395
  issueType,
349
396
  issueTitle
350
397
  );
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
398
  const worktreeId = worktreeIdForJob(job);
375
399
  if (worktreeId == null) {
376
400
  throw new Error('Job is missing issueNumber / jiraKey for worktree path');
@@ -430,7 +454,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
430
454
  prTitle: stripAiAttribution(prTitle),
431
455
  prBody: stripAiAttribution(prBody),
432
456
  };
433
- if (usage) patch.usage = usage;
457
+ if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
434
458
  setStatus(jobId, 'awaiting_review', patch);
435
459
  } catch (err) {
436
460
  const message = formatAgentJobError(err);
@@ -443,7 +467,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
443
467
  status: 'failed',
444
468
  error: message,
445
469
  };
446
- if (usage) failPatch.usage = usage;
470
+ if (usage) failPatch.usage = tagUsageProvider(usage, config.llmProvider);
447
471
  store.updateJob(jobId, failPatch);
448
472
  const updated = store.getJob(jobId);
449
473
  if (updated?.logs?.length) {
@@ -490,6 +514,9 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
490
514
  return;
491
515
  }
492
516
 
517
+ job = stampJobLlm(jobId);
518
+ if (!job) return;
519
+
493
520
  try {
494
521
  const onEvent = (message) => {
495
522
  const current = store.getJob(jobId);
@@ -531,7 +558,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
531
558
  prBody: stripAiAttribution(prBody),
532
559
  pendingReviewFeedback: undefined,
533
560
  };
534
- if (usage) patch.usage = usage;
561
+ if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
535
562
  setStatus(jobId, 'awaiting_review', patch);
536
563
  } catch (err) {
537
564
  const message = formatAgentJobError(err);
@@ -545,7 +572,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
545
572
  error: message,
546
573
  pendingReviewFeedback: undefined,
547
574
  };
548
- if (usage) failPatch.usage = usage;
575
+ if (usage) failPatch.usage = tagUsageProvider(usage, config.llmProvider);
549
576
  store.updateJob(jobId, failPatch);
550
577
  const updated = store.getJob(jobId);
551
578
  if (updated?.logs?.length) {
@@ -599,7 +626,18 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
599
626
  req.query.refresh === '1' ||
600
627
  req.query.refresh === 'true' ||
601
628
  req.query.force === '1';
602
- const result = await listModels({ selected: config.model, force });
629
+ const provider = normalizeLlmProvider(
630
+ typeof req.query.provider === 'string' ? req.query.provider : config.llmProvider
631
+ );
632
+ const selected =
633
+ provider === normalizeLlmProvider(config.llmProvider)
634
+ ? config.model
635
+ : config.lastModelsByProvider?.[provider];
636
+ const result = await listModels({
637
+ selected,
638
+ force,
639
+ provider,
640
+ });
603
641
  res.json(result);
604
642
  } catch (err) {
605
643
  res.status(500).json({ error: err.message });
@@ -623,6 +661,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
623
661
  applySecretField(envPatch, 'GH_TOKEN', patch.ghToken);
624
662
  applySecretField(envPatch, 'ANTHROPIC_API_KEY', patch.anthropicApiKey);
625
663
  applySecretField(envPatch, 'CLAUDE_CODE_OAUTH_TOKEN', patch.claudeOauthToken);
664
+ applySecretField(envPatch, 'OPENROUTER_API_KEY', patch.openrouterApiKey);
626
665
  if (patch.jiraBaseUrl !== undefined && typeof patch.jiraBaseUrl === 'string') {
627
666
  // Also mirror base URL into env for convenience when set via Settings
628
667
  const trimmed = patch.jiraBaseUrl.trim();
@@ -640,6 +679,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
640
679
  ghToken: _gh,
641
680
  anthropicApiKey: _ak,
642
681
  claudeOauthToken: _oa,
682
+ openrouterApiKey: _or,
643
683
  ...configPatch
644
684
  } = patch;
645
685
  updateConfig(repoRoot, config, configPatch);
@@ -691,29 +731,6 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
691
731
  return res.status(400).json({ error: preferredParsed.error });
692
732
  }
693
733
  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
734
 
718
735
  const ticketSource =
719
736
  req.body?.ticketSource === 'jira' || req.body?.ticketSource === 'github'
@@ -804,12 +821,15 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
804
821
  skipped.push(item.jiraKey || item.url);
805
822
  continue;
806
823
  }
824
+ const llm = snapshotJobLlm(config);
807
825
  const job = store.addJob({
808
826
  issueUrl: item.url,
809
827
  issueNumber: item.number,
810
828
  ticketSource: item.ticketSource,
811
829
  jiraKey: item.jiraKey,
812
830
  ...(preferredBranchName ? { preferredBranchName } : {}),
831
+ llmProvider: llm.llmProvider,
832
+ model: llm.model,
813
833
  });
814
834
  created.push(job);
815
835
  }
@@ -1143,6 +1163,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
1143
1163
  usage: undefined,
1144
1164
  pendingReviewFeedback: undefined,
1145
1165
  latestReviewComments: undefined,
1166
+ ...snapshotJobLlm(config),
1146
1167
  });
1147
1168
  updated = appendLog(updated, 'status', 'retry queued');
1148
1169
  res.json(updated);
package/src/store.js CHANGED
@@ -53,21 +53,27 @@ export class Store {
53
53
  * @param {{
54
54
  * issueUrl: string,
55
55
  * issueNumber?: number,
56
- * ticketSource?: 'github' | 'jira',
57
- * jiraKey?: string,
58
- * preferredBranchName?: string,
59
- * }} data
60
- * @returns {Job}
61
- */
56
+ * ticketSource?: 'github' | 'jira',
57
+ * jiraKey?: string,
58
+ * preferredBranchName?: string,
59
+ * llmProvider?: 'claude' | 'openrouter',
60
+ * model?: string,
61
+ * }} data
62
+ * @returns {Job}
63
+ */
62
64
  addJob(data) {
63
65
  const now = new Date().toISOString();
64
66
  const ticketSource = data.ticketSource === 'jira' ? 'jira' : 'github';
67
+ const llmProvider = data.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
68
+ const model = typeof data.model === 'string' ? data.model.trim() : '';
65
69
  /** @type {Job} */
66
70
  const job = {
67
71
  id: randomUUID(),
68
72
  issueUrl: data.issueUrl,
69
73
  issueNumber: data.issueNumber,
70
74
  ticketSource,
75
+ llmProvider,
76
+ ...(model ? { model } : {}),
71
77
  ...(data.jiraKey ? { jiraKey: data.jiraKey } : {}),
72
78
  ...(data.preferredBranchName ? { preferredBranchName: data.preferredBranchName } : {}),
73
79
  status: 'queued',
package/src/usage.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * @property {number} [cacheCreationInputTokens]
12
12
  * @property {number} [numTurns]
13
13
  * @property {number} [durationMs]
14
+ * @property {'claude' | 'openrouter'} [provider]
14
15
  * @property {Record<string, object>} [modelUsage]
15
16
  */
16
17
 
@@ -23,6 +24,73 @@ function asFiniteNumber(n) {
23
24
  return n;
24
25
  }
25
26
 
27
+ /**
28
+ * @param {object | null | undefined} job
29
+ * @returns {'claude' | 'openrouter'}
30
+ */
31
+ export function jobLlmProvider(job) {
32
+ if (job?.llmProvider === 'openrouter') return 'openrouter';
33
+ if (job?.llmProvider === 'claude') return 'claude';
34
+ if (job?.usage?.provider === 'openrouter') return 'openrouter';
35
+ if (job?.usage?.provider === 'claude') return 'claude';
36
+ const model = String(job?.model || '');
37
+ return model.includes('/') ? 'openrouter' : 'claude';
38
+ }
39
+
40
+ /**
41
+ * Snapshot of the LLM used for a job (enqueue / run start).
42
+ * @param {object | null | undefined} config
43
+ * @returns {{ llmProvider: 'claude' | 'openrouter', model: string }}
44
+ */
45
+ export function snapshotJobLlm(config) {
46
+ const llmProvider = config?.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
47
+ const model = typeof config?.model === 'string' ? config.model.trim() : '';
48
+ return { llmProvider, model: model || '-' };
49
+ }
50
+
51
+ /**
52
+ * @param {JobUsage | null | undefined} usage
53
+ * @param {'claude' | 'openrouter' | string | undefined} provider
54
+ * @returns {JobUsage | null | undefined}
55
+ */
56
+ export function tagUsageProvider(usage, provider) {
57
+ if (!usage || typeof usage !== 'object') return usage;
58
+ const p = provider === 'openrouter' ? 'openrouter' : 'claude';
59
+ return { ...usage, provider: p };
60
+ }
61
+
62
+ /**
63
+ * @typedef {{ totalCostUsd: number, totalTokens: number, withCost: number, withTokens: number }} UsageAgg
64
+ * @param {object[]} jobs
65
+ * @returns {{ claude: UsageAgg, openrouter: UsageAgg }}
66
+ */
67
+ export function aggregateUsageByProvider(jobs) {
68
+ const empty = () => ({
69
+ totalCostUsd: 0,
70
+ totalTokens: 0,
71
+ withCost: 0,
72
+ withTokens: 0,
73
+ });
74
+ /** @type {{ claude: UsageAgg, openrouter: UsageAgg }} */
75
+ const out = { claude: empty(), openrouter: empty() };
76
+ if (!Array.isArray(jobs)) return out;
77
+ for (const job of jobs) {
78
+ const u = job?.usage;
79
+ if (!u) continue;
80
+ const bucket = out[jobLlmProvider(job)];
81
+ if (typeof u.totalCostUsd === 'number' && Number.isFinite(u.totalCostUsd)) {
82
+ bucket.totalCostUsd += u.totalCostUsd;
83
+ bucket.withCost += 1;
84
+ }
85
+ const tok = (u.inputTokens || 0) + (u.outputTokens || 0);
86
+ if (tok > 0) {
87
+ bucket.totalTokens += tok;
88
+ bucket.withTokens += 1;
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
26
94
  /**
27
95
  * Extract usage from an SDK `result` message (success or error subtypes).
28
96
  * @param {object | null | undefined} message
@@ -70,6 +138,41 @@ export function extractUsageFromResult(message) {
70
138
  return out;
71
139
  }
72
140
 
141
+ /**
142
+ * Map OpenRouter Agent SDK getUsage() totals onto JobUsage.
143
+ * @param {object | null | undefined} totals
144
+ * @param {{ durationMs?: number, numTurns?: number }} [extra]
145
+ * @returns {JobUsage | null}
146
+ */
147
+ export function extractUsageFromOpenRouter(totals, extra = {}) {
148
+ if (!totals || typeof totals !== 'object') return null;
149
+ const totalCostUsd = asFiniteNumber(totals.cost);
150
+ const inputTokens = asFiniteNumber(totals.inputTokens);
151
+ const outputTokens = asFiniteNumber(totals.outputTokens);
152
+ const cacheReadInputTokens = asFiniteNumber(totals.cachedTokens);
153
+ const numTurns = asFiniteNumber(extra.numTurns ?? totals.modelCalls);
154
+ const durationMs = asFiniteNumber(extra.durationMs);
155
+
156
+ const hasSignal =
157
+ totalCostUsd !== undefined ||
158
+ inputTokens !== undefined ||
159
+ outputTokens !== undefined ||
160
+ cacheReadInputTokens !== undefined ||
161
+ numTurns !== undefined;
162
+
163
+ if (!hasSignal) return null;
164
+
165
+ /** @type {JobUsage} */
166
+ const out = {};
167
+ if (totalCostUsd !== undefined) out.totalCostUsd = totalCostUsd;
168
+ if (inputTokens !== undefined) out.inputTokens = inputTokens;
169
+ if (outputTokens !== undefined) out.outputTokens = outputTokens;
170
+ if (cacheReadInputTokens !== undefined) out.cacheReadInputTokens = cacheReadInputTokens;
171
+ if (numTurns !== undefined) out.numTurns = numTurns;
172
+ if (durationMs !== undefined) out.durationMs = durationMs;
173
+ return out;
174
+ }
175
+
73
176
  /**
74
177
  * Scan job logs for the last `agent_event` whose payload is a `result` message
75
178
  * that carries usage/cost fields. Used to backfill older jobs.