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/.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 +1166 -124
- package/public/index.html +154 -17
- package/public/styles.css +590 -33
- package/src/agent.js +127 -64
- package/src/config.js +181 -11
- package/src/models.js +296 -39
- package/src/openrouter-auth.js +37 -0
- package/src/server.js +77 -17
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,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
|
-
/**
|
|
44
|
-
|
|
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'
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 {
|
|
341
|
+
* @returns {string}
|
|
155
342
|
*/
|
|
156
|
-
function
|
|
157
|
-
if (
|
|
158
|
-
|
|
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
|
-
*
|
|
324
|
-
* @
|
|
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
|
-
|
|
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:
|
|
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
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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,
|
|
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
|
|
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 (
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
228
|
+
if (needLlm) {
|
|
229
|
+
const model = String(config.model ?? '').trim();
|
|
230
|
+
if (isNoModel(model)) {
|
|
215
231
|
return {
|
|
216
232
|
status: 400,
|
|
217
|
-
error:
|
|
218
|
-
|
|
219
|
-
|
|
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,
|
|
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 =
|
|
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,
|
|
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 =
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
}
|