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.
@@ -13,6 +13,9 @@
13
13
  # An API key takes precedence over subscription login:
14
14
  # ANTHROPIC_API_KEY=sk-ant-your-key-here
15
15
  #
16
+ # OpenRouter (Settings → switch LLM provider to OpenRouter):
17
+ # OPENROUTER_API_KEY=sk-or-your-key-here
18
+ #
16
19
  # Jira Cloud (only when ticketSource is "jira" in Settings / config.json):
17
20
  # JIRA_BASE_URL=https://your-domain.atlassian.net
18
21
  # JIRA_EMAIL=you@company.com
package/bin/acdev.js CHANGED
@@ -4,8 +4,9 @@ import { execSync } from 'node:child_process';
4
4
  import http from 'node:http';
5
5
  import open from 'open';
6
6
  import { checkClaudeAuth, formatClaudeAuthError } from '../src/claude-auth.js';
7
+ import { checkOpenRouterAuth, formatOpenRouterAuthError } from '../src/openrouter-auth.js';
7
8
  import { checkGhAuth, formatGhAuthError } from '../src/gh-auth.js';
8
- import { loadConfig } from '../src/config.js';
9
+ import { loadConfig, normalizeLlmProvider } from '../src/config.js';
9
10
  import { loadEnv } from '../src/env.js';
10
11
  import { migrateLegacyDataDir, migrateLegacyWorktreesDir } from '../src/paths.js';
11
12
  import { Store } from '../src/store.js';
@@ -75,9 +76,16 @@ async function main() {
75
76
  }
76
77
 
