acdev 1.0.6 → 1.0.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "acdev",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -129,6 +129,73 @@ let settingsSaving = false;
129
129
  let settingsTab = 'ticket';
130
130
  /** Sentinel: no model selected (jobs blocked until user picks one). */
131
131
  const NO_MODEL = '-';
132
+ const OPENROUTER_AGENT_MODEL_PREFIX = 'anthropic/';
133
+ /** @type {string} */
134
+ let modelSourceHintBase = '';
135
+ /**
136
+ * @param {string} modelId
137
+ * @returns {boolean}
138
+ */
139
+ function isOpenRouterAgentCompatibleModel(modelId) {
140
+ if (!modelId || !String(modelId).includes('/')) return false;
141
+ return String(modelId).trim().toLowerCase().startsWith(OPENROUTER_AGENT_MODEL_PREFIX);
142
+ }
143
+
144
+ /**
145
+ * @param {HTMLElement} btn
146
+ * @param {{ id: string, agentCompatible?: boolean }} model
147
+ */
148
+ function appendModelCompatibilityBadge(btn, model) {
149
+ if (currentLlmProviderSelection() !== 'openrouter') return;
150
+ const compatible =
151
+ model.agentCompatible ?? isOpenRouterAgentCompatibleModel(model.id);
152
+ const badge = document.createElement('span');
153
+ badge.className = compatible
154
+ ? 'model-picker-badge model-picker-badge--agent'
155
+ : 'model-picker-badge model-picker-badge--reference';
156
+ badge.textContent = compatible ? 'Agent' : 'May not work';
157
+ badge.title = compatible
158
+ ? 'Supported for acdev agent jobs (anthropic/* via Claude Agent SDK)'
159
+ : 'Listed for reference; agent jobs require anthropic/* models';
160
+ btn.appendChild(badge);
161
+ }
162
+
163
+ /**
164
+ * @param {HTMLElement} btn
165
+ * @param {{ id: string, label?: string, name?: string, agentCompatible?: boolean }} model
166
+ */
167
+ function populateModelPickerOption(btn, model) {
168
+ const titleRow = document.createElement('span');
169
+ titleRow.className = 'model-picker-option-title-row';
170
+
171
+ const title = document.createElement('span');
172
+ title.className = 'model-picker-option-title';
173
+ title.textContent = modelPickerLabel(model);
174
+ titleRow.appendChild(title);
175
+ appendModelCompatibilityBadge(titleRow, model);
176
+ btn.appendChild(titleRow);
177
+
178
+ if (model.id !== modelPickerLabel(model)) {
179
+ const sub = document.createElement('span');
180
+ sub.className = 'model-picker-option-id';
181
+ sub.textContent = model.id;
182
+ btn.appendChild(sub);
183
+ }
184
+ }
185
+
186
+ function refreshModelSourceHint() {
187
+ if (!els.settingsModelHint) return;
188
+ const provider = currentLlmProviderSelection();
189
+ let text = modelSourceHintBase;
190
+ if (
191
+ provider === 'openrouter' &&
192
+ !isNoModelSelection(currentModel) &&
193
+ !isOpenRouterAgentCompatibleModel(currentModel)
194
+ ) {
195
+ text += ` Selected model "${currentModel}" may not work for agent jobs — choose an anthropic/* model to run jobs.`;
196
+ }
197
+ els.settingsModelHint.textContent = text;
198
+ }
132
199
  /** @type {string} */
133
200
  let currentModel = 'claude-sonnet-5';
134
201
  /** @type {Array<{id:string,label?:string,name?:string}>} */
@@ -3012,6 +3079,7 @@ function updateModelLabel() {
3012
3079
  els.modelCombined.title = combined;
3013
3080
  }
3014
3081
  syncAgentPickerDisplay();
3082
+ refreshModelSourceHint();
3015
3083
  }
3016
3084
 
3017
3085
  /**
@@ -3019,8 +3087,10 @@ function updateModelLabel() {
3019
3087
  * @returns {Array<{id:string,label?:string,name?:string}>}
3020
3088
  */
3021
3089
  function getAvailableModels(provider = currentLlmProviderSelection()) {
3022
- if (availableModelsProvider === provider) return availableModels;
3023
- return [];
3090
+ if (availableModelsProvider === provider && availableModels.length) {
3091
+ return availableModels;
3092
+ }
3093
+ return defaultModelsForProvider(provider);
3024
3094
  }
3025
3095
 
