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/.acdev/.env.example +3 -0
- package/README.md +25 -13
- package/bin/acdev.js +20 -10
- package/package.json +1 -1
- package/public/app.js +1167 -124
- package/public/index.html +154 -17
- package/public/styles.css +590 -33
- package/src/agent.js +122 -64
- package/src/config.js +181 -11
- package/src/models.js +230 -39
- package/src/openrouter-auth.js +37 -0
- package/src/server.js +59 -15
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
|
|
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
|
-
/**
|
|
44
|
-
export const
|
|
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'
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
281
|
+
* @returns {string}
|
|
155
282
|
*/
|
|
156
|
-
function
|
|
157
|
-
if (
|
|
158
|
-
|
|
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
|
-
*
|
|
324
|
-
* @
|
|
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
|
-
|
|
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:
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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 =
|
|
358
|
-
|
|
359
|
-
selected
|
|
360
|
-
);
|
|
541
|
+
const models = curated;
|
|
542
|
+
const selected = reconcileModelForProvider(models, selectedRaw);
|
|
361
543
|
const result = { models, source: /** @type {ModelsSource} */ ('fallback') };
|
|
362
|
-
|
|
363
|
-
|
|
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,
|
|
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
|
|
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 (
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
220
|
+
if (needLlm) {
|
|
221
|
+
const model = String(config.model ?? '').trim();
|
|
222
|
+
if (isNoModel(model)) {
|
|
215
223
|
return {
|
|
216
224
|
status: 400,
|
|
217
|
-
error:
|
|
218
|
-
|
|
219
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
}
|