77
78
  if (!opts.stubAgent) {
78
- const claudeAuth = checkClaudeAuth();
79
- if (!claudeAuth.ok) {
80
- console.warn(formatClaudeAuthError(claudeAuth));
79
+ if (normalizeLlmProvider(config.llmProvider) === 'openrouter') {
80
+ const openrouterAuth = checkOpenRouterAuth();
81
+ if (!openrouterAuth.ok) {
82
+ console.warn(formatOpenRouterAuthError(openrouterAuth));
83
+ }
84
+ } else {
85
+ const claudeAuth = checkClaudeAuth();
86
+ if (!claudeAuth.ok) {
87
+ console.warn(formatClaudeAuthError(claudeAuth));
88
+ }
81
89
  }
82
90
  } else {
83
91
  console.log('✓ Stub agent enabled (LLM auth not required)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,11 +22,13 @@
22
22
  },
23
23
  "dependencies": {
24
24
  "@anthropic-ai/claude-agent-sdk": "0.1.77",
25
+ "@openrouter/agent": "^0.11.0",
25
26
  "dotenv": "^17.4.2",
26
27
  "express": "^4.21.2",
27
28
  "open": "^10.1.0",
28
29
  "simple-git": "^3.27.0",
29
- "uuid": "^11.1.0"
30
+ "uuid": "^11.1.0",
31
+ "zod": "^4.5.4"
30
32
  },
31
33
  "publishConfig": {
32
34
  "access": "public"
package/public/app.js CHANGED
@@ -160,6 +160,8 @@ let currentModel = 'claude-sonnet-5';
160
160
  let availableModels = [];
161
161
  /** @type {'github' | 'jira'} */
162
162
  let ticketSource = 'github';
163
+ /** @type {'claude' | 'openrouter'} */
164
+ let llmProvider = 'claude';
163
165
  /** @type {{
164
166
  * repoName?: string,
165
167
  * baseBranch?: string,
@@ -170,6 +172,8 @@ let ticketSource = 'github';
170
172
  * knownTools?: string[],
171
173
  * models?: Array<{id:string,label:string}>,
172
174
  * model?: string,
175
+ * llmProvider?: 'claude' | 'openrouter',
176
+ * lastModelsByProvider?: { claude?: string, openrouter?: string },
173
177
  * ticketSource?: 'github' | 'jira',
174
178
  * jiraBaseUrl?: string,
175
179
  * jiraEmail?: string | null,
@@ -190,6 +194,9 @@ let ticketSource = 'github';
190
194
  * anthropicApiKeyMasked?: string | null,
191
195
  * claudeOauthTokenSet?: boolean,
192
196
  * claudeOauthTokenMasked?: string | null,
197
+ * openrouterApiKeySet?: boolean,
198
+ * openrouterApiKeyMasked?: string | null,
199
+ * openrouterAuthOk?: boolean,
193
200
  * llmAuthOk?: boolean,
194
201
  * stubAgent?: boolean,
195
202
  * }} */
@@ -248,6 +255,7 @@ const els = {
248
255
  reviewList: document.getElementById('review-list'),
249
256
  reviewEmptyDetail: document.getElementById('review-empty-detail'),
250
257
  reviewDetailContent: document.getElementById('review-detail-content'),
258
+ reviewAgentMeta: document.getElementById('review-agent-meta'),
251
259
  reviewActions: document.getElementById('review-actions'),
252
260
  reviewFeedbackSection: document.getElementById('review-feedback-section'),
253
261
  reviewGeneralComment: document.getElementById('review-general-comment'),
@@ -331,6 +339,13 @@ const els = {
331
339
  settingsClaudeOauth: document.getElementById('settings-claude-oauth'),
332
340
  settingsClaudeOauthClear: document.getElementById('settings-claude-oauth-clear'),
333
341
  settingsClaudeOauthHint: document.getElementById('settings-claude-oauth-hint'),
342
+ settingsOpenrouterStatus: document.getElementById('settings-openrouter-status'),
343
+ settingsOpenrouterKey: document.getElementById('settings-openrouter-key'),
344
+ settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
345
+ settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
346
+ settingsLlmProvider: document.getElementById('settings-llm-provider'),
347
+ overviewLlmProvider: document.getElementById('overview-llm-provider'),
348
+ sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
334
349
  settingsTabs: document.getElementById('settings-tabs'),
335
350
  };
336
351
 
@@ -922,10 +937,37 @@ function jobRepoLine(job) {
922
937
 
923
938
  function jobSubLine(job) {
924
939
  const meta = jobRepoLine(job);
925
- if (meta.isJira) {
926
- return `${meta.number}${meta.branch}`;
927
- }
928
- return `${meta.repo} #${meta.number}${meta.branch}`;
940
+ const agent = jobLlmLine(job);
941
+ const base = meta.isJira
942
+ ? `${meta.number}${meta.branch}`
943
+ : `${meta.repo} #${meta.number}${meta.branch}`;
944
+ return agent ? `${base} · ${agent}` : base;
945
+ }
946
+
947
+ /**
948
+ * @param {object | null | undefined} job
949
+ * @returns {'claude' | 'openrouter'}
950
+ */
951
+ function jobLlmProvider(job) {
952
+ if (job?.llmProvider === 'openrouter') return 'openrouter';
953
+ if (job?.llmProvider === 'claude') return 'claude';
954
+ if (job?.usage?.provider === 'openrouter') return 'openrouter';
955
+ if (job?.usage?.provider === 'claude') return 'claude';
956
+ return String(job?.model || '').includes('/') ? 'openrouter' : 'claude';
957
+ }
958
+
959
+ function jobLlmLabel(job) {
960
+ return jobLlmProvider(job) === 'openrouter' ? 'OpenRouter' : 'Claude';
961
+ }
962
+
963
+ /** `OpenRouter · google/gemini-2.5-pro` */
964
+ function jobLlmLine(job) {
965
+ if (!job) return '';
966
+ const label = jobLlmLabel(job);
967
+ const model = typeof job.model === 'string' ? job.model.trim() : '';
968
+ if (model && model !== '-') return `${label} · ${model}`;
969
+ if (job.llmProvider || job.model || job.usage?.provider) return label;
970
+ return '';
929
971
  }
930
972
 
931
973
  function issueBadge(job) {
@@ -1056,26 +1098,37 @@ function formatUsageDetail(usage) {
1056
1098
  return parts.length ? parts.join(' · ') : null;
1057
1099
  }
1058
1100
 
1059
- /** Sum cost / tokens across jobs that have usage (this app's jobs only). */
1101
+ /** Sum cost / tokens across jobs that have usage, split by LLM provider. */
1060
1102
  function aggregateJobUsage(jobs) {
1061
- let totalCostUsd = 0;
1062
- let totalTokens = 0;
1063
- let withCost = 0;
1064
- let withTokens = 0;
1103
+ const empty = () => ({
1104
+ totalCostUsd: 0,
1105
+ totalTokens: 0,
1106
+ withCost: 0,
1107
+ withTokens: 0,
1108
+ });
1109
+ const out = { claude: empty(), openrouter: empty() };
1065
1110
  for (const job of jobs) {
1066
1111
  const u = job.usage;
1067
1112
  if (!u) continue;
1113
+ const bucket = out[jobLlmProvider(job)];
1068
1114
  if (typeof u.totalCostUsd === 'number' && Number.isFinite(u.totalCostUsd)) {
1069
- totalCostUsd += u.totalCostUsd;
1070
- withCost += 1;
1115
+ bucket.totalCostUsd += u.totalCostUsd;
1116
+ bucket.withCost += 1;
1071
1117
  }
1072
1118
  const tok = (u.inputTokens || 0) + (u.outputTokens || 0);
1073
1119
  if (tok > 0) {
1074
- totalTokens += tok;
1075
- withTokens += 1;
1120
+ bucket.totalTokens += tok;
1121
+ bucket.withTokens += 1;
1076
1122
  }
1077
1123
  }
1078
- return { totalCostUsd, totalTokens, withCost, withTokens };
1124
+ return out;
1125
+ }
1126
+
1127
+ function formatProviderUsageStat(agg) {
1128
+ if (!agg.withCost && !agg.withTokens) return '—';
1129
+ const cost = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
1130
+ const tok = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
1131
+ return `${cost} · ${tok}`;
1079
1132
  }
1080
1133
 
1081
1134
  /** Five progress dots: sync → worktree → agent → review → PR */
@@ -1230,6 +1283,27 @@ function formatLogEvent(event) {
1230
1283
  return { kind: 'status', label: 'Status', text, raw };
1231
1284
  }
1232
1285
 
1286
+ if (event.type === 'llm') {
1287
+ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
1288
+ const provider =
1289
+ payload.provider === 'openrouter' || payload.provider === 'OpenRouter'
1290
+ ? 'OpenRouter'
1291
+ : payload.provider === 'claude' || payload.provider === 'Claude'
1292
+ ? 'Claude'
1293
+ : payload.provider
1294
+ ? String(payload.provider)
1295
+ : 'Agent';
1296
+ const model = typeof payload.model === 'string' && payload.model.trim()
1297
+ ? payload.model.trim()
1298
+ : '—';
1299
+ return {
1300
+ kind: 'status',
1301
+ label: 'Agent',
1302
+ text: `${provider} · ${model}`,
1303
+ raw,
1304
+ };
1305
+ }
1306
+
1233
1307
  if (event.type === 'error') {
1234
1308
  return {
1235
1309
  kind: 'error',
@@ -2025,8 +2099,6 @@ function renderStats(jobs) {
2025
2099
  const review = jobs.filter((j) => j.status === 'awaiting_review').length;
2026
2100
  const alerts = collectAlerts(jobs).length;
2027
2101
  const agg = aggregateJobUsage(jobs);
2028
- const costLabel = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
2029
- const tokLabel = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
2030
2102
 
2031
2103
  const items = [
2032
2104
  { label: 'Queued', value: queued, color: 'var(--text-muted)', dot: false },
@@ -2034,18 +2106,18 @@ function renderStats(jobs) {
2034
2106
  { label: 'Ready for review', value: review, color: 'var(--primary)', dot: false },
2035
2107
  { label: 'Alerts', value: alerts, color: 'var(--accent)', dot: false },
2036
2108
  {
2037
- label: 'Job cost',
2038
- value: costLabel,
2109
+ label: 'Claude',
2110
+ value: formatProviderUsageStat(agg.claude),
2039
2111
  color: 'var(--primary)',
2040
2112
  dot: false,
2041
- hint: 'These jobs only',
2113
+ hint: 'Cost · in+out tok',
2042
2114
  },
2043
2115
  {
2044
- label: 'Tokens',
2045
- value: tokLabel,
2046
- color: 'var(--text-muted)',
2116
+ label: 'OpenRouter',
2117
+ value: formatProviderUsageStat(agg.openrouter),
2118
+ color: 'var(--primary)',
2047
2119
  dot: false,
2048
- hint: 'In + out',
2120
+ hint: 'Cost · in+out tok',
2049
2121
  },
2050
2122
  ];
2051
2123
 
@@ -2241,9 +2313,16 @@ function renderRuns(jobs) {
2241
2313
 
2242
2314
  const usageLine = document.createElement('div');
2243
2315
  usageLine.className = 'run-usage mono';
2316
+ const agentLine = jobLlmLine(job);
2244
2317
  const usageDetail = formatUsageDetail(job.usage);
2245
- usageLine.textContent = usageDetail || '—';
2246
- if (!usageDetail) usageLine.classList.add('muted');
2318
+ if (agentLine && usageDetail) {
2319
+ usageLine.textContent = `${agentLine} · ${usageDetail}`;
2320
+ } else if (agentLine) {
2321
+ usageLine.textContent = agentLine;
2322
+ } else {
2323
+ usageLine.textContent = usageDetail || '—';
2324
+ if (!usageDetail) usageLine.classList.add('muted');
2325
+ }
2247
2326
  card.appendChild(usageLine);
2248
2327
 
2249
2328
  const now = document.createElement('div');
@@ -2447,8 +2526,12 @@ function renderReview(jobs) {
2447
2526
  <div class="review-pick-sub"></div>
2448
2527
  `;
2449
2528
  btn.querySelector('.review-pick-title').textContent = jobTitle(job);
2450
- btn.querySelector('.review-pick-sub').textContent =
2451
- `${jobSubLine(job).replace(/ · .*$/, '')} · ${statusLabel(job.status)}`;
2529
+ const meta = jobRepoLine(job);
2530
+ const left = meta.isJira ? meta.number : `${meta.repo} #${meta.number}`;
2531
+ const agent = jobLlmLine(job);
2532
+ btn.querySelector('.review-pick-sub').textContent = [left, agent, statusLabel(job.status)]
2533
+ .filter(Boolean)
2534
+ .join(' · ');
2452
2535
  btn.addEventListener('click', () => selectReview(job.id));
2453
2536
  els.reviewList.appendChild(btn);
2454
2537
  }
@@ -2463,6 +2546,15 @@ function renderReview(jobs) {
2463
2546
  els.reviewEmptyDetail.classList.add('hidden');
2464
2547
  els.reviewDetailContent.classList.remove('hidden');
2465
2548
 
2549
+ if (els.reviewAgentMeta) {
2550
+ const agent = jobLlmLine(job);
2551
+ const usage = formatUsageDetail(job.usage);
2552
+ const parts = [agent, usage].filter(Boolean);
2553
+ els.reviewAgentMeta.textContent = parts.length
2554
+ ? parts.join(' · ')
2555
+ : 'Agent not recorded for this job.';
2556
+ }
2557
+
2466
2558
  const editable = job.status === 'awaiting_review';
2467
2559
  const isOpened = job.status === 'pr_opened';
2468
2560
  const isTerminal = job.status === 'discarded' || job.status === 'failed';
@@ -2711,6 +2803,8 @@ function jobMatchesReviewSearch(job, q) {
2711
2803
  ref?.full,
2712
2804
  jiraKey,
2713
2805
  job.ticketSource,
2806
+ job.llmProvider,
2807
+ job.model,
2714
2808
  ]
2715
2809
  .filter(Boolean)
2716
2810
  .map((s) => String(s).toLowerCase());
@@ -3008,6 +3102,7 @@ function updateModelLabel() {
3008
3102
  ? 'none selected'
3009
3103
  : currentModel;
3010
3104
  const combined = agentDisplayText(currentModel);
3105
+ const providerLabel = llmProvider === 'openrouter' ? 'OpenRouter' : 'Claude';
3011
3106
 
3012
3107
  if (els.modelCurrent) {
3013
3108
  els.modelCurrent.textContent = modelText;
@@ -3015,7 +3110,21 @@ function updateModelLabel() {
3015
3110
  }
3016
3111
  if (els.modelCombined) {
3017
3112
  els.modelCombined.textContent = combined;
3018
- els.modelCombined.title = combined;
3113
+ els.modelCombined.title = `${providerLabel}: ${combined}`;
3114
+ }
3115
+ if (els.modelProvider) {
3116
+ els.modelProvider.textContent = providerLabel;
3117
+ }
3118
+ if (els.sidebarAgentLabel) {
3119
+ els.sidebarAgentLabel.textContent = providerLabel;
3120
+ }
3121
+ if (els.overviewAgentIcon) {
3122
+ els.overviewAgentIcon.classList.toggle('agent-pill-icon--claude', llmProvider !== 'openrouter');
3123
+ els.overviewAgentIcon.classList.toggle('agent-pill-icon--openrouter', llmProvider === 'openrouter');
3124
+ }
3125
+ const pillLabel = els.overviewAgentTrigger?.querySelector('.agent-pill-label');
3126
+ if (pillLabel) {
3127
+ pillLabel.textContent = providerLabel;
3019
3128
  }
3020
3129
  syncAgentPickerDisplay();
3021
3130
  refreshModelSourceHint();
@@ -3521,6 +3630,16 @@ function reconcileModelForProvider(models, candidate = currentModel, storedCandi
3521
3630
  * @returns {Array<{id:string,label?:string,name?:string}>}
3522
3631
  */
3523
3632
  function defaultModels() {
3633
+ if (llmProvider === 'openrouter') {
3634
+ return [
3635
+ { id: 'google/gemini-2.5-pro', label: 'Google: Gemini 2.5 Pro' },
3636
+ { id: 'google/gemini-2.5-flash', label: 'Google: Gemini 2.5 Flash' },
3637
+ { id: 'openai/gpt-4.1', label: 'OpenAI: GPT-4.1' },
3638
+ { id: 'openai/gpt-4o', label: 'OpenAI: GPT-4o' },
3639
+ { id: 'anthropic/claude-sonnet-4.5', label: 'Anthropic: Claude Sonnet 4.5' },
3640
+ { id: 'anthropic/claude-opus-4.5', label: 'Anthropic: Claude Opus 4.5' },
3641
+ ];
3642
+ }
3524
3643
  return [
3525
3644
  { id: 'claude-sonnet-5', label: 'Sonnet 5' },
3526
3645
  { id: 'claude-opus-5', label: 'Opus 5' },
@@ -3532,16 +3651,24 @@ function defaultModels() {
3532
3651
  }
3533
3652
 
3534
3653
  /**
3535
- * @param {'anthropic' | 'fallback' | string | undefined} source
3654
+ * @param {'anthropic' | 'fallback' | 'openrouter' | 'openrouter-fallback' | string | undefined} source
3536
3655
  */
3537
3656
  function updateModelSourceHint(source) {
3538
3657
  if (!els.settingsModelHint) return;
3539
- if (source === 'anthropic') {
3658
+ if (source === 'openrouter') {
3659
+ modelSourceHintBase =
3660
+ 'OpenRouter model for agent runs. List loaded from OpenRouter Models API.';
3661
+ } else if (source === 'openrouter-fallback') {
3662
+ modelSourceHintBase =
3663
+ 'OpenRouter model for agent runs. Showing a short fallback list (live catalog unavailable).';
3664
+ } else if (source === 'anthropic') {
3540
3665
  modelSourceHintBase =
3541
3666
  'Claude model for agent runs. List loaded from Anthropic Models API.';
3542
3667
  } else if (source === 'fallback') {
3543
3668
  modelSourceHintBase =
3544
3669
  'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
3670
+ } else if (llmProvider === 'openrouter') {
3671
+ modelSourceHintBase = 'OpenRouter model for agent runs.';
3545
3672
  } else {
3546
3673
  modelSourceHintBase = 'Claude model for agent runs.';
3547
3674
  }
@@ -3611,6 +3738,7 @@ async function fetchModels(opts = {}) {
3611
3738
  }
3612
3739
  const query = new URLSearchParams();
3613
3740
  if (refresh) query.set('refresh', '1');
3741
+ if (llmProvider) query.set('provider', llmProvider);
3614
3742
  try {
3615
3743
  const res = await fetch(`/api/models?${query.toString()}`);
3616
3744
  if (!res.ok) {
@@ -3753,6 +3881,13 @@ function claudeStatusText(cfg) {
3753
3881
  }
3754
3882
  }
3755
3883
 
3884
+ function openrouterStatusText(cfg) {
3885
+ if (cfg.openrouterAuthOk) {
3886
+ return 'Authenticated via OPENROUTER_API_KEY';
3887
+ }
3888
+ return 'Not authenticated — add an OpenRouter API key';
3889
+ }
3890
+
3756
3891
  function fillAuthSettings(cfg) {
3757
3892
  if (els.settingsGhStatus) {
3758
3893
  els.settingsGhStatus.textContent = ghStatusText(cfg);
@@ -3811,6 +3946,25 @@ function fillAuthSettings(cfg) {
3811
3946
  ? 'OAuth token stored in <code>.acdev/.env</code> as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.'
3812
3947
  : 'From <code>claude setup-token</code> for non-interactive subscription auth. Stored as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.';
3813
3948
  }
3949
+
3950
+ if (els.settingsOpenrouterStatus) {
3951
+ els.settingsOpenrouterStatus.textContent = openrouterStatusText(cfg);
3952
+ els.settingsOpenrouterStatus.className = cfg.openrouterAuthOk
3953
+ ? 'auth-status ok'
3954
+ : 'auth-status err';
3955
+ }
3956
+ fillSecretInput(
3957
+ els.settingsOpenrouterKey,
3958
+ els.settingsOpenrouterKeyClear,
3959
+ Boolean(cfg.openrouterApiKeySet),
3960
+ cfg.openrouterApiKeyMasked,
3961
+ 'Paste an OpenRouter API key'
3962
+ );
3963
+ if (els.settingsOpenrouterKeyHint) {
3964
+ els.settingsOpenrouterKeyHint.innerHTML = cfg.openrouterApiKeySet
3965
+ ? 'API key stored in <code>.acdev/.env</code> as <code>OPENROUTER_API_KEY</code> (not committed). Leave blank to keep.'
3966
+ : 'From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.';
3967
+ }
3814
3968
  }
3815
3969
 
3816
3970
  function fillSettingsForm(cfg) {
@@ -3819,6 +3973,7 @@ function fillSettingsForm(cfg) {
3819
3973
  fillAuthSettings(cfg);
3820
3974
 
3821
3975
  updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
3976
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
3822
3977
 
3823
3978
  if (els.settingsJiraBaseUrl) {
3824
3979
  els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
@@ -4045,6 +4200,23 @@ function updateTicketSourceUI(source) {
4045
4200
  }
4046
4201
  }
4047
4202
 
4203
+ /**
4204
+ * @param {'claude' | 'openrouter'} provider
4205
+ */
4206
+ function updateLlmProviderUI(provider) {
4207
+ llmProvider = provider === 'openrouter' ? 'openrouter' : 'claude';
4208
+
4209
+ for (const toggle of [els.settingsLlmProvider, els.overviewLlmProvider]) {
4210
+ if (!toggle) continue;
4211
+ toggle.querySelectorAll('.source-btn').forEach((btn) => {
4212
+ const active = btn.dataset.provider === llmProvider;
4213
+ btn.classList.toggle('active', active);
4214
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
4215
+ });
4216
+ }
4217
+ updateModelLabel();
4218
+ }
4219
+
4048
4220
  /**
4049
4221
  * @param {'github' | 'jira'} next
4050
4222
  */
@@ -4066,6 +4238,26 @@ async function saveTicketSource(next) {
4066
4238
  }
4067
4239
  }
4068
4240
 
4241
+ async function saveLlmProvider(next) {
4242
+ const provider = next === 'openrouter' ? 'openrouter' : 'claude';
4243
+ updateLlmProviderUI(provider);
4244
+ clearAvailableModels();
4245
+ try {
4246
+ const res = await fetch('/api/config', {
4247
+ method: 'PATCH',
4248
+ headers: { 'Content-Type': 'application/json' },
4249
+ body: JSON.stringify({ llmProvider: provider }),
4250
+ });
4251
+ const data = await readJson(res);
4252
+ if (res.ok) {
4253
+ applyConfigSnapshot({ ...appConfig, ...data });
4254
+ await fetchModels({ refresh: true, reconcile: true });
4255
+ }
4256
+ } catch {
4257
+ void fetchModels({ refresh: true, reconcile: true });
4258
+ }
4259
+ }
4260
+
4069
4261
  function updateTimeoutMsHint(minutes) {
4070
4262
  if (!els.settingsTimeoutMs) return;
4071
4263
  const m = Number(minutes);
@@ -4093,6 +4285,7 @@ function setOverviewLlmFeedback(message, kind = 'ok') {
4093
4285
  */
4094
4286
  function fillOverviewLlmControls(cfg) {
4095
4287
  if (!cfg) return;
4288
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4096
4289
  syncAllModelComboboxValues(currentModel);
4097
4290
  }
4098
4291
 
@@ -4140,6 +4333,7 @@ function applyConfigSnapshot(data) {
4140
4333
  updateModelLabel();
4141
4334
  fillOverviewLlmControls(data);
4142
4335
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
4336
+ updateLlmProviderUI(data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4143
4337
  if (els.repoName) {
4144
4338
  els.repoName.textContent = data?.repoName || 'local repo';
4145
4339
  }
@@ -4186,6 +4380,7 @@ function readSettingsForm() {
4186
4380
  testCommand: testTrimmed === '' ? null : testTrimmed,
4187
4381
  allowedTools,
4188
4382
  ticketSource,
4383
+ llmProvider,
4189
4384
  };
4190
4385
 
4191
4386
  const ghToken = secretPatchValue(els.settingsGhToken);
@@ -4194,6 +4389,8 @@ function readSettingsForm() {
4194
4389
  if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
4195
4390
  const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
4196
4391
  if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
4392
+ const openrouterApiKey = secretPatchValue(els.settingsOpenrouterKey);
4393
+ if (openrouterApiKey !== undefined) patch.openrouterApiKey = openrouterApiKey;
4197
4394
 
4198
4395
  if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
4199
4396
  patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
@@ -4361,6 +4558,12 @@ bindSecretClear(
4361
4558
  els.settingsClaudeOauthHint,
4362
4559
  'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
4363
4560
  );
4561
+ bindSecretClear(
4562
+ els.settingsOpenrouterKeyClear,
4563
+ els.settingsOpenrouterKey,
4564
+ els.settingsOpenrouterKeyHint,
4565
+ 'Saved OPENROUTER_API_KEY will be removed when you click Save settings.'
4566
+ );
4364
4567
 
4365
4568
 
4366
4569
  document.addEventListener('click', (ev) => {
@@ -4466,9 +4669,6 @@ els.addBtn.addEventListener('click', async () => {
4466
4669
 
4467
4670
  const urls = splitIssueUrls(text);
4468
4671
  const branchName = els.preferredBranch?.value?.trim() || '';
4469
- // #region agent log
4470
- fetch('http://127.0.0.1:7258/ingest/377aa5e2-15ea-4447-a68b-7ce215882bc3',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'473a78'},body:JSON.stringify({sessionId:'473a78',runId:'pre-fix',hypothesisId:'B',location:'public/app.js:addBtn',message:'enqueue submit',data:{hasCustom:Boolean(branchName),branchName:branchName||null,urlCount:urls.length},timestamp:Date.now()})}).catch(()=>{});
4471
- // #endregion
4472
4672
 
4473
4673
  try {
4474
4674
  const res = await fetch('/api/issues', {
@@ -4524,6 +4724,18 @@ function handleTicketSourceToggleClick(e) {
4524
4724
  els.settingsTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4525
4725
  els.overviewTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4526
4726
 
4727
+ function handleLlmProviderToggleClick(e) {
4728
+ const btn = e.target.closest('.source-btn');
4729
+ if (!btn?.dataset.provider) return;
4730
+ const next = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
4731
+ if (next === llmProvider) return;
4732
+ e.stopPropagation();
4733
+ void saveLlmProvider(next);
4734
+ }
4735
+
4736
+ els.settingsLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
4737
+ els.overviewLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
4738
+
4527
4739
  els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
4528
4740
  e.preventDefault();
4529
4741
  setView('settings');
package/public/index.html CHANGED
@@ -72,7 +72,7 @@
72
72
  <div class="sidebar-footer">
73
73
  <div class="sidebar-divider"></div>
74
74
  <div class="model-block">
75
- <div class="model-label">Agent</div>
75
+ <div class="model-label" id="sidebar-agent-label">Agent</div>
76
76
  <div class="model-meta-row">
77
77
  <span class="model-combined" id="model-combined">—</span>
78
78
  </div>
@@ -190,6 +190,10 @@
190
190
  <svg class="agent-pill-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
191
191
  </button>
192
192
  <div class="agent-picker-panel" id="overview-agent-panel" role="dialog" aria-label="Choose agent model" aria-hidden="true">
193
+ <div class="source-toggle source-toggle--compact agent-picker-provider" id="overview-llm-provider" role="group" aria-label="LLM provider">
194
+ <button type="button" class="source-btn active" data-provider="claude">Claude</button>
195
+ <button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
196
+ </div>
193
197
  <label class="sr-only" for="overview-agent-search">Search models</label>
194
198
  <input
195
199
  id="overview-agent-search"
@@ -282,6 +286,7 @@
282
286
  <div class="review-detail" id="review-detail">
283
287
  <div class="empty-dashed" id="review-empty-detail">Select a job to review</div>
284
288
  <div class="stack hidden" id="review-detail-content">
289
+ <p class="review-agent-meta" id="review-agent-meta"></p>
285
290
  <div class="card" id="review-pr-meta-card">
286
291
  <div class="field-label">PR title</div>
287
292
  <input
@@ -548,6 +553,21 @@
548
553
  </div>
549
554
  </div>
550
555
  </div>
556
+
557
+ <div class="card settings-card">
558
+ <div class="rules-heading">OpenRouter authentication</div>
559
+ <p id="settings-openrouter-status" class="auth-status" role="status">Checking OpenRouter auth…</p>
560
+ <div class="settings-grid">
561
+ <div class="settings-field settings-field-full">
562
+ <label class="field-label" for="settings-openrouter-key">OpenRouter API key</label>
563
+ <input id="settings-openrouter-key" class="input" type="password" name="openrouterApiKey" autocomplete="new-password" placeholder="Leave blank to keep existing">
564
+ <button type="button" class="auth-clear hidden" id="settings-openrouter-key-clear">Clear saved API key</button>
565
+ <p class="field-hint" id="settings-openrouter-key-hint">
566
+ From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.
567
+ </p>
568
+ </div>
569
+ </div>
570
+ </div>
551
571
  </div>
552
572
  </div>
553
573
 
@@ -643,6 +663,14 @@
643
663
  Changes save to <code>.acdev/config.json</code> and apply to the next job — no restart needed.
644
664
  </p>
645
665
  <div class="card settings-card">
666
+ <div class="settings-field settings-field-full">
667
+ <div class="field-label">LLM provider</div>
668
+ <div class="source-toggle" id="settings-llm-provider" role="group" aria-label="LLM provider">
669
+ <button type="button" class="source-btn active" data-provider="claude">Claude</button>
670
+ <button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
671
+ </div>
672
+ <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>
673
+ </div>
646
674
  <div class="settings-grid">
647
675
  <div class="settings-field">
648
676
  <label class="field-label" for="settings-base-branch">Base branch</label>
@@ -682,7 +710,7 @@
682
710
  ></ul>
683
711
  </div>
684
712
  </div>
685
- <p class="field-hint" id="settings-model-hint">Claude model for agent runs.</p>
713
+ <p class="field-hint" id="settings-model-hint">Model for agent runs.</p>
686
714
  </div>
687
715
 
688
716
  <div class="settings-field">