3026
3096
  /**
@@ -3281,16 +3351,7 @@ function renderModelComboboxList(instance, selectedId = currentModel) {
3281
3351
  btn.classList.add('is-active');
3282
3352
  }
3283
3353
 
3284
- const title = document.createElement('span');
3285
- title.textContent = modelPickerLabel(m);
3286
- btn.appendChild(title);
3287
-
3288
- if (m.id !== modelPickerLabel(m)) {
3289
- const sub = document.createElement('span');
3290
- sub.className = 'model-picker-option-id';
3291
- sub.textContent = m.id;
3292
- btn.appendChild(sub);
3293
- }
3354
+ populateModelPickerOption(btn, m);
3294
3355
 
3295
3356
  btn.addEventListener('click', () => {
3296
3357
  instance.onSelect(m.id);
@@ -3477,16 +3538,7 @@ function renderAgentPickerModelList(instance, selectedId = currentModel) {
3477
3538
  btn.classList.add('is-active');
3478
3539
  }
3479
3540
 
3480
- const title = document.createElement('span');
3481
- title.textContent = modelPickerLabel(m);
3482
- btn.appendChild(title);
3483
-
3484
- if (m.id !== modelPickerLabel(m)) {
3485
- const sub = document.createElement('span');
3486
- sub.className = 'model-picker-option-id';
3487
- sub.textContent = m.id;
3488
- btn.appendChild(sub);
3489
- }
3541
+ populateModelPickerOption(btn, m);
3490
3542
 
3491
3543
  btn.addEventListener('click', () => {
3492
3544
  instance.onSelect(m.id);
@@ -3637,10 +3689,16 @@ function lastModelForProvider(provider) {
3637
3689
  function defaultModelsForProvider(provider = 'claude') {
3638
3690
  if (provider === 'openrouter') {
3639
3691
  return [
3640
- { id: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (Anthropic)' },
3641
- { id: 'anthropic/claude-opus-4', label: 'Claude Opus 4 (Anthropic)' },
3642
- { id: 'openai/gpt-4o', label: 'GPT-4o (OpenAI)' },
3643
- { id: 'x-ai/grok-2', label: 'Grok 2 (xAI)' },
3692
+ { id: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (Anthropic)', agentCompatible: true },
3693
+ { id: 'anthropic/claude-opus-4', label: 'Claude Opus 4 (Anthropic)', agentCompatible: true },
3694
+ { id: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet (Anthropic)', agentCompatible: true },
3695
+ { id: 'anthropic/claude-3.7-sonnet', label: 'Claude 3.7 Sonnet (Anthropic)', agentCompatible: true },
3696
+ { id: 'openai/gpt-4o', label: 'GPT-4o (OpenAI)', agentCompatible: false },
3697
+ { id: 'openai/gpt-4o-mini', label: 'GPT-4o Mini (OpenAI)', agentCompatible: false },
3698
+ { id: 'google/gemini-2.5-pro-preview', label: 'Gemini 2.5 Pro Preview (Google)', agentCompatible: false },
3699
+ { id: 'qwen/qwen3-235b-a22b', label: 'Qwen3 235B (Qwen)', agentCompatible: false },
3700
+ { id: 'deepseek/deepseek-chat-v3-0324', label: 'DeepSeek Chat V3 (DeepSeek)', agentCompatible: false },
3701
+ { id: 'meta-llama/llama-3.3-70b-instruct', label: 'Llama 3.3 70B Instruct (Meta)', agentCompatible: false },
3644
3702
  ];
3645
3703
  }
3646
3704
  return [
@@ -3661,22 +3719,23 @@ function updateModelSourceHint(source, provider = 'claude') {
3661
3719
  if (!els.settingsModelHint) return;
3662
3720
  const name = provider === 'openrouter' ? 'OpenRouter' : 'Claude';
3663
3721
  if (source === 'anthropic') {
3664
- els.settingsModelHint.textContent =
3722
+ modelSourceHintBase =
3665
3723
  `${name} model for agent runs. List loaded from Anthropic Models API.`;
3666
3724
  } else if (source === 'openrouter') {
3667
- els.settingsModelHint.textContent =
3668
- 'OpenRouter model for agent runs. List loaded from OpenRouter Models API.';
3725
+ modelSourceHintBase =
3726
+ 'OpenRouter models for agent runs. Full catalog from OpenRouter Models API. Agent jobs require anthropic/* models (marked “Agent” in the list).';
3669
3727
  } else if (source === 'fallback') {
3670
3728
  if (provider === 'openrouter') {
3671
- els.settingsModelHint.textContent =
3672
- 'OpenRouter model for agent runs. Showing curated models (live list unavailable — set OPENROUTER_API_KEY in Authentication).';
3729
+ modelSourceHintBase =
3730
+ 'OpenRouter models for agent runs. Showing curated catalog (live list unavailable — set OPENROUTER_API_KEY in Authentication). Agent jobs require anthropic/* models.';
3673
3731
  } else {
3674
- els.settingsModelHint.textContent =
3732
+ modelSourceHintBase =
3675
3733
  'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
3676
3734
  }
3677
3735
  } else {
3678
- els.settingsModelHint.textContent = `${name} model for agent runs.`;
3736
+ modelSourceHintBase = `${name} model for agent runs.`;
3679
3737
  }
3738
+ refreshModelSourceHint();
3680
3739
  }
3681
3740
 
3682
3741
  /**
@@ -3722,7 +3781,7 @@ function syncLlmProviderSelects(provider) {
3722
3781
  if (els.settingsLlmProviderHint) {
3723
3782
  els.settingsLlmProviderHint.textContent =
3724
3783
  value === 'openrouter'
3725
- ? 'Agent runs route through OpenRouter (requires OPENROUTER_API_KEY in Authentication).'
3784
+ ? 'Agent runs route through OpenRouter (requires OPENROUTER_API_KEY). All models are listed; agent jobs need anthropic/* models.'
3726
3785
  : 'Agent runs use Claude via Anthropic API, OAuth token, or claude auth login.';
3727
3786
  }
3728
3787
  }
@@ -3733,13 +3792,16 @@ function syncLlmProviderSelects(provider) {
3733
3792
  function populateSettingsModels(data) {
3734
3793
  const provider = data?.provider || currentLlmProviderSelection();
3735
3794
  const cached = getAvailableModels(provider);
3736
- const models = Array.isArray(data?.models)
3795
+ let models = Array.isArray(data?.models)
3737
3796
  ? data.models
3738
3797
  : data?.reconcileProvider
3739
3798
  ? []
3740
3799
  : cached.length
3741
3800
  ? cached
3742
3801
  : defaultModelsForProvider(provider);
3802
+ if (!models.length && data?.source) {
3803
+ models = defaultModelsForProvider(provider);
3804
+ }
3743
3805
  setAvailableModels(provider, models);
3744
3806
 
3745
3807
  if (data?.reconcileProvider) {
@@ -3759,10 +3821,9 @@ function populateSettingsModels(data) {
3759
3821
  updateModelLabel();
3760
3822
  }
3761
3823
 
3762
- function modelsForFetchFallback(provider, reconcileProvider) {
3763
- if (reconcileProvider) return [];
3824
+ function modelsForFetchFallback(provider) {
3764
3825
  const cached = getAvailableModels(provider);
3765
- if (cached.length) return cached;
3826
+ if (availableModelsProvider === provider && cached.length) return cached;
3766
3827
  return defaultModelsForProvider(provider);
3767
3828
  }
3768
3829
 
@@ -3780,7 +3841,7 @@ async function fetchModels(opts = {}) {
3780
3841
  const res = await fetch(`/api/models?${query.toString()}`);
3781
3842
  if (!res.ok) {
3782
3843
  populateSettingsModels({
3783
- models: modelsForFetchFallback(provider, reconcileProvider),
3844
+ models: modelsForFetchFallback(provider),
3784
3845
  selected: currentModel,
3785
3846
  provider,
3786
3847
  reconcileProvider,
@@ -3798,7 +3859,7 @@ async function fetchModels(opts = {}) {
3798
3859
  } catch (err) {
3799
3860
  console.error('Failed to fetch models:', err);
3800
3861
  populateSettingsModels({
3801
- models: modelsForFetchFallback(provider, reconcileProvider),
3862
+ models: modelsForFetchFallback(provider),
3802
3863
  selected: currentModel,
3803
3864
  provider,
3804
3865
  reconcileProvider,
@@ -4380,11 +4441,13 @@ function applyConfigSnapshot(data) {
4380
4441
  async function fetchConfig() {
4381
4442
  try {
4382
4443
  const res = await fetch('/api/config');
4383
- if (!res.ok) return;
4444
+ if (!res.ok) return null;
4384
4445
  const data = await readJson(res);
4385
4446
  applyConfigSnapshot(data);
4447
+ return data;
4386
4448
  } catch (err) {
4387
4449
  console.error('Failed to fetch config:', err);
4450
+ return null;
4388
4451
  }
4389
4452
  }
4390
4453
 
@@ -5032,7 +5095,10 @@ els.logJobFilter.addEventListener('change', () => {
5032
5095
  });
5033
5096
 
5034
5097
  applyTheme(loadTheme());
5035
- fetchConfig();
5036
- fetchModels();
5098
+ void (async () => {
5099
+ const cfg = await fetchConfig();
5100
+ const provider = cfg?.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
5101
+ await fetchModels({ provider });
5102
+ })();
5037
5103
  fetchJobs();
5038
5104
  setInterval(fetchJobs, 3000);
package/public/styles.css CHANGED
@@ -2298,6 +2298,40 @@ body.diff-fs-open {
2298
2298
  cursor: pointer;
2299
2299
  }
2300
2300
 
2301
+ .model-picker-option-title-row {
2302
+ display: flex;
2303
+ align-items: center;
2304
+ justify-content: space-between;
2305
+ gap: 8px;
2306
+ }
2307
+
2308
+ .model-picker-option-title {
2309
+ min-width: 0;
2310
+ overflow: hidden;
2311
+ text-overflow: ellipsis;
2312
+ white-space: nowrap;
2313
+ }
2314
+
2315
+ .model-picker-badge {
2316
+ flex-shrink: 0;
2317
+ padding: 1px 6px;
2318
+ border-radius: 999px;
2319
+ font-size: 10px;
2320
+ font-weight: 600;
2321
+ letter-spacing: 0.02em;
2322
+ text-transform: uppercase;
2323
+ }
2324
+
2325
+ .model-picker-badge--agent {
2326
+ color: var(--primary);
2327
+ background: color-mix(in srgb, var(--primary) 14%, transparent);
2328
+ }
2329
+
2330
+ .model-picker-badge--reference {
2331
+ color: var(--text-muted);
2332
+ background: color-mix(in srgb, var(--text-muted) 12%, transparent);
2333
+ }
2334
+
2301
2335
  .model-picker-option:hover,
2302
2336
  .model-picker-option.is-active {
2303
2337
  background: var(--surface-2);
package/src/agent.js CHANGED
@@ -427,11 +427,16 @@ export async function withLlmProviderEnv(config, fn) {
427
427
  ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
428
428
  HTTP_REFERER: process.env.HTTP_REFERER,
429
429
  X_TITLE: process.env.X_TITLE,
430
+ CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS,
430
431
  };
431
432
 
432
433
  process.env.ANTHROPIC_BASE_URL = OPENROUTER_ANTHROPIC_BASE_URL;
433
434
  process.env.ANTHROPIC_AUTH_TOKEN = apiKey;
434
435
  process.env.ANTHROPIC_API_KEY = '';
436
+ // OpenRouter rejects Anthropic-only beta headers on some models.
437
+ if (!process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) {
438
+ process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1';
439
+ }
435
440
  if (!process.env.HTTP_REFERER) {
436
441
  process.env.HTTP_REFERER = 'https://github.com/acdev';
437
442
  }
package/src/models.js CHANGED
@@ -44,17 +44,24 @@ export const MODEL_OPTIONS = CLAUDE_MODEL_OPTIONS;
44
44
 
45
45
  export const DEFAULT_MODEL = 'claude-sonnet-5';
46
46
 
47
- /** Curated OpenRouter model ids used as defaults + fallback. */
47
+ /**
48
+ * OpenRouter slugs commonly used with acdev. Curated list is merged with the
49
+ * live OpenRouter catalog; anthropic/* entries are agent-compatible.
50
+ */
51
+ export const OPENROUTER_AGENT_MODEL_PREFIX = 'anthropic/';
52
+
53
+ /** Curated OpenRouter model ids used as defaults + fallback when the live API is unavailable. */
48
54
  export const OPENROUTER_MODEL_OPTIONS = [
49
55
  { id: 'anthropic/claude-sonnet-4', label: 'Claude Sonnet 4 (Anthropic)' },
50
56
  { id: 'anthropic/claude-opus-4', label: 'Claude Opus 4 (Anthropic)' },
51
57
  { id: 'anthropic/claude-3.5-sonnet', label: 'Claude 3.5 Sonnet (Anthropic)' },
52
58
  { id: 'anthropic/claude-3.7-sonnet', label: 'Claude 3.7 Sonnet (Anthropic)' },
53
- { id: 'openai/gpt-4.1', label: 'GPT-4.1 (OpenAI)' },
54
59
  { 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)' },
60
+ { id: 'openai/gpt-4o-mini', label: 'GPT-4o Mini (OpenAI)' },
61
+ { id: 'google/gemini-2.5-pro-preview', label: 'Gemini 2.5 Pro Preview (Google)' },
62
+ { id: 'qwen/qwen3-235b-a22b', label: 'Qwen3 235B (Qwen)' },
63
+ { id: 'deepseek/deepseek-chat-v3-0324', label: 'DeepSeek Chat V3 (DeepSeek)' },
64
+ { id: 'meta-llama/llama-3.3-70b-instruct', label: 'Llama 3.3 70B Instruct (Meta)' },
58
65
  ];
