acdev 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/models.js CHANGED
@@ -15,8 +15,9 @@ import { join } from 'node:path';
15
15
  /**
16
16
  * Curated Claude Agent SDK / Claude Code model ids used as defaults + fallback.
17
17
  * Prefer documented Code aliases and Anthropic API ids (not invented snapshots).
18
+ * OpenRouter slugs (provider/model) are excluded — those belong in OPENROUTER_MODEL_OPTIONS.
18
19
  */
19
- export const MODEL_OPTIONS = [
20
+ export const CLAUDE_MODEL_OPTIONS = [
20
21
  { id: 'claude-sonnet-5', label: 'Sonnet 5' },
21
22
  { id: 'claude-opus-5', label: 'Opus 5' },
22
23
  { id: 'claude-fable-5', label: 'Fable 5' },
@@ -38,22 +39,49 @@ export const MODEL_OPTIONS = [
38
39
  { id: 'claude-opus-4-5-20251101', label: 'Opus 4.5 (20251101)' },
39
40
  ];
40
41
 
42
+ /** @deprecated Use CLAUDE_MODEL_OPTIONS */
43
+ export const MODEL_OPTIONS = CLAUDE_MODEL_OPTIONS;
44
+
41
45
  export const DEFAULT_MODEL = 'claude-sonnet-5';
42
46
 
43
- /** Loose model id shape accepted by config (API ids + Claude Code aliases). */
44
- export const MODEL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
47
+ /**
48
+ * OpenRouter slugs that work with acdev agent runs.
49
+ * Agent jobs use Claude Agent SDK via OpenRouter's Anthropic-compatible API;
50
+ * only anthropic/* models are supported on that path.
51
+ */
52
+ export const OPENROUTER_AGENT_MODEL_PREFIX = 'anthropic/';
53
+
54
+ /** Curated OpenRouter model ids used as defaults + fallback (agent-compatible only). */
55
+ export const OPENROUTER_MODEL_OPTIONS = [
56
+ { id: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (Anthropic)' },
57
+ { id: 'anthropic/claude-opus-4', label: 'Claude Opus 4 (Anthropic)' },
58
+ { id: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet (Anthropic)' },
59
+ { id: 'anthropic/claude-3.7-sonnet', label: 'Claude 3.7 Sonnet (Anthropic)' },
60
+ ];
61
+
62
+ export const DEFAULT_OPENROUTER_MODEL = 'anthropic/claude-sonnet-4';
63
+
64
+ /** Config value meaning no model selected (jobs blocked until user picks one). */
65
+ export const NO_MODEL = '-';
66
+
67
+ /** @typedef {'claude' | 'openrouter'} LlmProvider */
68
+ export const LLM_PROVIDERS = /** @type {const} */ (['claude', 'openrouter']);
69
+
70
+ /** Loose model id shape accepted by config (Claude aliases, API ids, OpenRouter slugs). */
71
+ export const MODEL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:\/-]{0,127}$/;
45
72
 
46
73
  const ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models';
74
+ const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
47
75
  const ANTHROPIC_VERSION = '2023-06-01';
48
76
  const CACHE_TTL_MS = 5 * 60_000;
49
77
  const KEYCHAIN_SERVICE = 'Claude Code-credentials';
50
78
 
51
79
  /** @typedef {{ id: string, name?: string, label?: string }} ModelOption */
52
- /** @typedef {'anthropic' | 'fallback'} ModelsSource */
53
- /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource }} ModelsListResult */
80
+ /** @typedef {'anthropic' | 'openrouter' | 'fallback'} ModelsSource */
81
+ /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource, provider?: LlmProvider }} ModelsListResult */
54
82
 
55
- /** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected'> } | null} */
56
- let cache = null;
83
+ /** @type {Map<LlmProvider, { expiresAt: number, result: Omit<ModelsListResult, 'selected' | 'provider'> }>} */
84
+ const cacheByProvider = new Map();
57
85
 
58
86
  /** @type {typeof fetch | null} */
59
87
  let fetchImpl = null;
@@ -92,7 +120,23 @@ export function _resetCredentialsTokenResolver() {
92
120
  }
93
121
 
