acdev 1.0.5 → 1.0.6

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,47 @@ 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
+ /** Curated OpenRouter model ids used as defaults + fallback. */
48
+ export const OPENROUTER_MODEL_OPTIONS = [
49
+ { id: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (Anthropic)' },
50
+ { id: 'anthropic/claude-opus-4', label: 'Claude Opus 4 (Anthropic)' },
51
+ { id: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet (Anthropic)' },
52
+ { id: 'anthropic/claude-3.7-sonnet', label: 'Claude 3.7 Sonnet (Anthropic)' },
53
+ { id: 'openai/gpt-4.1', label: 'GPT-4.1 (OpenAI)' },
54
+ { id: 'openai/gpt-4o', label: 'GPT-4o (OpenAI)' },
55
+ { id: 'google/gemini-2.5-pro-preview', label: 'Gemini 2.5 Pro (Google)' },
56
+ { id: 'x-ai/grok-2', label: 'Grok 2 (xAI)' },
57
+ { id: 'x-ai/grok-2-1212', label: 'Grok 2 1212 (xAI)' },
58
+ ];
59
+
60
+ export const DEFAULT_OPENROUTER_MODEL = 'anthropic/claude-sonnet-4';
61
+
62
+ /** Config value meaning no model selected (jobs blocked until user picks one). */
63
+ export const NO_MODEL = '-';
64
+
65
+ /** @typedef {'claude' | 'openrouter'} LlmProvider */
66
+ export const LLM_PROVIDERS = /** @type {const} */ (['claude', 'openrouter']);
67
+
68
+ /** Loose model id shape accepted by config (Claude aliases, API ids, OpenRouter slugs). */
69
+ export const MODEL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:\/-]{0,127}$/;
45
70
 
46
71
  const ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models';
72
+ const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
47
73
  const ANTHROPIC_VERSION = '2023-06-01';
48
74
  const CACHE_TTL_MS = 5 * 60_000;
49
75
  const KEYCHAIN_SERVICE = 'Claude Code-credentials';
50
76
 
51
77
  /** @typedef {{ id: string, name?: string, label?: string }} ModelOption */
52
- /** @typedef {'anthropic' | 'fallback'} ModelsSource */
53
- /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource }} ModelsListResult */
78
+ /** @typedef {'anthropic' | 'openrouter' | 'fallback'} ModelsSource */
79
+ /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource, provider?: LlmProvider }} ModelsListResult */
54
80
 
55
- /** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected'> } | null} */
56
- let cache = null;
81
+ /** @type {Map<LlmProvider, { expiresAt: number, result: Omit<ModelsListResult, 'selected' | 'provider'> }>} */
82
+ const cacheByProvider = new Map();
57
83
 
58
84
  /** @type {typeof fetch | null} */
59
85
  let fetchImpl = null;
@@ -92,7 +118,23 @@ export function _resetCredentialsTokenResolver() {
92
118
  }
93
119
 
94
120
  export function _resetModelsCache() {
95
- cache = null;
121
+ cacheByProvider.clear();
122
+ }
123
+
124
+ /**
125
+ * @param {unknown} value
126
+ * @returns {value is LlmProvider}
127
+ */
128
+ export function isValidLlmProvider(value) {
129
+ return value === 'claude' || value === 'openrouter';
130
+ }
131
+
132
+ /**
133
+ * Default model id for an LLM provider.
134
+ * @param {LlmProvider} [provider]
135
+ */
136
+ export function defaultModelForProvider(provider = 'claude') {
137
+ return provider === 'openrouter' ? DEFAULT_OPENROUTER_MODEL : DEFAULT_MODEL;
96
138
  }
97
139
 