59
66
 
60
67
  export const DEFAULT_OPENROUTER_MODEL = 'anthropic/claude-sonnet-4';
@@ -74,7 +81,7 @@ const ANTHROPIC_VERSION = '2023-06-01';
74
81
  const CACHE_TTL_MS = 5 * 60_000;
75
82
  const KEYCHAIN_SERVICE = 'Claude Code-credentials';
76
83
 
77
- /** @typedef {{ id: string, name?: string, label?: string }} ModelOption */
84
+ /** @typedef {{ id: string, name?: string, label?: string, agentCompatible?: boolean }} ModelOption */
78
85
  /** @typedef {'anthropic' | 'openrouter' | 'fallback'} ModelsSource */
79
86
  /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource, provider?: LlmProvider }} ModelsListResult */
80
87
 
@@ -191,6 +198,89 @@ export function isOpenRouterCatalogId(id) {
191
198
  return isValidModelId(id) && String(id).includes('/');
192
199
  }
193
200
 
201
+ /**
202
+ * Whether an OpenRouter slug can be used for acdev agent runs.
203
+ * Non-anthropic slugs may appear in OpenRouter's catalog but fail at runtime
204
+ * because the Claude Agent SDK speaks Anthropic Messages API semantics.
205
+ * @param {string} modelId
206
+ * @returns {boolean}
207
+ */
208
+ export function isOpenRouterAgentCompatibleModel(modelId) {
209
+ if (!isOpenRouterCatalogId(modelId)) return false;
210
+ return String(modelId).trim().toLowerCase().startsWith(OPENROUTER_AGENT_MODEL_PREFIX);
211
+ }
212
+
213
+ /**
214
+ * Mark whether each OpenRouter slug is supported for acdev agent jobs.
215
+ * @param {ModelOption} model
216
+ * @returns {ModelOption}
217
+ */
218
+ export function annotateOpenRouterModelCompatibility(model) {
219
+ if (!model?.id) return model;
220
+ return {
221
+ ...model,
222
+ agentCompatible: isOpenRouterAgentCompatibleModel(model.id),
223
+ };
224
+ }
225
+
226
+ /**
227
+ * @param {ModelOption[]} models
228
+ * @returns {ModelOption[]}
229
+ */
230
+ function annotateOpenRouterModelsCompatibility(models) {
231
+ return models.map(annotateOpenRouterModelCompatibility);
232
+ }
233
+
234
+ /**
235
+ * If the live list is empty, fall back to curated options. Listing is not
236
+ * filtered to anthropic/* — runtime enqueue still validates agent compatibility.
237
+ * @param {ModelOption[]} models
238
+ * @param {ModelOption[]} curated
239
+ * @returns {ModelOption[]}
240
+ */
241
+ function ensureOpenRouterModels(models, curated) {
242
+ const list = models.length > 0 ? models : curated;
243
+ return annotateOpenRouterModelsCompatibility(list);
244
+ }
245
+
246
+ /**
247
+ * Improve OpenRouter agent failure messages for the job UI.
248
+ * @param {string} message
249
+ * @param {string} [model]
250
+ * @returns {string}
251
+ */
252
+ export function enhanceOpenRouterAgentError(message, model) {
253
+ const raw = String(message || '').trim();
254
+ if (!raw) return raw;
255
+
256
+ const id = String(model || '').trim();
257
+ const routingFailure =
258
+ /no allowed providers are available/i.test(raw) ||
259
+ /not allowed by provider/i.test(raw) ||
260
+ /model.*not allowed/i.test(raw);
261
+
262
+ if (id && !isOpenRouterAgentCompatibleModel(id)) {
263
+ return [
264
+ `OpenRouter model "${id}" is not supported for acdev agent runs.`,
265
+ 'Agent jobs use the Claude Agent SDK via OpenRouter\'s Anthropic-compatible API.',
266
+ 'Choose an anthropic/* model (e.g. anthropic/claude-sonnet-4).',
267
+ raw !== id ? `Provider error: ${raw}` : '',
268
+ ]
269
+ .filter(Boolean)
270
+ .join(' ');
271
+ }
272
+
273
+ if (routingFailure) {
274
+ return [
275
+ raw,
276
+ 'If you use OpenRouter provider allowlists, clear Settings → Privacy → Providers or add the model\'s upstream provider.',
277
+ 'For acdev, anthropic/* models on OpenRouter are the most reliable choice.',
278
+ ].join(' ');
279
+ }
280
+
281
+ return raw;
282
+ }
283
+
194
284
  /**
195
285
  * Curated fallback list for a provider.
196
286
  * @param {LlmProvider} provider
@@ -531,14 +621,20 @@ async function listModelsForProvider(provider, opts = {}) {
531
621
  ? await fetchOpenRouterModels()
532
622
  : await fetchAnthropicModels();
533
623
  const live = filterModelsForProvider(liveRaw, provider);
534
- const models = filterModelsForProvider(mergeModelLists(curated, live), provider);
624
+ let models = filterModelsForProvider(mergeModelLists(curated, live), provider);
625
+ if (provider === 'openrouter') {
626
+ models = ensureOpenRouterModels(models, curated);
627
+ }
535
628
  const selected = reconcileModelForProvider(models, selectedRaw);
536
629
  const source = /** @type {ModelsSource} */ (provider === 'openrouter' ? 'openrouter' : 'anthropic');