94
122
  export function _resetModelsCache() {
95
- cache = null;
123
+ cacheByProvider.clear();
124
+ }
125
+
126
+ /**
127
+ * @param {unknown} value
128
+ * @returns {value is LlmProvider}
129
+ */
130
+ export function isValidLlmProvider(value) {
131
+ return value === 'claude' || value === 'openrouter';
132
+ }
133
+
134
+ /**
135
+ * Default model id for an LLM provider.
136
+ * @param {LlmProvider} [provider]
137
+ */
138
+ export function defaultModelForProvider(provider = 'claude') {
139
+ return provider === 'openrouter' ? DEFAULT_OPENROUTER_MODEL : DEFAULT_MODEL;
96
140
  }
97
141
 
98
142
  /**
@@ -103,6 +147,24 @@ export function isValidModelId(value) {
103
147
  return typeof value === 'string' && MODEL_ID_RE.test(value.trim());
104
148
  }
105
149
 
150
+ /**
151
+ * @param {unknown} value
152
+ * @returns {boolean}
153
+ */
154
+ export function isNoModel(value) {
155
+ if (value == null) return true;
156
+ const s = String(value).trim();
157
+ return s === '' || s === NO_MODEL;
158
+ }
159
+
160
+ /**
161
+ * @param {unknown} value
162
+ * @returns {boolean}
163
+ */
164
+ export function isValidModelSelection(value) {
165
+ return isNoModel(value) || isValidModelId(value);
166
+ }
167
+
106
168
  /**
107
169
  * @param {string} id
108
170
  * @param {string} [displayName]
@@ -114,7 +176,131 @@ function toOption(id, displayName) {
114
176
  }
115
177
 
116
178
  /**
117
- * Merge curated options (stable order / aliases) with live Anthropic rows.
179
+ * Claude direct path ids: Anthropic API ids and Claude Code aliases (no provider/ prefix).
180
+ * @param {string} id
181
+ * @returns {boolean}
182
+ */
183
+ export function isClaudeCatalogId(id) {
184
+ return isValidModelId(id) && !String(id).includes('/');
185
+ }
186
+
187
+ /**
188
+ * OpenRouter slugs use provider/model form (always includes `/`).
189
+ * @param {string} id
190
+ * @returns {boolean}
191
+ */
192
+ export function isOpenRouterCatalogId(id) {
193
+ return isValidModelId(id) && String(id).includes('/');
194
+ }
195
+
196
+ /**
197
+ * Whether an OpenRouter slug can be used for acdev agent runs.
198
+ * Non-anthropic slugs may appear in OpenRouter's catalog but fail at runtime
199
+ * because the Claude Agent SDK speaks Anthropic Messages API semantics.
200
+ * @param {string} modelId
201
+ * @returns {boolean}
202
+ */
203
+ export function isOpenRouterAgentCompatibleModel(modelId) {
204
+ if (!isOpenRouterCatalogId(modelId)) return false;
205
+ return String(modelId).trim().toLowerCase().startsWith(OPENROUTER_AGENT_MODEL_PREFIX);
206
+ }
207
+
208
+ /**
209
+ * @param {ModelOption[]} models
210
+ * @returns {ModelOption[]}
211
+ */
212
+ function filterOpenRouterAgentModels(models) {
213
+ return models.filter((m) => m?.id && isOpenRouterAgentCompatibleModel(m.id));
214
+ }
215
+
216
+ /**
217
+ * Improve OpenRouter agent failure messages for the job UI.
218
+ * @param {string} message
219
+ * @param {string} [model]
220
+ * @returns {string}
221
+ */
222
+ export function enhanceOpenRouterAgentError(message, model) {
223
+ const raw = String(message || '').trim();
224
+ if (!raw) return raw;
225
+
226
+ const id = String(model || '').trim();
227
+ const routingFailure =
228
+ /no allowed providers are available/i.test(raw) ||
229
+ /not allowed by provider/i.test(raw) ||
230
+ /model.*not allowed/i.test(raw);
231
+
232
+ if (id && !isOpenRouterAgentCompatibleModel(id)) {
233
+ return [
234
+ `OpenRouter model "${id}" is not supported for acdev agent runs.`,
235
+ 'Agent jobs use the Claude Agent SDK via OpenRouter\'s Anthropic-compatible API.',
236
+ 'Choose an anthropic/* model (e.g. anthropic/claude-sonnet-4).',
237
+ raw !== id ? `Provider error: ${raw}` : '',
238
+ ]
239
+ .filter(Boolean)
240
+ .join(' ');
241
+ }
242
+
243
+ if (routingFailure) {
244
+ return [
245
+ raw,
246
+ 'If you use OpenRouter provider allowlists, clear Settings → Privacy → Providers or add the model\'s upstream provider.',
247
+ 'For acdev, anthropic/* models on OpenRouter are the most reliable choice.',
248
+ ].join(' ');
249
+ }
250
+
251
+ return raw;
252
+ }
253
+
254
+ /**
255
+ * Curated fallback list for a provider.
256
+ * @param {LlmProvider} provider
257
+ * @returns {ModelOption[]}
258
+ */
259
+ export function curatedModelOptions(provider) {
260
+ return provider === 'openrouter'
261
+ ? OPENROUTER_MODEL_OPTIONS.map((m) => ({ ...m }))
262
+ : CLAUDE_MODEL_OPTIONS.map((m) => ({ ...m }));
263
+ }
264
+
265
+ /**
266
+ * Whether a model id belongs in a provider's curated catalog.
267
+ * @param {string} modelId
268
+ * @param {LlmProvider} provider
269
+ * @returns {boolean}
270
+ */
271
+ export function isModelInCuratedCatalog(modelId, provider) {
272
+ if (isNoModel(modelId)) return false;
273
+ const id = String(modelId).trim();
274
+ return curatedModelOptions(provider).some((m) => m.id === id);
275
+ }
276
+
277
+ /**
278
+ * Whether a model id is valid for a provider (format-based, not curated-only).
279
+ * Claude: direct ids without `/`. OpenRouter: provider/model slugs with `/`.
280
+ * @param {string} modelId
281
+ * @param {LlmProvider} provider
282
+ * @returns {boolean}
283
+ */
284
+ export function isModelIdForProvider(modelId, provider) {
285
+ if (isNoModel(modelId) || !isValidModelId(modelId)) return false;
286
+ const id = String(modelId).trim();
287
+ return provider === 'openrouter' ? isOpenRouterCatalogId(id) : isClaudeCatalogId(id);
288
+ }
289
+
290
+ /**
291
+ * Drop ids that do not belong in this provider's catalog.
292
+ * @param {ModelOption[]} models
293
+ * @param {LlmProvider} provider
294
+ * @returns {ModelOption[]}
295
+ */
296
+ function filterModelsForProvider(models, provider) {
297
+ const keep =
298
+ provider === 'openrouter' ? isOpenRouterCatalogId : isClaudeCatalogId;
299
+ return models.filter((m) => m?.id && keep(m.id));
300
+ }
301
+
302
+ /**
303
+ * Merge curated options (stable order / aliases) with live rows for one provider.
118
304
  * Same ids keep curated position but prefer live display names.
119
305
  * @param {ModelOption[]} curated
120
306
  * @param {ModelOption[]} live
@@ -149,13 +335,16 @@ function mergeModelLists(curated, live) {
149
335
  }
150
336
 
151
337
  /**
338
+ * Keep selected only when it exists in the provider model catalog.
152
339
  * @param {ModelOption[]} models
153
340
  * @param {string} selected
154
- * @returns {ModelOption[]}
341
+ * @returns {string}
155
342
  */