98
140
  /**
@@ -103,6 +145,24 @@ export function isValidModelId(value) {
103
145
  return typeof value === 'string' && MODEL_ID_RE.test(value.trim());
104
146
  }
105
147
 
148
+ /**
149
+ * @param {unknown} value
150
+ * @returns {boolean}
151
+ */
152
+ export function isNoModel(value) {
153
+ if (value == null) return true;
154
+ const s = String(value).trim();
155
+ return s === '' || s === NO_MODEL;
156
+ }
157
+
158
+ /**
159
+ * @param {unknown} value
160
+ * @returns {boolean}
161
+ */
162
+ export function isValidModelSelection(value) {
163
+ return isNoModel(value) || isValidModelId(value);
164
+ }
165
+
106
166
  /**
107
167
  * @param {string} id
108
168
  * @param {string} [displayName]
@@ -114,7 +174,73 @@ function toOption(id, displayName) {
114
174
  }
115
175
 
116
176
  /**
117
- * Merge curated options (stable order / aliases) with live Anthropic rows.
177
+ * Claude direct path ids: Anthropic API ids and Claude Code aliases (no provider/ prefix).
178
+ * @param {string} id
179
+ * @returns {boolean}
180
+ */
181
+ export function isClaudeCatalogId(id) {
182
+ return isValidModelId(id) && !String(id).includes('/');
183
+ }
184
+
185
+ /**
186
+ * OpenRouter slugs use provider/model form (always includes `/`).
187
+ * @param {string} id
188
+ * @returns {boolean}
189
+ */
190
+ export function isOpenRouterCatalogId(id) {
191
+ return isValidModelId(id) && String(id).includes('/');
192
+ }
193
+
194
+ /**
195
+ * Curated fallback list for a provider.
196
+ * @param {LlmProvider} provider
197
+ * @returns {ModelOption[]}
198
+ */
199
+ export function curatedModelOptions(provider) {
200
+ return provider === 'openrouter'
201
+ ? OPENROUTER_MODEL_OPTIONS.map((m) => ({ ...m }))
202
+ : CLAUDE_MODEL_OPTIONS.map((m) => ({ ...m }));
203
+ }
204
+
205
+ /**
206
+ * Whether a model id belongs in a provider's curated catalog.
207
+ * @param {string} modelId
208
+ * @param {LlmProvider} provider
209
+ * @returns {boolean}
210
+ */
211
+ export function isModelInCuratedCatalog(modelId, provider) {
212
+ if (isNoModel(modelId)) return false;
213
+ const id = String(modelId).trim();
214
+ return curatedModelOptions(provider).some((m) => m.id === id);
215
+ }
216
+
217
+ /**
218
+ * Whether a model id is valid for a provider (format-based, not curated-only).
219
+ * Claude: direct ids without `/`. OpenRouter: provider/model slugs with `/`.
220
+ * @param {string} modelId
221
+ * @param {LlmProvider} provider
222
+ * @returns {boolean}
223
+ */
224
+ export function isModelIdForProvider(modelId, provider) {
225
+ if (isNoModel(modelId) || !isValidModelId(modelId)) return false;
226
+ const id = String(modelId).trim();
227
+ return provider === 'openrouter' ? isOpenRouterCatalogId(id) : isClaudeCatalogId(id);
228
+ }
229
+
230
+ /**
231
+ * Drop ids that do not belong in this provider's catalog.
232
+ * @param {ModelOption[]} models
233
+ * @param {LlmProvider} provider
234
+ * @returns {ModelOption[]}
235
+ */
236
+ function filterModelsForProvider(models, provider) {
237
+ const keep =
238
+ provider === 'openrouter' ? isOpenRouterCatalogId : isClaudeCatalogId;
239
+ return models.filter((m) => m?.id && keep(m.id));
240
+ }
241
+
242
+ /**
243
+ * Merge curated options (stable order / aliases) with live rows for one provider.
118
244
  * Same ids keep curated position but prefer live display names.
119
245
  * @param {ModelOption[]} curated
120
246
  * @param {ModelOption[]} live
@@ -149,13 +275,16 @@ function mergeModelLists(curated, live) {
149
275
  }
150
276
 
151
277
  /**
278
+ * Keep selected only when it exists in the provider model catalog.
152
279
  * @param {ModelOption[]} models
153
280
  * @param {string} selected
154
- * @returns {ModelOption[]}
281
+ * @returns {string}
155
282
  */