537
630
  const result = { models, source };
538
631
  cacheByProvider.set(provider, { expiresAt: now + CACHE_TTL_MS, result });
539
632
  return { ...result, selected, provider };
540
633
  } catch {
541
- const models = curated;
634
+ let models = curated;
635
+ if (provider === 'openrouter') {
636
+ models = ensureOpenRouterModels(models, curated);
637
+ }
542
638
  const selected = reconcileModelForProvider(models, selectedRaw);
543
639
  const result = { models, source: /** @type {ModelsSource} */ ('fallback') };
544
640
  cacheByProvider.set(provider, { expiresAt: now + 30_000, result });
package/src/server.js CHANGED
@@ -39,7 +39,7 @@ import { usageFromLogs, withJobUsage } from './usage.js';
39
39
  import { checkGhAuth } from './gh-auth.js';
40
40
  import { checkClaudeAuth } from './claude-auth.js';
41
41
  import { checkOpenRouterAuth } from './openrouter-auth.js';
42
- import { isValidLlmProvider, isValidModelId, isNoModel, NO_MODEL } from './models.js';
42
+ import { isValidLlmProvider, isValidModelId, isNoModel, NO_MODEL, isOpenRouterAgentCompatibleModel, enhanceOpenRouterAgentError } from './models.js';
43
43
 
44
44
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
45
45
 
@@ -197,6 +197,14 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
197
197
  return isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude';
198
198
  }