156
- function ensureSelected(models, selected) {
157
- if (!selected || models.some((m) => m.id === selected)) return models;
158
- return [toOption(selected), ...models];
343
+ export function reconcileModelForProvider(models, selected) {
344
+ if (isNoModel(selected)) return NO_MODEL;
345
+ const id = String(selected).trim();
346
+ if (!isValidModelId(id)) return NO_MODEL;
347
+ return models.some((m) => m.id === id) ? id : NO_MODEL;
159
348
  }
160
349
 
161
350
  /**
@@ -320,47 +509,115 @@ export async function fetchAnthropicModels() {
320
509
  }
321
510
 
322
511
  /**
323
- * List models for the UI.
324
- * @param {{ selected?: string, force?: boolean }} [opts]
512
+ * @param {Response} res
513
+ * @returns {Promise<ModelOption[]>}
514
+ */
515
+ async function parseOpenRouterModelsResponse(res) {
516
+ if (!res.ok) {
517
+ throw new Error(`OpenRouter Models API HTTP ${res.status}`);
518
+ }
519
+ const body = await res.json();
520
+ const rows = Array.isArray(body?.data) ? body.data : [];
521
+ /** @type {ModelOption[]} */
522
+ const models = [];
523
+ for (const row of rows) {
524
+ const id = typeof row?.id === 'string' ? row.id.trim() : '';
525
+ if (!isValidModelId(id)) continue;
526
+ const display =
527
+ typeof row.name === 'string'
528
+ ? row.name
529
+ : typeof row?.id === 'string'
530
+ ? row.id
531
+ : '';
532
+ models.push(toOption(id, display));
533
+ }
534
+ if (models.length === 0) {
535
+ throw new Error('OpenRouter Models API returned no models');
536
+ }
537
+ return models;
538
+ }
539
+
540
+ /**
541
+ * Fetch live models from OpenRouter (no cache).
542
+ * @returns {Promise<ModelOption[]>}
543
+ */
544
+ export async function fetchOpenRouterModels() {
545
+ const env = envResolver();
546
+ const apiKey = (env.OPENROUTER_API_KEY || '').trim();
547
+ if (!apiKey) {
548
+ throw new Error('No OpenRouter API key for models list');
549
+ }
550
+ const doFetch = fetchImpl || globalThis.fetch;
551
+ if (typeof doFetch !== 'function') {
552
+ throw new Error('fetch is not available');
553
+ }
554
+ const res = await doFetch(OPENROUTER_MODELS_URL, {
555
+ method: 'GET',
556
+ headers: {
557
+ Authorization: `Bearer ${apiKey}`,
558
+ 'HTTP-Referer': 'https://github.com/acdev',
559
+ 'X-Title': 'acdev',
560
+ },
561
+ });
562
+ return parseOpenRouterModelsResponse(res);
563
+ }
564
+
565
+ /**
566
+ * @param {LlmProvider} provider
567
+ * @param {{ selected?: string, force?: boolean }} opts
325
568
  * @returns {Promise<ModelsListResult>}
326
569
  */
327
- export async function listModels(opts = {}) {
570
+ async function listModelsForProvider(provider, opts = {}) {
328
571
  const selectedRaw = opts.selected;
329
- const selected = isValidModelId(selectedRaw)
330
- ? String(selectedRaw).trim()
331
- : DEFAULT_MODEL;
332
572
  const force = opts.force === true;
333
573
  const now = Date.now();
574
+ const cache = cacheByProvider.get(provider);
334
575
 
335
576
  if (!force && cache && cache.expiresAt > now) {
577
+ const selected = reconcileModelForProvider(cache.result.models, selectedRaw);
336
578
  return {
337
579
  ...cache.result,
338
- models: ensureSelected(cache.result.models, selected),
580
+ models: cache.result.models,
339
581
  selected,
582
+ provider,
340
583
  };
341
584
  }
342
585
 
586
+ const curated = curatedModelOptions(provider);
587
+
343
588
  try {
344
- const live = await fetchAnthropicModels();
345
- // Keep curated Claude Code aliases available alongside API ids.
346
- const models = ensureSelected(
347
- mergeModelLists(
348
- MODEL_OPTIONS.map((m) => ({ ...m })),
349
- live
350
- ),
351
- selected
352
- );
353
- const result = { models, source: /** @type {ModelsSource} */ ('anthropic') };
354
- cache = { expiresAt: now + CACHE_TTL_MS, result };
355
- return { ...result, selected };
589
+ const liveRaw =
590
+ provider === 'openrouter'
591
+ ? await fetchOpenRouterModels()
592
+ : await fetchAnthropicModels();
593
+ const live = filterModelsForProvider(liveRaw, provider);
594
+ let models = filterModelsForProvider(mergeModelLists(curated, live), provider);
595
+ if (provider === 'openrouter') {
596
+ models = filterOpenRouterAgentModels(models);
597
+ }
598
+ const selected = reconcileModelForProvider(models, selectedRaw);
599
+ const source = /** @type {ModelsSource} */ (provider === 'openrouter' ? 'openrouter' : 'anthropic');
600
+ const result = { models, source };
601
+ cacheByProvider.set(provider, { expiresAt: now + CACHE_TTL_MS, result });
602
+ return { ...result, selected, provider };
356
603
  } catch {
357
- const models = ensureSelected(
358
- MODEL_OPTIONS.map((m) => ({ ...m })),
359
- selected
360
- );
604
+ let models = curated;
605
+ if (provider === 'openrouter') {
606
+ models = filterOpenRouterAgentModels(models);
607
+ }
608
+ const selected = reconcileModelForProvider(models, selectedRaw);
361
609
  const result = { models, source: /** @type {ModelsSource} */ ('fallback') };
362
- // Short cache on fallback so we retry Anthropic soon after auth is saved.
363
- cache = { expiresAt: now + 30_000, result };
364
- return { ...result, selected };
610
+ cacheByProvider.set(provider, { expiresAt: now + 30_000, result });
611
+ return { ...result, selected, provider };
365
612
  }
366
613
  }
614
+
615
+ /**
616
+ * List models for the UI.
617
+ * @param {{ selected?: string, force?: boolean, provider?: LlmProvider }} [opts]
618
+ * @returns {Promise<ModelsListResult>}
619
+ */
620
+ export async function listModels(opts = {}) {
621
+ const provider = isValidLlmProvider(opts.provider) ? opts.provider : 'claude';
622
+ return listModelsForProvider(provider, opts);
623
+ }
@@ -0,0 +1,37 @@
1
+ /** @typedef {{ ok: true } | { ok: false, reason: 'missing' }} OpenRouterAuthResult */
2
+
3
+ /** @type {() => NodeJS.ProcessEnv} */
4
+ let envResolver = () => process.env;
5
+
6
+ /** @param {() => NodeJS.ProcessEnv} fn */
7
+ export function _setEnvResolver(fn) {
8
+ envResolver = fn;
9
+ }
10
+
11
+ export function _resetEnvResolver() {
12
+ envResolver = () => process.env;
13
+ }
14
+
15
+ /**
16
+ * Whether OpenRouter can authenticate agent runs.
17
+ * @returns {OpenRouterAuthResult}
18
+ */
19
+ export function checkOpenRouterAuth() {
20
+ const key = (envResolver().OPENROUTER_API_KEY || '').trim();
21
+ if (key) return { ok: true };
22
+ return { ok: false, reason: 'missing' };
23
+ }
24
+
25
+ /**
26
+ * Human-readable startup warning for a failed {@link checkOpenRouterAuth}.
27
+ * Soft-auth: server still starts so Settings can configure the key.
28
+ * @param {OpenRouterAuthResult} [_result]
29
+ */
30
+ export function formatOpenRouterAuthError(_result) {
31
+ return [
32
+ '⚠ OpenRouter is not authenticated — server will still start.',
33
+ ' Open Settings → Authentication to add your OpenRouter API key',
34
+ ' (or set OPENROUTER_API_KEY in `.acdev/.env`).',
35
+ ' For UI-only testing without auth: pass `--stub-agent`.',
36
+ ].join('\n');
37
+ }
package/src/server.js CHANGED
@@ -38,6 +38,8 @@ import { splitIssueUrls } from './urls.js';
38
38
  import { usageFromLogs, withJobUsage } from './usage.js';
39
39
  import { checkGhAuth } from './gh-auth.js';
40
40
  import { checkClaudeAuth } from './claude-auth.js';
41
+ import { checkOpenRouterAuth } from './openrouter-auth.js';
42
+ import { isValidLlmProvider, isValidModelId, isNoModel, NO_MODEL, isOpenRouterAgentCompatibleModel, enhanceOpenRouterAgentError } from './models.js';
41
43
 
42
44
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
43
45
 
@@ -173,6 +175,7 @@ export function normalizeReviewComments(body) {
173
175
  * resolveJiraCredentials?: Function,
174
176
  * checkGhAuth?: typeof checkGhAuth,
175
177
  * checkClaudeAuth?: typeof checkClaudeAuth,
178
+ * checkOpenRouterAuth?: typeof checkOpenRouterAuth,
176
179
  * },
177
180
  * }} options