156
- function ensureSelected(models, selected) {
157
- if (!selected || models.some((m) => m.id === selected)) return models;
158
- return [toOption(selected), ...models];
283
+ export function reconcileModelForProvider(models, selected) {
284
+ if (isNoModel(selected)) return NO_MODEL;
285
+ const id = String(selected).trim();
286
+ if (!isValidModelId(id)) return NO_MODEL;
287
+ return models.some((m) => m.id === id) ? id : NO_MODEL;
159
288
  }
160
289
 
161
290
  /**
@@ -320,47 +449,109 @@ export async function fetchAnthropicModels() {
320
449
  }
321
450
 
322
451
  /**
323
- * List models for the UI.
324
- * @param {{ selected?: string, force?: boolean }} [opts]
452
+ * @param {Response} res
453
+ * @returns {Promise<ModelOption[]>}
454
+ */
455
+ async function parseOpenRouterModelsResponse(res) {
456
+ if (!res.ok) {
457
+ throw new Error(`OpenRouter Models API HTTP ${res.status}`);
458
+ }
459
+ const body = await res.json();
460
+ const rows = Array.isArray(body?.data) ? body.data : [];
461
+ /** @type {ModelOption[]} */
462
+ const models = [];
463
+ for (const row of rows) {
464
+ const id = typeof row?.id === 'string' ? row.id.trim() : '';
465
+ if (!isValidModelId(id)) continue;
466
+ const display =
467
+ typeof row.name === 'string'
468
+ ? row.name
469
+ : typeof row?.id === 'string'
470
+ ? row.id
471
+ : '';
472
+ models.push(toOption(id, display));
473
+ }
474
+ if (models.length === 0) {
475
+ throw new Error('OpenRouter Models API returned no models');
476
+ }
477
+ return models;
478
+ }
479
+
480
+ /**
481
+ * Fetch live models from OpenRouter (no cache).
482
+ * @returns {Promise<ModelOption[]>}
483
+ */
484
+ export async function fetchOpenRouterModels() {
485
+ const env = envResolver();
486
+ const apiKey = (env.OPENROUTER_API_KEY || '').trim();
487
+ if (!apiKey) {
488
+ throw new Error('No OpenRouter API key for models list');
489
+ }
490
+ const doFetch = fetchImpl || globalThis.fetch;
491
+ if (typeof doFetch !== 'function') {
492
+ throw new Error('fetch is not available');
493
+ }
494
+ const res = await doFetch(OPENROUTER_MODELS_URL, {
495
+ method: 'GET',
496
+ headers: {
497
+ Authorization: `Bearer ${apiKey}`,
498
+ 'HTTP-Referer': 'https://github.com/acdev',
499
+ 'X-Title': 'acdev',
500
+ },
501
+ });
502
+ return parseOpenRouterModelsResponse(res);
503
+ }
504
+
505
+ /**
506
+ * @param {LlmProvider} provider
507
+ * @param {{ selected?: string, force?: boolean }} opts
325
508
  * @returns {Promise<ModelsListResult>}
326
509
  */
327
- export async function listModels(opts = {}) {
510
+ async function listModelsForProvider(provider, opts = {}) {
328
511
  const selectedRaw = opts.selected;
329
- const selected = isValidModelId(selectedRaw)
330
- ? String(selectedRaw).trim()
331
- : DEFAULT_MODEL;
332
512
  const force = opts.force === true;
333
513
  const now = Date.now();
514
+ const cache = cacheByProvider.get(provider);
334
515
 
335
516
  if (!force && cache && cache.expiresAt > now) {
517
+ const selected = reconcileModelForProvider(cache.result.models, selectedRaw);
336
518
  return {
337
519
  ...cache.result,
338
- models: ensureSelected(cache.result.models, selected),
520
+ models: cache.result.models,
339
521
  selected,
522
+ provider,
340
523
  };
341
524
  }
342
525
 
526
+ const curated = curatedModelOptions(provider);
527
+
343
528
  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 };
529
+ const liveRaw =
530
+ provider === 'openrouter'
531
+ ? await fetchOpenRouterModels()
532
+ : await fetchAnthropicModels();
533
+ const live = filterModelsForProvider(liveRaw, provider);
534
+ const models = filterModelsForProvider(mergeModelLists(curated, live), provider);
535
+ const selected = reconcileModelForProvider(models, selectedRaw);
536
+ const source = /** @type {ModelsSource} */ (provider === 'openrouter' ? 'openrouter' : 'anthropic');
537
+ const result = { models, source };
538
+ cacheByProvider.set(provider, { expiresAt: now + CACHE_TTL_MS, result });
539
+ return { ...result, selected, provider };
356
540
  } catch {
357
- const models = ensureSelected(
358
- MODEL_OPTIONS.map((m) => ({ ...m })),
359
- selected
360
- );
541
+ const models = curated;
542
+ const selected = reconcileModelForProvider(models, selectedRaw);
361
543
  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 };
544
+ cacheByProvider.set(provider, { expiresAt: now + 30_000, result });
545
+ return { ...result, selected, provider };
365
546
  }