199
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
+ }
207
+
200
208
  /**
201
209
  * Reject enqueue / agent / PR actions when required auth is missing.
202
210
  * @param {{ needGh?: boolean, needLlm?: boolean }} [opts]
@@ -245,6 +253,14 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
245
253
  code: 'openrouter_auth_required',
246
254
  };
247
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
+ }
248
264
  } else {
249
265
  const claude = doCheckClaudeAuth();
250
266
  if (!claude.ok) {
@@ -421,7 +437,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
421
437
  if (usage) patch.usage = usage;
422
438
  setStatus(jobId, 'awaiting_review', patch);
423
439
  } catch (err) {
424
- const message = err instanceof Error ? err.message : String(err);
440
+ const message = formatAgentJobError(err);
425
441
  const current = store.getJob(jobId);
426
442
  if (!current) return;
427
443
  appendLog(current, 'error', message);
@@ -522,7 +538,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
522
538
  if (usage) patch.usage = usage;
523
539
  setStatus(jobId, 'awaiting_review', patch);
524
540
  } catch (err) {
525
- const message = err instanceof Error ? err.message : String(err);
541
+ const message = formatAgentJobError(err);
526
542
  const current = store.getJob(jobId);
527
543
  if (!current) return;
528
544
  appendLog(current, 'error', message);