acdev 1.0.11 → 1.0.13

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": "acdev",
3
- "version": "1.0.11",
3
+ "version": "1.0.13",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -255,6 +255,7 @@ const els = {
255
255
  reviewList: document.getElementById('review-list'),
256
256
  reviewEmptyDetail: document.getElementById('review-empty-detail'),
257
257
  reviewDetailContent: document.getElementById('review-detail-content'),
258
+ reviewAgentMeta: document.getElementById('review-agent-meta'),
258
259
  reviewActions: document.getElementById('review-actions'),
259
260
  reviewFeedbackSection: document.getElementById('review-feedback-section'),
260
261
  reviewGeneralComment: document.getElementById('review-general-comment'),
@@ -343,6 +344,7 @@ const els = {
343
344
  settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
344
345
  settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
345
346
  settingsLlmProvider: document.getElementById('settings-llm-provider'),
347
+ settingsLlmProviderHint: document.getElementById('settings-llm-provider-hint'),
346
348
  overviewLlmProvider: document.getElementById('overview-llm-provider'),
347
349
  sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
348
350
  settingsTabs: document.getElementById('settings-tabs'),
@@ -936,10 +938,37 @@ function jobRepoLine(job) {
936
938
 
937
939
  function jobSubLine(job) {
938
940
  const meta = jobRepoLine(job);
939
- if (meta.isJira) {
940
- return `${meta.number}${meta.branch}`;
941
- }
942
- return `${meta.repo} #${meta.number}${meta.branch}`;
941
+ const agent = jobLlmLine(job);
942
+ const base = meta.isJira
943
+ ? `${meta.number}${meta.branch}`
944
+ : `${meta.repo} #${meta.number}${meta.branch}`;
945
+ return agent ? `${base} · ${agent}` : base;
946
+ }
947
+
948
+ /**
949
+ * @param {object | null | undefined} job
950
+ * @returns {'claude' | 'openrouter'}
951
+ */
952
+ function jobLlmProvider(job) {
953
+ if (job?.llmProvider === 'openrouter') return 'openrouter';
954
+ if (job?.llmProvider === 'claude') return 'claude';
955
+ if (job?.usage?.provider === 'openrouter') return 'openrouter';
956
+ if (job?.usage?.provider === 'claude') return 'claude';
957
+ return String(job?.model || '').includes('/') ? 'openrouter' : 'claude';
958
+ }
959
+
960
+ function jobLlmLabel(job) {
961
+ return jobLlmProvider(job) === 'openrouter' ? 'OpenRouter' : 'Claude';
962
+ }
963
+
964
+ /** `OpenRouter · google/gemini-2.5-pro` */
965
+ function jobLlmLine(job) {
966
+ if (!job) return '';
967
+ const label = jobLlmLabel(job);
968
+ const model = typeof job.model === 'string' ? job.model.trim() : '';
969
+ if (model && model !== '-') return `${label} · ${model}`;
970
+ if (job.llmProvider || job.model || job.usage?.provider) return label;
971
+ return '';
943
972
  }
944
973
 
945
974
  function issueBadge(job) {
@@ -1070,26 +1099,37 @@ function formatUsageDetail(usage) {
1070
1099
  return parts.length ? parts.join(' · ') : null;
1071
1100
  }
1072
1101
 
1073
- /** Sum cost / tokens across jobs that have usage (this app's jobs only). */
1102
+ /** Sum cost / tokens across jobs that have usage, split by LLM provider. */
1074
1103
  function aggregateJobUsage(jobs) {
1075
- let totalCostUsd = 0;
1076
- let totalTokens = 0;
1077
- let withCost = 0;
1078
- let withTokens = 0;
1104
+ const empty = () => ({
1105
+ totalCostUsd: 0,
1106
+ totalTokens: 0,
1107
+ withCost: 0,
1108
+ withTokens: 0,
1109
+ });
1110
+ const out = { claude: empty(), openrouter: empty() };
1079
1111
  for (const job of jobs) {
1080
1112
  const u = job.usage;
1081
1113
  if (!u) continue;
1114
+ const bucket = out[jobLlmProvider(job)];
1082
1115
  if (typeof u.totalCostUsd === 'number' && Number.isFinite(u.totalCostUsd)) {
1083
- totalCostUsd += u.totalCostUsd;
1084
- withCost += 1;
1116
+ bucket.totalCostUsd += u.totalCostUsd;
1117
+ bucket.withCost += 1;
1085
1118
  }
1086
1119
  const tok = (u.inputTokens || 0) + (u.outputTokens || 0);
1087
1120
  if (tok > 0) {
1088
- totalTokens += tok;
1089
- withTokens += 1;
1121
+ bucket.totalTokens += tok;
1122
+ bucket.withTokens += 1;
1090
1123
  }
1091
1124
  }
1092
- return { totalCostUsd, totalTokens, withCost, withTokens };
1125
+ return out;
1126
+ }
1127
+
1128
+ function formatProviderUsageStat(agg) {
1129
+ if (!agg.withCost && !agg.withTokens) return '—';
1130
+ const cost = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
1131
+ const tok = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
1132
+ return `${cost} · ${tok}`;
1093
1133
  }
1094
1134
 
1095
1135
  /** Five progress dots: sync → worktree → agent → review → PR */
@@ -1244,6 +1284,27 @@ function formatLogEvent(event) {
1244
1284
  return { kind: 'status', label: 'Status', text, raw };
1245
1285
  }
1246
1286
 
1287
+ if (event.type === 'llm') {
1288
+ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
1289
+ const provider =
1290
+ payload.provider === 'openrouter' || payload.provider === 'OpenRouter'
1291
+ ? 'OpenRouter'
1292
+ : payload.provider === 'claude' || payload.provider === 'Claude'
1293
+ ? 'Claude'
1294
+ : payload.provider
1295
+ ? String(payload.provider)
1296
+ : 'Agent';
1297
+ const model = typeof payload.model === 'string' && payload.model.trim()
1298
+ ? payload.model.trim()
1299
+ : '—';
1300
+ return {
1301
+ kind: 'status',
1302
+ label: 'Agent',
1303
+ text: `${provider} · ${model}`,
1304
+ raw,
1305
+ };
1306
+ }
1307
+
1247
1308
  if (event.type === 'error') {
1248
1309
  return {
1249
1310
  kind: 'error',
@@ -2039,8 +2100,6 @@ function renderStats(jobs) {
2039
2100
  const review = jobs.filter((j) => j.status === 'awaiting_review').length;
2040
2101
  const alerts = collectAlerts(jobs).length;
2041
2102
  const agg = aggregateJobUsage(jobs);
2042
- const costLabel = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
2043
- const tokLabel = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
2044
2103
 
2045
2104
  const items = [
2046
2105
  { label: 'Queued', value: queued, color: 'var(--text-muted)', dot: false },
@@ -2048,18 +2107,18 @@ function renderStats(jobs) {
2048
2107
  { label: 'Ready for review', value: review, color: 'var(--primary)', dot: false },
2049
2108
  { label: 'Alerts', value: alerts, color: 'var(--accent)', dot: false },
2050
2109
  {
2051
- label: 'Job cost',
2052
- value: costLabel,
2110
+ label: 'Claude',
2111
+ value: formatProviderUsageStat(agg.claude),
2053
2112
  color: 'var(--primary)',
2054
2113
  dot: false,
2055
- hint: 'These jobs only',
2114
+ hint: 'Cost · in+out tok',
2056
2115
  },
2057
2116
  {
2058
- label: 'Tokens',
2059
- value: tokLabel,
2060
- color: 'var(--text-muted)',
2117
+ label: 'OpenRouter',
2118
+ value: formatProviderUsageStat(agg.openrouter),
2119
+ color: 'var(--primary)',
2061
2120
  dot: false,
2062
- hint: 'In + out',
2121
+ hint: 'Cost · in+out tok',
2063
2122
  },
2064
2123
  ];
2065
2124
 
@@ -2255,9 +2314,16 @@ function renderRuns(jobs) {
2255
2314
 
2256
2315
  const usageLine = document.createElement('div');
2257
2316
  usageLine.className = 'run-usage mono';
2317
+ const agentLine = jobLlmLine(job);
2258
2318
  const usageDetail = formatUsageDetail(job.usage);
2259
- usageLine.textContent = usageDetail || '—';
2260
- if (!usageDetail) usageLine.classList.add('muted');
2319
+ if (agentLine && usageDetail) {
2320
+ usageLine.textContent = `${agentLine} · ${usageDetail}`;
2321
+ } else if (agentLine) {
2322
+ usageLine.textContent = agentLine;
2323
+ } else {
2324
+ usageLine.textContent = usageDetail || '—';
2325
+ if (!usageDetail) usageLine.classList.add('muted');
2326
+ }
2261
2327
  card.appendChild(usageLine);
2262
2328
 
2263
2329
  const now = document.createElement('div');
@@ -2461,8 +2527,12 @@ function renderReview(jobs) {
2461
2527
  <div class="review-pick-sub"></div>
2462
2528
  `;
2463
2529
  btn.querySelector('.review-pick-title').textContent = jobTitle(job);
2464
- btn.querySelector('.review-pick-sub').textContent =
2465
- `${jobSubLine(job).replace(/ · .*$/, '')} · ${statusLabel(job.status)}`;
2530
+ const meta = jobRepoLine(job);
2531
+ const left = meta.isJira ? meta.number : `${meta.repo} #${meta.number}`;
2532
+ const agent = jobLlmLine(job);
2533
+ btn.querySelector('.review-pick-sub').textContent = [left, agent, statusLabel(job.status)]
2534
+ .filter(Boolean)
2535
+ .join(' · ');
2466
2536
  btn.addEventListener('click', () => selectReview(job.id));
2467
2537
  els.reviewList.appendChild(btn);
2468
2538
  }
@@ -2477,6 +2547,15 @@ function renderReview(jobs) {
2477
2547
  els.reviewEmptyDetail.classList.add('hidden');
2478
2548
  els.reviewDetailContent.classList.remove('hidden');
2479
2549
 
2550
+ if (els.reviewAgentMeta) {
2551
+ const agent = jobLlmLine(job);
2552
+ const usage = formatUsageDetail(job.usage);
2553
+ const parts = [agent, usage].filter(Boolean);
2554
+ els.reviewAgentMeta.textContent = parts.length
2555
+ ? parts.join(' · ')
2556
+ : 'Agent not recorded for this job.';
2557
+ }
2558
+
2480
2559
  const editable = job.status === 'awaiting_review';
2481
2560
  const isOpened = job.status === 'pr_opened';
2482
2561
  const isTerminal = job.status === 'discarded' || job.status === 'failed';
@@ -2725,6 +2804,8 @@ function jobMatchesReviewSearch(job, q) {
2725
2804
  ref?.full,
2726
2805
  jiraKey,
2727
2806
  job.ticketSource,
2807
+ job.llmProvider,
2808
+ job.model,
2728
2809
  ]
2729
2810
  .filter(Boolean)
2730
2811
  .map((s) => String(s).toLowerCase());
@@ -3893,7 +3974,7 @@ function fillSettingsForm(cfg) {
3893
3974
  fillAuthSettings(cfg);
3894
3975
 
3895
3976
  updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
3896
- updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
3977
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude', cfg);
3897
3978
 
3898
3979
  if (els.settingsJiraBaseUrl) {
3899
3980
  els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
@@ -4120,20 +4201,76 @@ function updateTicketSourceUI(source) {
4120
4201
  }
4121
4202
  }
4122
4203
 
4204
+ /**
4205
+ * @param {'claude' | 'openrouter'} provider
4206
+ * @param {typeof appConfig} [cfg]
4207
+ */
4208
+ function llmProviderReady(provider, cfg = appConfig) {
4209
+ if (cfg?.stubAgent) return true;
4210
+ if (provider === 'openrouter') return cfg?.openrouterAuthOk === true;
4211
+ return cfg?.claudeAuthOk === true;
4212
+ }
4213
+
4123
4214
  /**
4124
4215
  * @param {'claude' | 'openrouter'} provider
4125
4216
  */
4126
- function updateLlmProviderUI(provider) {
4217
+ function llmProviderUnavailableReason(provider) {
4218
+ if (provider === 'openrouter') {
4219
+ return 'OpenRouter is not configured. Add an API key in Settings → Authentication first.';
4220
+ }
4221
+ return 'Claude is not configured. Add an API key or OAuth token in Settings → Authentication, or run claude auth login.';
4222
+ }
4223
+
4224
+ const LLM_PROVIDER_HINT_DEFAULT =
4225
+ 'Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model). A provider stays disabled until it is configured and authenticated.';
4226
+
4227
+ /**
4228
+ * @param {typeof appConfig} [cfg]
4229
+ */
4230
+ function updateLlmProviderHint(cfg = appConfig) {
4231
+ if (!els.settingsLlmProviderHint) return;
4232
+ if (cfg?.stubAgent) {
4233
+ els.settingsLlmProviderHint.innerHTML = LLM_PROVIDER_HINT_DEFAULT;
4234
+ return;
4235
+ }
4236
+ const missing = [];
4237
+ if (!llmProviderReady('claude', cfg)) {
4238
+ missing.push('Claude (API key, OAuth token, or <code>claude auth login</code>)');
4239
+ }
4240
+ if (!llmProviderReady('openrouter', cfg)) {
4241
+ missing.push('OpenRouter (API key in Authentication)');
4242
+ }
4243
+ if (!missing.length) {
4244
+ els.settingsLlmProviderHint.innerHTML = LLM_PROVIDER_HINT_DEFAULT;
4245
+ return;
4246
+ }
4247
+ els.settingsLlmProviderHint.innerHTML = `Cannot enable a provider until it is configured and authenticated. Missing: ${missing.join('; ')}.`;
4248
+ }
4249
+
4250
+ /**
4251
+ * @param {'claude' | 'openrouter'} provider
4252
+ * @param {typeof appConfig} [cfg]
4253
+ */
4254
+ function updateLlmProviderUI(provider, cfg = appConfig) {
4127
4255
  llmProvider = provider === 'openrouter' ? 'openrouter' : 'claude';
4128
4256
 
4129
4257
  for (const toggle of [els.settingsLlmProvider, els.overviewLlmProvider]) {
4130
4258
  if (!toggle) continue;
4131
4259
  toggle.querySelectorAll('.source-btn').forEach((btn) => {
4132
- const active = btn.dataset.provider === llmProvider;
4260
+ const id = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
4261
+ const active = id === llmProvider;
4262
+ const ready = llmProviderReady(id, cfg);
4133
4263
  btn.classList.toggle('active', active);
4134
4264
  btn.setAttribute('aria-pressed', active ? 'true' : 'false');
4265
+ btn.disabled = !ready;
4266
+ if (!ready) {
4267
+ btn.title = llmProviderUnavailableReason(id);
4268
+ } else {
4269
+ btn.removeAttribute('title');
4270
+ }
4135
4271
  });
4136
4272
  }
4273
+ updateLlmProviderHint(cfg);
4137
4274
  updateModelLabel();
4138
4275
  }
4139
4276
 
@@ -4160,6 +4297,13 @@ async function saveTicketSource(next) {
4160
4297
 
4161
4298
  async function saveLlmProvider(next) {
4162
4299
  const provider = next === 'openrouter' ? 'openrouter' : 'claude';
4300
+ if (!llmProviderReady(provider)) {
4301
+ const msg = llmProviderUnavailableReason(provider);
4302
+ setOverviewLlmFeedback(msg, 'error');
4303
+ setSettingsFeedback(msg, 'error');
4304
+ return;
4305
+ }
4306
+ const prev = llmProvider;
4163
4307
  updateLlmProviderUI(provider);
4164
4308
  clearAvailableModels();
4165
4309
  try {
@@ -4169,11 +4313,18 @@ async function saveLlmProvider(next) {
4169
4313
  body: JSON.stringify({ llmProvider: provider }),
4170
4314
  });
4171
4315
  const data = await readJson(res);
4172
- if (res.ok) {
4173
- applyConfigSnapshot({ ...appConfig, ...data });
4316
+ if (!res.ok) {
4317
+ updateLlmProviderUI(prev);
4318
+ const msg = data.error || `Update failed (HTTP ${res.status})`;
4319
+ setOverviewLlmFeedback(msg, 'error');
4320
+ setSettingsFeedback(msg, 'error');
4174
4321
  await fetchModels({ refresh: true, reconcile: true });
4322
+ return;
4175
4323
  }
4324
+ applyConfigSnapshot({ ...appConfig, ...data });
4325
+ await fetchModels({ refresh: true, reconcile: true });
4176
4326
  } catch {
4327
+ updateLlmProviderUI(prev);
4177
4328
  void fetchModels({ refresh: true, reconcile: true });
4178
4329
  }
4179
4330
  }
@@ -4205,7 +4356,7 @@ function setOverviewLlmFeedback(message, kind = 'ok') {
4205
4356
  */
4206
4357
  function fillOverviewLlmControls(cfg) {
4207
4358
  if (!cfg) return;
4208
- updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4359
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude', cfg);
4209
4360
  syncAllModelComboboxValues(currentModel);
4210
4361
  }
4211
4362
 
@@ -4253,7 +4404,10 @@ function applyConfigSnapshot(data) {
4253
4404
  updateModelLabel();
4254
4405
  fillOverviewLlmControls(data);
4255
4406
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
4256
- updateLlmProviderUI(data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4407
+ updateLlmProviderUI(
4408
+ data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude',
4409
+ data || appConfig
4410
+ );
4257
4411
  if (els.repoName) {
4258
4412
  els.repoName.textContent = data?.repoName || 'local repo';
4259
4413
  }
@@ -4650,6 +4804,12 @@ function handleLlmProviderToggleClick(e) {
4650
4804
  const next = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
4651
4805
  if (next === llmProvider) return;
4652
4806
  e.stopPropagation();
4807
+ if (btn.disabled || !llmProviderReady(next)) {
4808
+ const msg = llmProviderUnavailableReason(next);
4809
+ setOverviewLlmFeedback(msg, 'error');
4810
+ setSettingsFeedback(msg, 'error');
4811
+ return;
4812
+ }
4653
4813
  void saveLlmProvider(next);
4654
4814
  }
4655
4815
 
package/public/index.html CHANGED
@@ -286,6 +286,7 @@
286
286
  <div class="review-detail" id="review-detail">
287
287
  <div class="empty-dashed" id="review-empty-detail">Select a job to review</div>
288
288
  <div class="stack hidden" id="review-detail-content">
289
+ <p class="review-agent-meta" id="review-agent-meta"></p>
289
290
  <div class="card" id="review-pr-meta-card">
290
291
  <div class="field-label">PR title</div>
291
292
  <input
@@ -668,7 +669,7 @@
668
669
  <button type="button" class="source-btn active" data-provider="claude">Claude</button>
669
670
  <button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
670
671
  </div>
671
- <p class="field-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model).</p>
672
+ <p class="field-hint" id="settings-llm-provider-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model). A provider stays disabled until it is configured and authenticated.</p>
672
673
  </div>
673
674
  <div class="settings-grid">
674
675
  <div class="settings-field">
package/public/styles.css CHANGED
@@ -1064,6 +1064,13 @@ a { color: var(--primary); text-underline-offset: 3px; }
1064
1064
  color: var(--text-muted);
1065
1065
  }
1066
1066
 
1067
+ .review-agent-meta {
1068
+ margin: 0 0 4px;
1069
+ font-size: 13px;
1070
+ color: var(--text-muted);
1071
+ font-variant-numeric: tabular-nums;
1072
+ }
1073
+
1067
1074
  .btn-remove {
1068
1075
  width: 26px;
1069
1076
  height: 26px;
@@ -2468,7 +2475,7 @@ body.diff-fs-open {
2468
2475
  letter-spacing: 0.02em;
2469
2476
  }
2470
2477
 
2471
- .source-btn:hover:not(.active) {
2478
+ .source-btn:hover:not(.active):not(:disabled) {
2472
2479
  color: var(--text);
2473
2480
  background: color-mix(in srgb, var(--text) 4%, transparent);
2474
2481
  }
@@ -2480,6 +2487,16 @@ body.diff-fs-open {
2480
2487
  box-shadow: var(--shadow-sm);
2481
2488
  }
2482
2489
 
2490
+ .source-btn:disabled {
2491
+ opacity: 0.45;
2492
+ cursor: not-allowed;
2493
+ }
2494
+
2495
+ .source-btn.active:disabled {
2496
+ opacity: 0.7;
2497
+ cursor: default;
2498
+ }
2499
+
2483
2500
  .enqueue-jira-hint {
2484
2501
  margin: 0;
2485
2502
  font-size: 12px;
package/src/config.js CHANGED
@@ -91,6 +91,42 @@ export function normalizeLlmProvider(value) {
91
91
  return value === 'openrouter' ? 'openrouter' : 'claude';
92
92
  }
93
93
 
94
+ /**
95
+ * Block enabling an LLM provider that is not authenticated.
96
+ * Stub-agent skips the check (UI-only testing without credentials).
97
+ * @param {unknown} nextProvider
98
+ * @param {{
99
+ * stubAgent?: boolean,
100
+ * claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
101
+ * openrouterAuth?: import('./openrouter-auth.js').OpenRouterAuthResult,
102
+ * }} [opts]
103
+ * @returns {{ error: string, code: string } | null}
104
+ */
105
+ export function llmProviderAuthGate(nextProvider, opts = {}) {
106
+ if (opts.stubAgent === true) return null;
107
+ const provider = normalizeLlmProvider(nextProvider);
108
+ if (provider === 'openrouter') {
109
+ const or = opts.openrouterAuth ?? checkOpenRouterAuth();
110
+ if (!or.ok) {
111
+ return {
112
+ error:
113
+ 'OpenRouter is not authenticated. Add an API key in Settings → Authentication before enabling OpenRouter.',
114
+ code: 'openrouter_auth_required',
115
+ };
116
+ }
117
+ return null;
118
+ }
119
+ const claude = opts.claudeAuth ?? checkClaudeAuth();
120
+ if (!claude.ok) {
121
+ return {
122
+ error:
123
+ 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, or run claude auth login, before enabling Claude.',
124
+ code: 'claude_auth_required',
125
+ };
126
+ }
127
+ return null;
128
+ }
129
+
94
130
  /**
95
131
  * @param {unknown} raw
96
132
  * @returns {{ claude?: string, openrouter?: string }}
package/src/server.js CHANGED
@@ -33,11 +33,16 @@ import {
33
33
  runAgentOnReviewFeedback,
34
34
  stripAiAttribution,
35
35
  } from './agent.js';
36
- import { publicConfig, updateConfig, normalizeLlmProvider } from './config.js';
36
+ import {
37
+ publicConfig,
38
+ updateConfig,
39
+ normalizeLlmProvider,
40
+ llmProviderAuthGate,
41
+ } from './config.js';
37
42
  import { upsertEnvVars } from './env.js';
38
43
  import { listModels } from './models.js';
39
44
  import { splitIssueUrls } from './urls.js';
40
- import { usageFromLogs, withJobUsage } from './usage.js';
45
+ import { usageFromLogs, withJobUsage, snapshotJobLlm, tagUsageProvider } from './usage.js';
41
46
  import { checkGhAuth } from './gh-auth.js';
42
47
  import { checkClaudeAuth } from './claude-auth.js';
43
48
  import { checkOpenRouterAuth } from './openrouter-auth.js';
@@ -304,6 +309,25 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
304
309
  return store.updateJob(job.id, { logs });
305
310
  }
306
311
 
312
+ /**
313
+ * Persist current LLM provider/model on the job and emit a log line.
314
+ * @param {string} jobId
315
+ */
316
+ function stampJobLlm(jobId) {
317
+ const snap = snapshotJobLlm(config);
318
+ let job = store.getJob(jobId);
319
+ if (!job) return undefined;
320
+ job = store.updateJob(jobId, snap);
321
+ job = appendLog(job, 'llm', {
322
+ provider: snap.llmProvider,
323
+ model: snap.model,
324
+ });
325
+ if (job?.logs?.length) {
326
+ emitEvent(jobId, job.logs[job.logs.length - 1]);
327
+ }
328
+ return job;
329
+ }
330
+
307
331
  function setStatus(jobId, status, extra = {}) {
308
332
  if (!store.getJob(jobId)) return undefined;
309
333
  const job = store.updateJob(jobId, { status, ...extra });
@@ -330,6 +354,9 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
330
354
  return;
331
355
  }
332
356
 
357
+ job = stampJobLlm(jobId);
358
+ if (!job) return;
359
+
333
360
  try {
334
361
  job = setStatus(jobId, 'syncing');
335
362
  if (!job) return;
@@ -432,7 +459,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
432
459
  prTitle: stripAiAttribution(prTitle),
433
460
  prBody: stripAiAttribution(prBody),
434
461
  };
435
- if (usage) patch.usage = usage;
462
+ if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
436
463
  setStatus(jobId, 'awaiting_review', patch);
437
464
  } catch (err) {
438
465
  const message = formatAgentJobError(err);
@@ -445,7 +472,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
445
472
  status: 'failed',
446
473
  error: message,
447
474
  };
448
- if (usage) failPatch.usage = usage;
475
+ if (usage) failPatch.usage = tagUsageProvider(usage, config.llmProvider);
449
476
  store.updateJob(jobId, failPatch);
450
477
  const updated = store.getJob(jobId);
451
478
  if (updated?.logs?.length) {
@@ -492,6 +519,9 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
492
519
  return;
493
520
  }
494
521
 
522
+ job = stampJobLlm(jobId);
523
+ if (!job) return;
524
+
495
525
  try {
496
526
  const onEvent = (message) => {
497
527
  const current = store.getJob(jobId);
@@ -533,7 +563,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
533
563
  prBody: stripAiAttribution(prBody),
534
564
  pendingReviewFeedback: undefined,
535
565
  };
536
- if (usage) patch.usage = usage;
566
+ if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
537
567
  setStatus(jobId, 'awaiting_review', patch);
538
568
  } catch (err) {
539
569
  const message = formatAgentJobError(err);
@@ -547,7 +577,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
547
577
  error: message,
548
578
  pendingReviewFeedback: undefined,
549
579
  };
550
- if (usage) failPatch.usage = usage;
580
+ if (usage) failPatch.usage = tagUsageProvider(usage, config.llmProvider);
551
581
  store.updateJob(jobId, failPatch);
552
582
  const updated = store.getJob(jobId);
553
583
  if (updated?.logs?.length) {
@@ -657,6 +687,21 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
657
687
  openrouterApiKey: _or,
658
688
  ...configPatch
659
689
  } = patch;
690
+
691
+ if (configPatch.llmProvider === 'claude' || configPatch.llmProvider === 'openrouter') {
692
+ const next = configPatch.llmProvider;
693
+ if (next !== normalizeLlmProvider(config.llmProvider)) {
694
+ const gate = llmProviderAuthGate(next, {
695
+ stubAgent: useStubAgent,
696
+ claudeAuth: doCheckClaudeAuth(),
697
+ openrouterAuth: checkOpenRouterAuth(),
698
+ });
699
+ if (gate) {
700
+ return res.status(400).json(gate);
701
+ }
702
+ }
703
+ }
704
+
660
705
  updateConfig(repoRoot, config, configPatch);
661
706
  res.json(publicConfigPayload());
662
707
  } catch (err) {
@@ -796,12 +841,15 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
796
841
  skipped.push(item.jiraKey || item.url);
797
842
  continue;
798
843
  }
844
+ const llm = snapshotJobLlm(config);
799
845
  const job = store.addJob({
800
846
  issueUrl: item.url,
801
847
  issueNumber: item.number,
802
848
  ticketSource: item.ticketSource,
803
849
  jiraKey: item.jiraKey,
804
850
  ...(preferredBranchName ? { preferredBranchName } : {}),
851
+ llmProvider: llm.llmProvider,
852
+ model: llm.model,
805
853
  });
806
854
  created.push(job);
807
855
  }
@@ -1135,6 +1183,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
1135
1183
  usage: undefined,
1136
1184
  pendingReviewFeedback: undefined,
1137
1185
  latestReviewComments: undefined,
1186
+ ...snapshotJobLlm(config),
1138
1187
  });
1139
1188
  updated = appendLog(updated, 'status', 'retry queued');
1140
1189
  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