178
181
  */
@@ -188,15 +191,28 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
188
191
  const doApplyFileExclusions = deps.applyFileExclusions || applyFileExclusions;
189
192
  const doCheckGhAuth = deps.checkGhAuth || checkGhAuth;
190
193
  const doCheckClaudeAuth = deps.checkClaudeAuth || checkClaudeAuth;
194
+ const doCheckOpenRouterAuth = deps.checkOpenRouterAuth || checkOpenRouterAuth;
195
+
196
+ function currentLlmProvider() {
197
+ return isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude';
198
+ }
199
+
200
+ function formatAgentJobError(err) {
201
+ let message = err instanceof Error ? err.message : String(err);
202
+ if (currentLlmProvider() === 'openrouter') {
203
+ message = enhanceOpenRouterAgentError(message, config.model);
204
+ }
205
+ return message;
206
+ }
191
207
 
192
208
  /**
193
209
  * Reject enqueue / agent / PR actions when required auth is missing.
194
- * @param {{ needGh?: boolean, needClaude?: boolean }} [opts]
210
+ * @param {{ needGh?: boolean, needLlm?: boolean }} [opts]
195
211
  * @returns {{ status: number, error: string, code: string } | null}
196
212
  */
197
213
  function authGate(opts = {}) {
198
214
  const needGh = opts.needGh !== false;
199
- const needClaude = opts.needClaude === true && !useStubAgent;
215
+ const needLlm = opts.needLlm !== false && !useStubAgent;
200
216
 
201
217
  if (needGh) {
202
218
  const gh = doCheckGhAuth();
@@ -209,16 +225,53 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
209
225
  }
210
226
  }