366
547
  }
548
+
549
+ /**
550
+ * List models for the UI.
551
+ * @param {{ selected?: string, force?: boolean, provider?: LlmProvider }} [opts]
552
+ * @returns {Promise<ModelsListResult>}
553
+ */
554
+ export async function listModels(opts = {}) {
555
+ const provider = isValidLlmProvider(opts.provider) ? opts.provider : 'claude';
556
+ return listModelsForProvider(provider, opts);
557
+ }
@@ -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 } 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,20 @@ 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
+ }
191
199
 
192
200
  /**
193
201
  * Reject enqueue / agent / PR actions when required auth is missing.
194
- * @param {{ needGh?: boolean, needClaude?: boolean }} [opts]
202
+ * @param {{ needGh?: boolean, needLlm?: boolean }} [opts]
195
203
  * @returns {{ status: number, error: string, code: string } | null}
196
204
  */
197
205
  function authGate(opts = {}) {
198
206
  const needGh = opts.needGh !== false;
199
- const needClaude = opts.needClaude === true && !useStubAgent;
207
+ const needLlm = opts.needLlm !== false && !useStubAgent;
200
208
 
201
209
  if (needGh) {
202
210
  const gh = doCheckGhAuth();
@@ -209,16 +217,45 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
209
217
  }
210
218
  }
211
219
 
212
- if (needClaude) {
213
- const claude = doCheckClaudeAuth();
214
- if (!claude.ok) {
220
+ if (needLlm) {
221
+ const model = String(config.model ?? '').trim();
222
+ if (isNoModel(model)) {
215
223
  return {
216
224
  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',
225
+ error: `No model selected. Choose a model in Settings → Configuration (not "${NO_MODEL}") before starting jobs.`,
226
+ code: 'model_required',
227
+ };
228
+ }
229
+ if (!isValidModelId(model)) {
230
+ return {
231
+ status: 400,
232
+ error: 'Invalid model in configuration. Choose a valid model in Settings → Configuration.',
233
+ code: 'model_invalid',
220
234
  };
221
235
  }
236
+
237
+ const provider = currentLlmProvider();
238
+ if (provider === 'openrouter') {
239
+ const openrouter = doCheckOpenRouterAuth();
240
+ if (!openrouter.ok) {
241
+ return {
242
+ status: 400,
243
+ error:
244
+ 'OpenRouter is not authenticated. Add an API key in Settings → Authentication, or start with --stub-agent.',
245
+ code: 'openrouter_auth_required',
246
+ };
247
+ }
248
+ } else {
249
+ const claude = doCheckClaudeAuth();
250
+ if (!claude.ok) {
251
+ return {
252
+ status: 400,
253
+ error:
254
+ 'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, run claude auth login, or start with --stub-agent.',
255
+ code: 'claude_auth_required',
256
+ };
257
+ }
258
+ }
222
259
  }
223
260
 
224
261
  return null;
@@ -230,6 +267,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
230
267
  stubAgent: useStubAgent,
231
268
  ghAuth: doCheckGhAuth(),
232
269
  claudeAuth: doCheckClaudeAuth(),
270
+ openrouterAuth: doCheckOpenRouterAuth(),
233
271
  });
234
272
  }
235
273
 
@@ -271,7 +309,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
271
309
  let job = store.getJob(jobId);
