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/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;
@@ -1050,6 +1064,13 @@ a { color: var(--primary); text-underline-offset: 3px; }
1050
1064
  color: var(--text-muted);
1051
1065
  }
1052
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
+
1053
1074
  .btn-remove {
1054
1075
  width: 26px;
1055
1076
  height: 26px;
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) {
package/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { checkClaudeAuth, publicClaudeAuthMethod } from './claude-auth.js';
4
+ import { checkOpenRouterAuth } from './openrouter-auth.js';
4
5
  import { maskSecret } from './env.js';
5
6
  import { checkGhAuth, githubTokenFromEnv, originRemoteInfo } from './gh-auth.js';
6
7
  import { normalizeJiraBaseUrl } from './jira.js';
@@ -10,7 +11,10 @@ import {
10
11
  isValidModelId,
11
12
  isNoModel,
12
13
  isClaudeCatalogId,
14
+ isOpenRouterModelId,
15
+ isModelIdForProvider,
13
16
  curatedModelOptions,
17
+ OPENROUTER_MODEL_OPTIONS,
14
18
  } from './models.js';
15
19
  import { dataDir } from './paths.js';
16
20
 
@@ -21,6 +25,9 @@ export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
21
25
 
22
26
  export const TICKET_SOURCES = /** @type {const} */ (['github', 'jira']);
23
27
 
28
+ /** LLM backends. Claude = Agent SDK; OpenRouter = @openrouter/agent. */
29
+ export const LLM_PROVIDERS = /** @type {const} */ (['claude', 'openrouter']);
30
+
24
31
  /**
25
32
  * Shared post-PR actions for Jira and GitHub Issues rules.
26
33
  * - Jira `set_status` / `close_issue`: workflow transitions (close → Done-like status).
@@ -39,6 +46,7 @@ export const GITHUB_AFTER_PR_ACTIONS = AFTER_PR_ACTIONS;
39
46
 
40
47
  const ALLOWED_TOOLS_SET = new Set(KNOWN_TOOLS);
41
48
  const ALLOWED_TICKET_SOURCES = new Set(TICKET_SOURCES);
49
+ const ALLOWED_LLM_PROVIDERS = new Set(LLM_PROVIDERS);
42
50
  const ALLOWED_AFTER_PR_ACTIONS = new Set(AFTER_PR_ACTIONS);
43
51
 
44
52
  /** @typedef {'none' | 'set_status' | 'add_label' | 'close_issue'} AfterPrAction */
@@ -65,6 +73,8 @@ const DEFAULTS = {
65
73
  allowedTools: [...KNOWN_TOOLS],
66
74
  agentTimeoutMs: 900_000,
67
75
  model: DEFAULT_MODEL,
76
+ llmProvider: /** @type {'claude' | 'openrouter'} */ ('claude'),
77
+ lastModelsByProvider: /** @type {{ claude?: string, openrouter?: string }} */ ({}),
68
78
  ticketSource: /** @type {'github' | 'jira'} */ ('github'),
69
79
  jiraBaseUrl: '',
70
80
  /** PR body phrase for Jira tickets, e.g. "Relates to PROJ-123". */
@@ -73,6 +83,55 @@ const DEFAULTS = {
73
83
  githubRules: structuredClone(DEFAULT_GITHUB_RULES),
74
84
  };
75
85
 
86
+ /**
87
+ * @param {unknown} value
88
+ * @returns {'claude' | 'openrouter'}
89
+ */
90
+ export function normalizeLlmProvider(value) {
91
+ return value === 'openrouter' ? 'openrouter' : 'claude';
92
+ }
93
+
94
+ /**
95
+ * @param {unknown} raw
96
+ * @returns {{ claude?: string, openrouter?: string }}
97
+ */
98
+ function normalizeLastModelsByProvider(raw) {
99
+ /** @type {{ claude?: string, openrouter?: string }} */
100
+ const out = {};
101
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
102
+ const obj = /** @type {Record<string, unknown>} */ (raw);
103
+ if (typeof obj.claude === 'string' && isValidModelId(obj.claude.trim()) && isClaudeCatalogId(obj.claude.trim())) {
104
+ out.claude = obj.claude.trim();
105
+ }
106
+ if (
107
+ typeof obj.openrouter === 'string' &&
108
+ isValidModelId(obj.openrouter.trim()) &&
109
+ isOpenRouterModelId(obj.openrouter.trim())
110
+ ) {
111
+ out.openrouter = obj.openrouter.trim();
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function rememberCurrentModel(config) {
117
+ const provider = normalizeLlmProvider(config.llmProvider);
118
+ const prev = normalizeLastModelsByProvider(config.lastModelsByProvider);
119
+ if (!isNoModel(config.model) && isModelIdForProvider(config.model, provider)) {
120
+ prev[provider] = String(config.model).trim();
121
+ }
122
+ config.lastModelsByProvider = prev;
123
+ }
124
+
125
+ function restoreModelForProvider(config, provider) {
126
+ const last = normalizeLastModelsByProvider(config.lastModelsByProvider);
127
+ const stored = last[provider];
128
+ if (stored && isModelIdForProvider(stored, provider)) {
129
+ config.model = stored;
130
+ return;
131
+ }
132
+ config.model = provider === 'openrouter' ? NO_MODEL : DEFAULT_MODEL;
133
+ }
134
+
76
135
  /**
77
136
  * Migrate legacy OpenRouter model ids to a Claude model.
78
137
  * @param {unknown} raw
@@ -240,6 +299,8 @@ function persistable(config) {
240
299
  allowedTools: config.allowedTools,
241
300
  agentTimeoutMs: config.agentTimeoutMs,
242
301
  model: config.model,
302
+ llmProvider: normalizeLlmProvider(config.llmProvider),
303
+ lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
243
304
  ticketSource: config.ticketSource,
244
305
  jiraBaseUrl: config.jiraBaseUrl || '',
245
306
  jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
@@ -297,6 +358,17 @@ export function loadConfig(repoRoot) {
297
358
 
298
359
  let needsSave = false;
299
360
 
361
+ if (raw.llmProvider === 'openrouter' || (isValidModelId(raw.model) && isOpenRouterModelId(String(raw.model)))) {
362
+ config.llmProvider = 'openrouter';
363
+ } else if (raw.llmProvider === 'claude' || raw.llmProvider == null) {
364
+ config.llmProvider = 'claude';
365
+ } else if (!ALLOWED_LLM_PROVIDERS.has(raw.llmProvider)) {
366
+ config.llmProvider = 'claude';
367
+ needsSave = true;
368
+ }
369
+
370
+ config.lastModelsByProvider = normalizeLastModelsByProvider(raw.lastModelsByProvider);
371
+
300
372
  if (isNoModel(config.model)) {
301
373
  if (String(config.model).trim() !== NO_MODEL) {
302
374
  config.model = NO_MODEL;
@@ -305,8 +377,14 @@ export function loadConfig(repoRoot) {
305
377
  config.model = NO_MODEL;
306
378
  }
307
379
  } else if (!isValidModelId(config.model)) {
308
- config.model = DEFAULT_MODEL;
380
+ config.model = config.llmProvider === 'openrouter' ? NO_MODEL : DEFAULT_MODEL;
309
381
  needsSave = true;
382
+ } else if (config.llmProvider === 'openrouter') {
383
+ config.model = String(config.model).trim();
384
+ if (!isOpenRouterModelId(config.model)) {
385
+ restoreModelForProvider(config, 'openrouter');
386
+ needsSave = true;
387
+ }
310
388
  } else {
311
389
  const migrated = migrateLegacyModel(raw, config.model);
312
390
  if (migrated !== String(config.model).trim()) {
@@ -317,9 +395,7 @@ export function loadConfig(repoRoot) {
317
395
  }
318
396
  }
319
397
 
320
- if (raw.llmProvider === 'openrouter' || raw.lastModelsByProvider != null) {
321
- needsSave = true;
322
- }
398
+ rememberCurrentModel(config);
323
399
 
324
400
  if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
325
401
  config.ticketSource = 'github';
@@ -349,6 +425,24 @@ export function loadConfig(repoRoot) {
349
425
  * @returns {typeof DEFAULTS}
350
426
  */
351
427
  export function updateConfig(repoRoot, config, patch) {
428
+ if (patch.llmProvider !== undefined) {
429
+ if (!ALLOWED_LLM_PROVIDERS.has(patch.llmProvider)) {
430
+ throw new Error(
431
+ `Invalid llmProvider "${patch.llmProvider}". Allowed: ${LLM_PROVIDERS.join(', ')}`
432
+ );
433
+ }
434
+ const next = /** @type {'claude' | 'openrouter'} */ (patch.llmProvider);
435
+ if (next !== normalizeLlmProvider(config.llmProvider)) {
436
+ rememberCurrentModel(config);
437
+ config.llmProvider = next;
438
+ if (patch.model == null) {
439
+ restoreModelForProvider(config, next);
440
+ }
441
+ }
442
+ }
443
+
444
+ const provider = normalizeLlmProvider(config.llmProvider);
445
+
352
446
  if (patch.model != null) {
353
447
  if (isNoModel(patch.model)) {
354
448
  config.model = NO_MODEL;
@@ -356,15 +450,19 @@ export function updateConfig(repoRoot, config, patch) {
356
450
  throw new Error(
357
451
  `Invalid model "${patch.model}". Expected a non-empty model id or "${NO_MODEL}" for no selection`
358
452
  );
359
- } else if (!isClaudeCatalogId(String(patch.model).trim())) {
453
+ } else if (!isModelIdForProvider(String(patch.model).trim(), provider)) {
360
454
  throw new Error(
361
- `Invalid model "${patch.model}". Expected a Claude model id (no provider/ prefix)`
455
+ provider === 'openrouter'
456
+ ? `Invalid model "${patch.model}". Expected an OpenRouter model id (provider/model)`
457
+ : `Invalid model "${patch.model}". Expected a Claude model id (no provider/ prefix)`
362
458
  );
363
459
  } else {
364
460
  config.model = String(patch.model).trim();
365
461
  }
366
462
  }
367
463
 
464
+ rememberCurrentModel(config);
465
+
368
466
  if (patch.baseBranch !== undefined) {
369
467
  if (typeof patch.baseBranch !== 'string' || !patch.baseBranch.trim()) {
370
468
  throw new Error('baseBranch must be a non-empty string');
@@ -456,11 +554,14 @@ export function updateConfig(repoRoot, config, patch) {
456
554
  * }} [opts]
457
555
  */
458
556
  export function publicConfig(config, opts = {}) {
557
+ const llmProvider = normalizeLlmProvider(config.llmProvider);
459
558
  const model = isNoModel(config.model)
460
559
  ? NO_MODEL
461
- : isValidModelId(config.model) && isClaudeCatalogId(config.model)
560
+ : isModelIdForProvider(config.model, llmProvider)
462
561
  ? String(config.model).trim()
463
- : DEFAULT_MODEL;
562
+ : llmProvider === 'openrouter'
563
+ ? NO_MODEL
564
+ : DEFAULT_MODEL;
464
565
  const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
465
566
  const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
466
567
  ? config.ticketSource
@@ -489,11 +590,19 @@ export function publicConfig(config, opts = {}) {
489
590
  const ghTokenMask = maskSecret(githubTokenFromEnv());
490
591
  const anthropicMask = maskSecret(process.env.ANTHROPIC_API_KEY);
491
592
  const claudeOauthMask = maskSecret(process.env.CLAUDE_CODE_OAUTH_TOKEN);
593
+ const openrouterMask = maskSecret(process.env.OPENROUTER_API_KEY);
594
+ const openrouterAuth = checkOpenRouterAuth();
595
+ const llmAuthOk = llmProvider === 'openrouter' ? openrouterAuth.ok : claudeAuth.ok === true;
492
596
 
493
- const curatedModels = curatedModelOptions();
597
+ const curatedModels =
598
+ llmProvider === 'openrouter'
599
+ ? OPENROUTER_MODEL_OPTIONS.map((m) => ({ ...m }))
600
+ : curatedModelOptions();
494
601
 
495
602
  return {
496
603
  model,
604
+ llmProvider,
605
+ lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
497
606
  baseBranch: config.baseBranch,
498
607
  testCommand: config.testCommand ?? null,
499
608
  maxAgentTurns: config.maxAgentTurns,
@@ -525,7 +634,10 @@ export function publicConfig(config, opts = {}) {
525
634
  anthropicApiKeyMasked: anthropicMask.masked,
526
635
  claudeOauthTokenSet: claudeOauthMask.set,
527
636
  claudeOauthTokenMasked: claudeOauthMask.masked,
528
- llmAuthOk: claudeAuth.ok === true,
637
+ openrouterApiKeySet: openrouterMask.set,
638
+ openrouterApiKeyMasked: openrouterMask.masked,
639
+ openrouterAuthOk: openrouterAuth.ok === true,
640
+ llmAuthOk,
529
641
  stubAgent: opts.stubAgent === true,
530
642
  ...(repoName ? { repoName } : {}),
531
643
  };
package/src/models.js CHANGED
@@ -41,6 +41,16 @@ export const CLAUDE_MODEL_OPTIONS = [
41
41
  /** @deprecated Use CLAUDE_MODEL_OPTIONS */
42
42
  export const MODEL_OPTIONS = CLAUDE_MODEL_OPTIONS;
43
43
 
44
+ /** Small fallback if OpenRouter's live catalog cannot be fetched. */
45
+ export const OPENROUTER_MODEL_OPTIONS = [
46
+ { id: 'google/gemini-2.5-pro', label: 'Google: Gemini 2.5 Pro' },
47
+ { id: 'google/gemini-2.5-flash', label: 'Google: Gemini 2.5 Flash' },
48
+ { id: 'openai/gpt-4.1', label: 'OpenAI: GPT-4.1' },
49
+ { id: 'openai/gpt-4o', label: 'OpenAI: GPT-4o' },
50
+ { id: 'anthropic/claude-sonnet-4.5', label: 'Anthropic: Claude Sonnet 4.5' },
51
+ { id: 'anthropic/claude-opus-4.5', label: 'Anthropic: Claude Opus 4.5' },
52
+ ];
53
+
44
54
  export const DEFAULT_MODEL = 'claude-sonnet-5';
45
55
 
46
56
  /** Config value meaning no model selected (jobs blocked until user picks one). */
@@ -50,16 +60,19 @@ export const NO_MODEL = '-';
50
60
  export const MODEL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:\/-]{0,127}$/;
51
61
 
52
62
  const ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models';
63
+ const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
53
64
  const ANTHROPIC_VERSION = '2023-06-01';
54
65
  const CACHE_TTL_MS = 5 * 60_000;
55
66
  const KEYCHAIN_SERVICE = 'Claude Code-credentials';
56
67
 
57
68
  /** @typedef {{ id: string, name?: string, label?: string }} ModelOption */
58
- /** @typedef {'anthropic' | 'fallback'} ModelsSource */
59
- /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource }} ModelsListResult */
69
+ /** @typedef {'anthropic' | 'fallback' | 'openrouter' | 'openrouter-fallback'} ModelsSource */
70
+ /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource, provider: 'claude' | 'openrouter' }} ModelsListResult */
60
71
 
61
- /** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected'> } | null} */
62
- let cache = null;
72
+ /** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected' | 'provider'> } | null} */
73
+ let claudeCache = null;
74
+ /** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected' | 'provider'> } | null} */
75
+ let openrouterCache = null;
63
76
 
64
77
  /** @type {typeof fetch | null} */
65
78
  let fetchImpl = null;
@@ -98,7 +111,8 @@ export function _resetCredentialsTokenResolver() {
98
111
  }
99
112
 
100
113
  export function _resetModelsCache() {
101
- cache = null;
114
+ claudeCache = null;
115
+ openrouterCache = null;
102
116
  }
103
117
 
104
118
  /**
@@ -166,13 +180,25 @@ export function isModelInCuratedCatalog(modelId) {
166
180
  }
167
181
 
168
182
  /**
169
- * Whether a model id is valid for Claude (direct ids without `/`).
183
+ * OpenRouter catalog ids are `provider/model` slugs.
184
+ * @param {string} id
185
+ * @returns {boolean}
186
+ */
187
+ export function isOpenRouterModelId(id) {
188
+ return isValidModelId(id) && String(id).includes('/');
189
+ }
190
+
191
+ /**
192
+ * Whether a model id is valid for the given LLM provider.
170
193
  * @param {string} modelId
194
+ * @param {'claude' | 'openrouter'} [provider]
171
195
  * @returns {boolean}
172
196
  */
173
- export function isModelIdForProvider(modelId) {
197
+ export function isModelIdForProvider(modelId, provider = 'claude') {
174
198
  if (isNoModel(modelId) || !isValidModelId(modelId)) return false;
175
- return isClaudeCatalogId(String(modelId).trim());
199
+ const id = String(modelId).trim();
200
+ if (provider === 'openrouter') return isOpenRouterModelId(id);
201
+ return isClaudeCatalogId(id);
176
202
  }
177
203
 
178
204
  /**
@@ -394,21 +420,59 @@ export async function fetchAnthropicModels() {
394
420
  }
395
421
 
396
422
  /**
397
- * List Claude models for the UI.
423
+ * Fetch live models from OpenRouter (public catalog; key optional).
424
+ * @returns {Promise<ModelOption[]>}
425
+ */
426
+ export async function fetchOpenRouterModels() {
427
+ const doFetch = fetchImpl || globalThis.fetch;
428
+ if (typeof doFetch !== 'function') {
429
+ throw new Error('fetch is not available');
430
+ }
431
+ /** @type {Record<string, string>} */
432
+ const headers = { Accept: 'application/json' };
433
+ const key = String(envResolver().OPENROUTER_API_KEY || '').trim();
434
+ if (key) headers.Authorization = `Bearer ${key}`;
435
+ const res = await doFetch(OPENROUTER_MODELS_URL, { method: 'GET', headers });
436
+ if (!res.ok) {
437
+ throw new Error(`OpenRouter Models API HTTP ${res.status}`);
438
+ }
439
+ const body = await res.json();
440
+ const rows = Array.isArray(body?.data) ? body.data : [];
441
+ /** @type {ModelOption[]} */
442
+ const models = [];
443
+ for (const row of rows) {
444
+ const id = typeof row?.id === 'string' ? row.id.trim() : '';
445
+ if (!isOpenRouterModelId(id)) continue;
446
+ const display =
447
+ typeof row.name === 'string'
448
+ ? row.name
449
+ : typeof row.display_name === 'string'
450
+ ? row.display_name
451
+ : '';
452
+ models.push(toOption(id, display));
453
+ }
454
+ if (models.length === 0) {
455
+ throw new Error('OpenRouter Models API returned no models');
456
+ }
457
+ return models;
458
+ }
459
+
460
+ /**
398
461
  * @param {{ selected?: string, force?: boolean }} [opts]
399
462
  * @returns {Promise<ModelsListResult>}
400
463
  */
401
- export async function listModels(opts = {}) {
464
+ async function listClaudeModels(opts = {}) {
402
465
  const selectedRaw = opts.selected;
403
466
  const force = opts.force === true;
404
467
  const now = Date.now();
405
468
 
406
- if (!force && cache && cache.expiresAt > now) {
407
- const selected = reconcileModelForProvider(cache.result.models, selectedRaw);
469
+ if (!force && claudeCache && claudeCache.expiresAt > now) {
470
+ const selected = reconcileModelForProvider(claudeCache.result.models, selectedRaw);
408
471
  return {
409
- ...cache.result,
410
- models: cache.result.models,
472
+ ...claudeCache.result,
473
+ models: claudeCache.result.models,
411
474
  selected,
475
+ provider: 'claude',
412
476
  };
413
477
  }
414
478
 
@@ -420,13 +484,62 @@ export async function listModels(opts = {}) {
420
484
  const models = filterClaudeModels(mergeModelLists(curated, live));
421
485
  const selected = reconcileModelForProvider(models, selectedRaw);
422
486
  const result = { models, source: /** @type {ModelsSource} */ ('anthropic') };
423
- cache = { expiresAt: now + CACHE_TTL_MS, result };
424
- return { ...result, selected };
487
+ claudeCache = { expiresAt: now + CACHE_TTL_MS, result };
488
+ return { ...result, selected, provider: 'claude' };
425
489
  } catch {
426
490
  const models = curated;
427
491
  const selected = reconcileModelForProvider(models, selectedRaw);
428
492
  const result = { models, source: /** @type {ModelsSource} */ ('fallback') };
429
- cache = { expiresAt: now + 30_000, result };
430
- return { ...result, selected };
493
+ claudeCache = { expiresAt: now + 30_000, result };
494
+ return { ...result, selected, provider: 'claude' };
495
+ }
496
+ }
497
+
498
+ /**
499
+ * @param {{ selected?: string, force?: boolean }} [opts]
500
+ * @returns {Promise<ModelsListResult>}
501
+ */
502
+ async function listOpenRouterModels(opts = {}) {
503
+ const selectedRaw = opts.selected;
504
+ const force = opts.force === true;
505
+ const now = Date.now();
506
+
507
+ if (!force && openrouterCache && openrouterCache.expiresAt > now) {
508
+ const selected = reconcileModelForProvider(openrouterCache.result.models, selectedRaw);
509
+ return {
510
+ ...openrouterCache.result,
511
+ models: openrouterCache.result.models,
512
+ selected,
513
+ provider: 'openrouter',
514
+ };
515
+ }
516
+
517
+ const curated = OPENROUTER_MODEL_OPTIONS.map((m) => ({ ...m }));
518
+
519
+ try {
520
+ const live = await fetchOpenRouterModels();
521
+ const models = mergeModelLists(curated, live);
522
+ const selected = reconcileModelForProvider(models, selectedRaw);
523
+ const result = { models, source: /** @type {ModelsSource} */ ('openrouter') };
524
+ openrouterCache = { expiresAt: now + CACHE_TTL_MS, result };
525
+ return { ...result, selected, provider: 'openrouter' };
526
+ } catch {
527
+ const models = curated;
528
+ const selected = reconcileModelForProvider(models, selectedRaw);
529
+ const result = { models, source: /** @type {ModelsSource} */ ('openrouter-fallback') };
530
+ openrouterCache = { expiresAt: now + 30_000, result };
531
+ return { ...result, selected, provider: 'openrouter' };
532
+ }
533
+ }
534
+
535
+ /**
536
+ * List models for the UI.
537
+ * @param {{ selected?: string, force?: boolean, provider?: 'claude' | 'openrouter' }} [opts]
538
+ * @returns {Promise<ModelsListResult>}
539
+ */
540
+ export async function listModels(opts = {}) {
541
+ if (opts.provider === 'openrouter') {
542
+ return listOpenRouterModels(opts);
431
543
  }
544
+ return listClaudeModels(opts);
432
545
  }