211
227
 
212
- if (needClaude) {
213
- const claude = doCheckClaudeAuth();
214
- if (!claude.ok) {
228
+ if (needLlm) {
229
+ const model = String(config.model ?? '').trim();
230
+ if (isNoModel(model)) {
215
231
  return {
216
232
  status: 400,
217
- error:
218
- 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, run claude auth login, or start with --stub-agent.',
219
- code: 'claude_auth_required',
233
+ error: `No model selected. Choose a model in Settings → Configuration (not "${NO_MODEL}") before starting jobs.`,
234
+ code: 'model_required',
235
+ };
236
+ }
237
+ if (!isValidModelId(model)) {
238
+ return {
239
+ status: 400,
240
+ error: 'Invalid model in configuration. Choose a valid model in Settings → Configuration.',
241
+ code: 'model_invalid',
220
242
  };
221
243
  }
244
+
245
+ const provider = currentLlmProvider();
246
+ if (provider === 'openrouter') {
247
+ const openrouter = doCheckOpenRouterAuth();
248
+ if (!openrouter.ok) {
249
+ return {
250
+ status: 400,
251
+ error:
252
+ 'OpenRouter is not authenticated. Add an API key in Settings → Authentication, or start with --stub-agent.',
253
+ code: 'openrouter_auth_required',
254
+ };
255
+ }
256
+ if (!isOpenRouterAgentCompatibleModel(model)) {
257
+ return {
258
+ status: 400,
259
+ error:
260
+ `Model "${model}" is not supported for OpenRouter agent runs. acdev uses the Claude Agent SDK (Anthropic-compatible API). Choose an anthropic/* model such as anthropic/claude-sonnet-4.`,
261
+ code: 'openrouter_model_incompatible',
262
+ };
263
+ }
264
+ } else {
265
+ const claude = doCheckClaudeAuth();
266
+ if (!claude.ok) {
267
+ return {
268
+ status: 400,
269
+ error:
270
+ 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, run claude auth login, or start with --stub-agent.',
271
+ code: 'claude_auth_required',
272
+ };
273
+ }
274
+ }
222
275
  }
223
276
 
224
277
  return null;
@@ -230,6 +283,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
230
283
  stubAgent: useStubAgent,
231
284
  ghAuth: doCheckGhAuth(),
232
285
  claudeAuth: doCheckClaudeAuth(),
286
+ openrouterAuth: doCheckOpenRouterAuth(),
233
287
  });
