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.
@@ -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.11",
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
  * }} */
@@ -331,6 +338,13 @@ const els = {
331
338
  settingsClaudeOauth: document.getElementById('settings-claude-oauth'),
332
339
  settingsClaudeOauthClear: document.getElementById('settings-claude-oauth-clear'),
333
340
  settingsClaudeOauthHint: document.getElementById('settings-claude-oauth-hint'),
341
+ settingsOpenrouterStatus: document.getElementById('settings-openrouter-status'),
342
+ settingsOpenrouterKey: document.getElementById('settings-openrouter-key'),
343
+ settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
344
+ settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
345
+ settingsLlmProvider: document.getElementById('settings-llm-provider'),
346
+ overviewLlmProvider: document.getElementById('overview-llm-provider'),
347
+ sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
334
348
  settingsTabs: document.getElementById('settings-tabs'),
335
349
  };
336
350
 
@@ -3008,6 +3022,7 @@ function updateModelLabel() {
3008
3022
  ? 'none selected'
3009
3023
  : currentModel;
3010
3024
  const combined = agentDisplayText(currentModel);
3025
+ const providerLabel = llmProvider === 'openrouter' ? 'OpenRouter' : 'Claude';
3011
3026
 
3012
3027
  if (els.modelCurrent) {
3013
3028
  els.modelCurrent.textContent = modelText;
@@ -3015,7 +3030,21 @@ function updateModelLabel() {
3015
3030
  }
3016
3031
  if (els.modelCombined) {
3017
3032
  els.modelCombined.textContent = combined;
3018
- els.modelCombined.title = combined;
3033
+ els.modelCombined.title = `${providerLabel}: ${combined}`;
3034
+ }
3035
+ if (els.modelProvider) {
3036
+ els.modelProvider.textContent = providerLabel;
3037
+ }
3038
+ if (els.sidebarAgentLabel) {
3039
+ els.sidebarAgentLabel.textContent = providerLabel;
3040
+ }
3041
+ if (els.overviewAgentIcon) {
3042
+ els.overviewAgentIcon.classList.toggle('agent-pill-icon--claude', llmProvider !== 'openrouter');
3043
+ els.overviewAgentIcon.classList.toggle('agent-pill-icon--openrouter', llmProvider === 'openrouter');
3044
+ }
3045
+ const pillLabel = els.overviewAgentTrigger?.querySelector('.agent-pill-label');
3046
+ if (pillLabel) {
3047
+ pillLabel.textContent = providerLabel;
3019
3048
  }
3020
3049
  syncAgentPickerDisplay();
3021
3050
  refreshModelSourceHint();
@@ -3521,6 +3550,16 @@ function reconcileModelForProvider(models, candidate = currentModel, storedCandi
3521
3550
  * @returns {Array<{id:string,label?:string,name?:string}>}
3522
3551
  */
3523
3552
  function defaultModels() {
3553
+ if (llmProvider === 'openrouter') {
3554
+ return [
3555
+ { id: 'google/gemini-2.5-pro', label: 'Google: Gemini 2.5 Pro' },
3556
+ { id: 'google/gemini-2.5-flash', label: 'Google: Gemini 2.5 Flash' },
3557
+ { id: 'openai/gpt-4.1', label: 'OpenAI: GPT-4.1' },
3558
+ { id: 'openai/gpt-4o', label: 'OpenAI: GPT-4o' },
3559
+ { id: 'anthropic/claude-sonnet-4.5', label: 'Anthropic: Claude Sonnet 4.5' },
3560
+ { id: 'anthropic/claude-opus-4.5', label: 'Anthropic: Claude Opus 4.5' },
3561
+ ];
3562
+ }
3524
3563
  return [
3525
3564
  { id: 'claude-sonnet-5', label: 'Sonnet 5' },
3526
3565
  { id: 'claude-opus-5', label: 'Opus 5' },
@@ -3532,16 +3571,24 @@ function defaultModels() {
3532
3571
  }
3533
3572
 
3534
3573
  /**
3535
- * @param {'anthropic' | 'fallback' | string | undefined} source
3574
+ * @param {'anthropic' | 'fallback' | 'openrouter' | 'openrouter-fallback' | string | undefined} source
3536
3575
  */
3537
3576
  function updateModelSourceHint(source) {
3538
3577
  if (!els.settingsModelHint) return;
3539
- if (source === 'anthropic') {
3578
+ if (source === 'openrouter') {
3579
+ modelSourceHintBase =
3580
+ 'OpenRouter model for agent runs. List loaded from OpenRouter Models API.';
3581
+ } else if (source === 'openrouter-fallback') {
3582
+ modelSourceHintBase =
3583
+ 'OpenRouter model for agent runs. Showing a short fallback list (live catalog unavailable).';
3584
+ } else if (source === 'anthropic') {
3540
3585
  modelSourceHintBase =
3541
3586
  'Claude model for agent runs. List loaded from Anthropic Models API.';
3542
3587
  } else if (source === 'fallback') {
3543
3588
  modelSourceHintBase =
3544
3589
  'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
3590
+ } else if (llmProvider === 'openrouter') {
3591
+ modelSourceHintBase = 'OpenRouter model for agent runs.';
3545
3592
  } else {
3546
3593
  modelSourceHintBase = 'Claude model for agent runs.';
3547
3594
  }
@@ -3611,6 +3658,7 @@ async function fetchModels(opts = {}) {
3611
3658
  }
3612
3659
  const query = new URLSearchParams();
3613
3660
  if (refresh) query.set('refresh', '1');
3661
+ if (llmProvider) query.set('provider', llmProvider);
3614
3662
  try {
3615
3663
  const res = await fetch(`/api/models?${query.toString()}`);
3616
3664
  if (!res.ok) {
@@ -3753,6 +3801,13 @@ function claudeStatusText(cfg) {
3753
3801
  }
3754
3802
  }
3755
3803
 
3804
+ function openrouterStatusText(cfg) {
3805
+ if (cfg.openrouterAuthOk) {
3806
+ return 'Authenticated via OPENROUTER_API_KEY';
3807
+ }
3808
+ return 'Not authenticated — add an OpenRouter API key';
3809
+ }
3810
+
3756
3811
  function fillAuthSettings(cfg) {
3757
3812
  if (els.settingsGhStatus) {
3758
3813
  els.settingsGhStatus.textContent = ghStatusText(cfg);
@@ -3811,6 +3866,25 @@ function fillAuthSettings(cfg) {
3811
3866
  ? '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
3867
  : '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
3868
  }
3869
+
3870
+ if (els.settingsOpenrouterStatus) {
3871
+ els.settingsOpenrouterStatus.textContent = openrouterStatusText(cfg);
3872
+ els.settingsOpenrouterStatus.className = cfg.openrouterAuthOk
3873
+ ? 'auth-status ok'
3874
+ : 'auth-status err';
3875
+ }
3876
+ fillSecretInput(
3877
+ els.settingsOpenrouterKey,
3878
+ els.settingsOpenrouterKeyClear,
3879
+ Boolean(cfg.openrouterApiKeySet),
3880
+ cfg.openrouterApiKeyMasked,
3881
+ 'Paste an OpenRouter API key'
3882
+ );
3883
+ if (els.settingsOpenrouterKeyHint) {
3884
+ els.settingsOpenrouterKeyHint.innerHTML = cfg.openrouterApiKeySet
3885
+ ? 'API key stored in <code>.acdev/.env</code> as <code>OPENROUTER_API_KEY</code> (not committed). Leave blank to keep.'
3886
+ : 'From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.';
3887
+ }
3814
3888
  }
3815
3889
 
3816
3890
  function fillSettingsForm(cfg) {
@@ -3819,6 +3893,7 @@ function fillSettingsForm(cfg) {
3819
3893
  fillAuthSettings(cfg);
3820
3894
 
3821
3895
  updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
3896
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
3822
3897
 
3823
3898
  if (els.settingsJiraBaseUrl) {
3824
3899
  els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
@@ -4045,6 +4120,23 @@ function updateTicketSourceUI(source) {
4045
4120
  }
4046
4121
  }
4047
4122
 
4123
+ /**
4124
+ * @param {'claude' | 'openrouter'} provider
4125
+ */
4126
+ function updateLlmProviderUI(provider) {
4127
+ llmProvider = provider === 'openrouter' ? 'openrouter' : 'claude';
4128
+
4129
+ for (const toggle of [els.settingsLlmProvider, els.overviewLlmProvider]) {
4130
+ if (!toggle) continue;
4131
+ toggle.querySelectorAll('.source-btn').forEach((btn) => {
4132
+ const active = btn.dataset.provider === llmProvider;
4133
+ btn.classList.toggle('active', active);
4134
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
4135
+ });
4136
+ }
4137
+ updateModelLabel();
4138
+ }
4139
+
4048
4140
  /**
4049
4141
  * @param {'github' | 'jira'} next
4050
4142
  */
@@ -4066,6 +4158,26 @@ async function saveTicketSource(next) {
4066
4158
  }
4067
4159
  }
4068
4160
 
4161
+ async function saveLlmProvider(next) {
4162
+ const provider = next === 'openrouter' ? 'openrouter' : 'claude';
4163
+ updateLlmProviderUI(provider);
4164
+ clearAvailableModels();
4165
+ try {
4166
+ const res = await fetch('/api/config', {
4167
+ method: 'PATCH',
4168
+ headers: { 'Content-Type': 'application/json' },
4169
+ body: JSON.stringify({ llmProvider: provider }),
4170
+ });
4171
+ const data = await readJson(res);
4172
+ if (res.ok) {
4173
+ applyConfigSnapshot({ ...appConfig, ...data });
4174
+ await fetchModels({ refresh: true, reconcile: true });
4175
+ }
4176
+ } catch {
4177
+ void fetchModels({ refresh: true, reconcile: true });
4178
+ }
4179
+ }
4180
+
4069
4181
  function updateTimeoutMsHint(minutes) {
4070
4182
  if (!els.settingsTimeoutMs) return;
4071
4183
  const m = Number(minutes);
@@ -4093,6 +4205,7 @@ function setOverviewLlmFeedback(message, kind = 'ok') {
4093
4205
  */
4094
4206
  function fillOverviewLlmControls(cfg) {
4095
4207
  if (!cfg) return;
4208
+ updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4096
4209
  syncAllModelComboboxValues(currentModel);
4097
4210
  }
4098
4211
 
@@ -4140,6 +4253,7 @@ function applyConfigSnapshot(data) {
4140
4253
  updateModelLabel();
4141
4254
  fillOverviewLlmControls(data);
4142
4255
  updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
4256
+ updateLlmProviderUI(data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
4143
4257
  if (els.repoName) {
4144
4258
  els.repoName.textContent = data?.repoName || 'local repo';
4145
4259
  }
@@ -4186,6 +4300,7 @@ function readSettingsForm() {
4186
4300
  testCommand: testTrimmed === '' ? null : testTrimmed,
4187
4301
  allowedTools,
4188
4302
  ticketSource,
4303
+ llmProvider,
4189
4304
  };
4190
4305
 
4191
4306
  const ghToken = secretPatchValue(els.settingsGhToken);
@@ -4194,6 +4309,8 @@ function readSettingsForm() {
4194
4309
  if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
4195
4310
  const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
4196
4311
  if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
4312
+ const openrouterApiKey = secretPatchValue(els.settingsOpenrouterKey);
4313
+ if (openrouterApiKey !== undefined) patch.openrouterApiKey = openrouterApiKey;
4197
4314
 
4198
4315
  if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
4199
4316
  patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
@@ -4361,6 +4478,12 @@ bindSecretClear(
4361
4478
  els.settingsClaudeOauthHint,
4362
4479
  'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
4363
4480
  );
4481
+ bindSecretClear(
4482
+ els.settingsOpenrouterKeyClear,
4483
+ els.settingsOpenrouterKey,
4484
+ els.settingsOpenrouterKeyHint,
4485
+ 'Saved OPENROUTER_API_KEY will be removed when you click Save settings.'
4486
+ );
4364
4487
 
4365
4488
 
4366
4489
  document.addEventListener('click', (ev) => {
@@ -4466,9 +4589,6 @@ els.addBtn.addEventListener('click', async () => {
4466
4589
 
4467
4590
  const urls = splitIssueUrls(text);
4468
4591
  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
4592
 
4473
4593
  try {
4474
4594
  const res = await fetch('/api/issues', {
@@ -4524,6 +4644,18 @@ function handleTicketSourceToggleClick(e) {
4524
4644
  els.settingsTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4525
4645
  els.overviewTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
4526
4646
 
4647
+ function handleLlmProviderToggleClick(e) {
4648
+ const btn = e.target.closest('.source-btn');
4649
+ if (!btn?.dataset.provider) return;
4650
+ const next = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
4651
+ if (next === llmProvider) return;
4652
+ e.stopPropagation();
4653
+ void saveLlmProvider(next);
4654
+ }
4655
+
4656
+ els.settingsLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
4657
+ els.overviewLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
4658
+
4527
4659
  els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
4528
4660
  e.preventDefault();
4529
4661
  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"
@@ -548,6 +552,21 @@
548
552
  </div>
549
553
  </div>
550
554
  </div>
555
+
556
+ <div class="card settings-card">
557
+ <div class="rules-heading">OpenRouter authentication</div>
558
+ <p id="settings-openrouter-status" class="auth-status" role="status">Checking OpenRouter auth…</p>
559
+ <div class="settings-grid">
560
+ <div class="settings-field settings-field-full">
561
+ <label class="field-label" for="settings-openrouter-key">OpenRouter API key</label>
562
+ <input id="settings-openrouter-key" class="input" type="password" name="openrouterApiKey" autocomplete="new-password" placeholder="Leave blank to keep existing">
563
+ <button type="button" class="auth-clear hidden" id="settings-openrouter-key-clear">Clear saved API key</button>
564
+ <p class="field-hint" id="settings-openrouter-key-hint">
565
+ From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.
566
+ </p>
567
+ </div>
568
+ </div>
569
+ </div>
551
570
  </div>
552
571
  </div>
553
572
 
@@ -643,6 +662,14 @@
643
662
  Changes save to <code>.acdev/config.json</code> and apply to the next job — no restart needed.
644
663
  </p>
645
664
  <div class="card settings-card">
665
+ <div class="settings-field settings-field-full">
666
+ <div class="field-label">LLM provider</div>
667
+ <div class="source-toggle" id="settings-llm-provider" role="group" aria-label="LLM provider">
668
+ <button type="button" class="source-btn active" data-provider="claude">Claude</button>
669
+ <button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
670
+ </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
+ </div>
646
673
  <div class="settings-grid">
647
674
  <div class="settings-field">
648
675
  <label class="field-label" for="settings-base-branch">Base branch</label>
@@ -682,7 +709,7 @@
682
709
  ></ul>
683
710
  </div>
684
711
  </div>
685
- <p class="field-hint" id="settings-model-hint">Claude model for agent runs.</p>
712
+ <p class="field-hint" id="settings-model-hint">Model for agent runs.</p>
686
713
  </div>
687
714
 
688
715
  <div class="settings-field">
package/public/styles.css CHANGED
@@ -588,6 +588,10 @@ a { color: var(--primary); text-underline-offset: 3px; }
588
588
  background: linear-gradient(145deg, #e8a87c 0%, #d97706 100%);
589
589
  }
590
590
 
591
+ .agent-pill-icon--openrouter {
592
+ background: linear-gradient(145deg, #7c9cff 0%, #4f46e5 100%);
593
+ }
594
+
591
595
  .agent-pill-body {
592
596
  flex: 1;
593
597
  min-width: 0;
@@ -636,7 +640,7 @@ a { color: var(--primary); text-underline-offset: 3px; }
636
640
  display: flex;
637
641
  flex-direction: column;
638
642
  width: min(360px, calc(100vw - 48px));
639
- max-height: 320px;
643
+ max-height: 360px;
640
644
  background: var(--surface);
641
645
  border: 1px solid var(--border);
642
646
  border-radius: 10px;
@@ -713,6 +717,16 @@ a { color: var(--primary); text-underline-offset: 3px; }
713
717
  background: transparent;
714
718
  }
715
719
 
720
+ .agent-picker-provider {
721
+ margin: 10px 10px 0;
722
+ max-width: none;
723
+ }
724
+
725
+ .agent-picker-provider .source-btn {
726
+ padding: 7px 10px;
727
+ font-size: 12px;
728
+ }
729
+
716
730
  .agent-picker-search {
717
731
  flex-shrink: 0;
718
732
  min-height: 36px;
package/src/agent.js CHANGED
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { ensureNoAiAttributionSettings, getIssueTitle } from './git.js';
5
5
  import { extractUsageFromResult } from './usage.js';
6
+ import { runOpenRouterQuery } from './openrouter-agent.js';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
8
9
 
@@ -494,6 +495,42 @@ async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn =
494
495
  return { resultText, meta, usage };
495
496
  }
496
497
 
498
+ /**
499
+ * Dispatch Claude Agent SDK vs OpenRouter Agent SDK.
500
+ * @param {{
501
+ * prompt: string,
502
+ * worktreePath: string,
503
+ * config: object,
504
+ * onEvent: (message: unknown) => void,
505
+ * queryFn?: typeof query,
506
+ * callModelFn?: (args: object) => object,
507
+ * }} params
508
+ */
509
+ async function runConfiguredQuery({
510
+ prompt,
511
+ worktreePath,
512
+ config,
513
+ onEvent,
514
+ queryFn,
515
+ callModelFn,
516
+ }) {
517
+ if (config.llmProvider === 'openrouter') {
518
+ const out = await runOpenRouterQuery({
519
+ prompt,
520
+ worktreePath,
521
+ config,
522
+ onEvent,
523
+ callModelFn,
524
+ });
525
+ return {
526
+ resultText: out.resultText,
527
+ meta: extractPrMetadata(out.resultText),
528
+ usage: out.usage,
529
+ };
530
+ }
531
+ return runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn });
532
+ }
533
+
497
534
  function stubAgentResult(onEvent, title, body) {
498
535
  const fakeResult = {
499
536
  type: 'result',
@@ -543,6 +580,7 @@ function stubAgentResult(onEvent, title, body) {
543
580
  * jiraKey?: string,
544
581
  * jiraIssue?: object,
545
582
  * queryFn?: typeof query,
583
+ * callModelFn?: (args: object) => object,
546
584
  * }} params
547
585
  */
548
586
  export async function runAgentOnIssue({
@@ -557,6 +595,7 @@ export async function runAgentOnIssue({
557
595
  jiraKey,
558
596
  jiraIssue,
559
597
  queryFn = query,
598
+ callModelFn,
560
599
  }) {
561
600
  if (stub) {
562
601
  await new Promise((resolve) => setTimeout(resolve, 2000));
@@ -567,19 +606,22 @@ export async function runAgentOnIssue({
567
606
  return stubAgentResult(onEvent, 'Fix issue (stub)', body);
568
607
  }
569
608
 
570
- const { resultText, meta, usage } = await runAgentQuery({
571
- prompt: buildPrompt(issueUrl, config, {
572
- branchName,
573
- issueNumber,
574
- ticketSource,
575
- jiraKey,
576
- jiraIssue,
577
- jiraPrLinkPhrase: config.jiraPrLinkPhrase,
578
- }),
609
+ const prompt = buildPrompt(issueUrl, config, {
610
+ branchName,
611
+ issueNumber,
612
+ ticketSource,
613
+ jiraKey,
614
+ jiraIssue,
615
+ jiraPrLinkPhrase: config.jiraPrLinkPhrase,
616
+ });
617
+
618
+ const { resultText, meta, usage } = await runConfiguredQuery({
619
+ prompt,
579
620
  worktreePath,
580
621
  config,
581
622
  onEvent,
582
623
  queryFn,
624
+ callModelFn,
583
625
  });
584
626
 
585
627
  if (meta) {
@@ -612,6 +654,7 @@ export async function runAgentOnIssue({
612
654
  * ticketSource?: 'github' | 'jira',
613
655
  * jiraKey?: string,
614
656
  * queryFn?: typeof query,
657
+ * callModelFn?: (args: object) => object,
615
658
  * }} params
616
659
  */
617
660
  export async function runAgentOnReviewFeedback({
@@ -627,6 +670,7 @@ export async function runAgentOnReviewFeedback({
627
670
  ticketSource,
628
671
  jiraKey,
629
672
  queryFn = query,
673
+ callModelFn,
630
674
  }) {
631
675
  if (stub) {
632
676
  await new Promise((resolve) => setTimeout(resolve, 500));
@@ -637,22 +681,25 @@ export async function runAgentOnReviewFeedback({
637
681
  return stubAgentResult(onEvent, 'Address review feedback (stub)', body);
638
682
  }
639
683
 
640
- const { resultText, meta, usage } = await runAgentQuery({
641
- prompt: buildReviewFeedbackPrompt({
642
- issueUrl,
643
- generalComment,
644
- lineComments,
645
- config,
646
- branchName,
647
- issueNumber,
648
- ticketSource,
649
- jiraKey,
650
- jiraPrLinkPhrase: config.jiraPrLinkPhrase,
651
- }),
684
+ const prompt = buildReviewFeedbackPrompt({
685
+ issueUrl,
686
+ generalComment,
687
+ lineComments,
688
+ config,
689
+ branchName,
690
+ issueNumber,
691
+ ticketSource,
692
+ jiraKey,
693
+ jiraPrLinkPhrase: config.jiraPrLinkPhrase,
694
+ });
695
+
696
+ const { resultText, meta, usage } = await runConfiguredQuery({
697
+ prompt,
652
698
  worktreePath,
653
699
  config,
654
700
  onEvent,
655
701
  queryFn,
702
+ callModelFn,
656
703
  });
657
704
 
658
705
  if (meta) {