272
310
  if (!job) return;
273
311
 
274
- const gate = authGate({ needGh: true, needClaude: true });
312
+ const gate = authGate({ needGh: true, needLlm: true });
275
313
  if (gate) {
276
314
  appendLog(job, 'error', gate.error);
277
315
  store.updateJob(jobId, { status: 'failed', error: gate.error });
@@ -410,7 +448,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
410
448
  let job = store.getJob(jobId);
411
449
  if (!job || job.status !== 'applying_feedback') return;
412
450
 
413
- const gate = authGate({ needGh: false, needClaude: true });
451
+ const gate = authGate({ needGh: false, needLlm: true });
414
452
  if (gate) {
415
453
  appendLog(job, 'error', gate.error);
416
454
  store.updateJob(jobId, {
@@ -549,7 +587,11 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
549
587
  req.query.refresh === '1' ||
550
588
  req.query.refresh === 'true' ||
551
589
  req.query.force === '1';
552
- const result = await listModels({ selected: config.model, force });
590
+ const provider =
591
+ req.query.provider === 'openrouter' || req.query.provider === 'claude'
592
+ ? req.query.provider
593
+ : currentLlmProvider();
594
+ const result = await listModels({ selected: config.model, force, provider });
553
595
  res.json(result);
554
596
  } catch (err) {
555
597
  res.status(500).json({ error: err.message });
@@ -573,6 +615,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
573
615
  applySecretField(envPatch, 'GH_TOKEN', patch.ghToken);
574
616
  applySecretField(envPatch, 'ANTHROPIC_API_KEY', patch.anthropicApiKey);
575
617
  applySecretField(envPatch, 'CLAUDE_CODE_OAUTH_TOKEN', patch.claudeOauthToken);
618
+ applySecretField(envPatch, 'OPENROUTER_API_KEY', patch.openrouterApiKey);
576
619
  if (patch.jiraBaseUrl !== undefined && typeof patch.jiraBaseUrl === 'string') {
577
620
  // Also mirror base URL into env for convenience when set via Settings
578
621
  const trimmed = patch.jiraBaseUrl.trim();
@@ -590,6 +633,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
590
633
  ghToken: _gh,
591
634
  anthropicApiKey: _ak,
592
635
  claudeOauthToken: _oa,
636
+ openrouterApiKey: _or,
593
637
  ...configPatch
594
638
  } = patch;
595
639
  updateConfig(repoRoot, config, configPatch);
@@ -623,7 +667,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
623
667
 
624
668
  app.post('/api/issues', (req, res) => {
625
669
  try {
626
- const gate = authGate({ needGh: true, needClaude: true });
670
+ const gate = authGate({ needGh: true, needLlm: true });
627
671
  if (gate) {
628
672
  return res.status(gate.status).json({ error: gate.error, code: gate.code });
629
673
  }
@@ -803,7 +847,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
803
847
 
804
848
  app.post('/api/jobs/:id/review', (req, res) => {
805
849
  try {
806
- const gate = authGate({ needGh: false, needClaude: true });
850
+ const gate = authGate({ needGh: false, needLlm: true });
807
851
  if (gate) {
808
852
  return res.status(gate.status).json({ error: gate.error, code: gate.code });
809
853
  }
@@ -853,7 +897,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
853
897
 
854
898
  app.post('/api/jobs/:id/approve', async (req, res) => {
855
899
  try {
856
- const gate = authGate({ needGh: true, needClaude: false });
900
+ const gate = authGate({ needGh: true, needLlm: false });
857
901
  if (gate) {
858
902
  return res.status(gate.status).json({ error: gate.error, code: gate.code });
859
903
  }
@@ -1036,7 +1080,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
1036
1080
 
1037
1081
  app.post('/api/jobs/:id/retry', (req, res) => {
1038
1082
  try {
1039
- const gate = authGate({ needGh: true, needClaude: true });
1083
+ const gate = authGate({ needGh: true, needLlm: true });
1040
1084
  if (gate) {
1041
1085
  return res.status(gate.status).json({ error: gate.error, code: gate.code });
1042
1086
  }