234
288
  }
235
289
 
@@ -271,7 +325,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
271
325
  let job = store.getJob(jobId);
272
326
  if (!job) return;
273
327
 
274
- const gate = authGate({ needGh: true, needClaude: true });
328
+ const gate = authGate({ needGh: true, needLlm: true });
275
329
  if (gate) {
276
330
  appendLog(job, 'error', gate.error);
277
331
  store.updateJob(jobId, { status: 'failed', error: gate.error });
@@ -383,7 +437,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
383
437
  if (usage) patch.usage = usage;
384
438
  setStatus(jobId, 'awaiting_review', patch);
385
439
  } catch (err) {
386
- const message = err instanceof Error ? err.message : String(err);
440
+ const message = formatAgentJobError(err);
387
441
  const current = store.getJob(jobId);
388
442
  if (!current) return;
389
443
  appendLog(current, 'error', message);
@@ -410,7 +464,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
410
464
  let job = store.getJob(jobId);
411
465
  if (!job || job.status !== 'applying_feedback') return;
412
466
 
413
- const gate = authGate({ needGh: false, needClaude: true });
467
+ const gate = authGate({ needGh: false, needLlm: true });
414
468
  if (gate) {
415
469
  appendLog(job, 'error', gate.error);
416
470
  store.updateJob(jobId, {
@@ -484,7 +538,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
484
538
  if (usage) patch.usage = usage;
485
539
  setStatus(jobId, 'awaiting_review', patch);
486
540
  } catch (err) {
487
- const message = err instanceof Error ? err.message : String(err);
541
+ const message = formatAgentJobError(err);
488
542
  const current = store.getJob(jobId);
489
543
  if (!current) return;
490
544
  appendLog(current, 'error', message);
@@ -549,7 +603,11 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
549
603
  req.query.refresh === '1' ||
550
604
  req.query.refresh === 'true' ||
551
605
  req.query.force === '1';
552
- const result = await listModels({ selected: config.model, force });
606
+ const provider =
607
+ req.query.provider === 'openrouter' || req.query.provider === 'claude'
608
+ ? req.query.provider
609
+ : currentLlmProvider();
610
+ const result = await listModels({ selected: config.model, force, provider });
553
611
  res.json(result);
554
612
  } catch (err) {
555
613
  res.status(500).json({ error: err.message });
@@ -573,6 +631,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
573
631
  applySecretField(envPatch, 'GH_TOKEN', patch.ghToken);
574
632
  applySecretField(envPatch, 'ANTHROPIC_API_KEY', patch.anthropicApiKey);
575
633
  applySecretField(envPatch, 'CLAUDE_CODE_OAUTH_TOKEN', patch.claudeOauthToken);
634
+ applySecretField(envPatch, 'OPENROUTER_API_KEY', patch.openrouterApiKey);
576
635
  if (patch.jiraBaseUrl !== undefined && typeof patch.jiraBaseUrl === 'string') {
577
636
  // Also mirror base URL into env for convenience when set via Settings
578
637
  const trimmed = patch.jiraBaseUrl.trim();
@@ -590,6 +649,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
590
649
  ghToken: _gh,
591
650
  anthropicApiKey: _ak,
592
651
  claudeOauthToken: _oa,
652
+ openrouterApiKey: _or,
593
653
  ...configPatch
594
654
  } = patch;
595
655
  updateConfig(repoRoot, config, configPatch);
@@ -623,7 +683,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
623
683
 
624
684
  app.post('/api/issues', (req, res) => {
625
685
  try {
626
- const gate = authGate({ needGh: true, needClaude: true });
686
+ const gate = authGate({ needGh: true, needLlm: true });
627
687
  if (gate) {
628
688
  return res.status(gate.status).json({ error: gate.error, code: gate.code });
629
689
  }
@@ -803,7 +863,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
803
863
 
804
864
  app.post('/api/jobs/:id/review', (req, res) => {
805
865
  try {
806
- const gate = authGate({ needGh: false, needClaude: true });
866
+ const gate = authGate({ needGh: false, needLlm: true });
807
867
  if (gate) {
808
868
  return res.status(gate.status).json({ error: gate.error, code: gate.code });
809
869
  }
@@ -853,7 +913,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
853
913
 
854
914
  app.post('/api/jobs/:id/approve', async (req, res) => {
855
915
  try {
856
- const gate = authGate({ needGh: true, needClaude: false });
916
+ const gate = authGate({ needGh: true, needLlm: false });
857
917
  if (gate) {
858
918
  return res.status(gate.status).json({ error: gate.error, code: gate.code });
859
919
  }
@@ -1036,7 +1096,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
1036
1096
 
1037
1097
  app.post('/api/jobs/:id/retry', (req, res) => {
1038
1098
  try {
1039
- const gate = authGate({ needGh: true, needClaude: true });
1099
+ const gate = authGate({ needGh: true, needLlm: true });
1040
1100
  if (gate) {
1041
1101
  return res.status(gate.status).json({ error: gate.error, code: gate.code });
1042
1102
  }