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/agent.js CHANGED
@@ -2,6 +2,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { promisify } from 'node:util';
4
4
  import { ensureNoAiAttributionSettings, getIssueTitle } from './git.js';
5
+ import { isValidLlmProvider } from './models.js';
5
6
  import { extractUsageFromResult } from './usage.js';
6
7
 
7
8
  const execFileAsync = promisify(execFile);
@@ -396,6 +397,61 @@ async function fetchIssueTitle(issueUrl, worktreePath) {
396
397
  */
397
398
  export const DISALLOWED_AGENT_TOOLS = ['Task', 'TaskOutput', 'AskUserQuestion'];
398
399
 
400
+ const OPENROUTER_ANTHROPIC_BASE_URL = 'https://openrouter.ai/api';
401
+
402
+ /**
403
+ * Run an agent query with provider-specific Anthropic env overrides.
404
+ * OpenRouter uses Claude Agent SDK via Anthropic-compatible "skin" routing.
405
+ * @template T
406
+ * @param {object} config
407
+ * @param {() => Promise<T>} fn
408
+ * @returns {Promise<T>}
409
+ */
410
+ export async function withLlmProviderEnv(config, fn) {
411
+ const provider = isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude';
412
+ if (provider !== 'openrouter') {
413
+ return fn();
414
+ }
415
+
416
+ const apiKey = (process.env.OPENROUTER_API_KEY || '').trim();
417
+ if (!apiKey) {
418
+ throw new Error(
419
+ 'OpenRouter is not authenticated. Add OPENROUTER_API_KEY in Settings → Authentication.'
420
+ );
421
+ }
422
+
423
+ /** @type {Record<string, string | undefined>} */
424
+ const saved = {
425
+ ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
426
+ ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
427
+ ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
428
+ HTTP_REFERER: process.env.HTTP_REFERER,
429
+ X_TITLE: process.env.X_TITLE,
430
+ };
431
+
432
+ process.env.ANTHROPIC_BASE_URL = OPENROUTER_ANTHROPIC_BASE_URL;
433
+ process.env.ANTHROPIC_AUTH_TOKEN = apiKey;
434
+ process.env.ANTHROPIC_API_KEY = '';
435
+ if (!process.env.HTTP_REFERER) {
436
+ process.env.HTTP_REFERER = 'https://github.com/acdev';
437
+ }
438
+ if (!process.env.X_TITLE) {
439
+ process.env.X_TITLE = 'acdev';
440
+ }
441
+
442
+ try {
443
+ return await fn();
444
+ } finally {
445
+ for (const [key, value] of Object.entries(saved)) {
446
+ if (value === undefined) {
447
+ delete process.env[key];
448
+ } else {
449
+ process.env[key] = value;
450
+ }
451
+ }
452
+ }
453
+ }
454
+
399
455
  /**
400
456
  * Prefer the first successful SDK result. A known SDK bug can emit a follow-up
401
457
  * `error_during_execution` right after success when async subagents finish.
@@ -422,76 +478,78 @@ export function mergeAgentResult(current, incoming) {
422
478
  * }} params
423
479
  */
424
480
  async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn = query }) {
425
- const timeoutMs = config.agentTimeoutMs ?? 900_000;
426
- let lastMessage = null;
427
- let resultMessage = null;
428
- const abortController = new AbortController();
429
-
430
- // Claude Agent SDK defaults to injecting Co-Authored-By trailers into commit
431
- // instructions unless attribution is cleared via Claude settings.
432
- ensureNoAiAttributionSettings(worktreePath);
433
-
434
- const runLoop = async () => {
435
- try {
436
- for await (const message of queryFn({
437
- // String prompt = single-turn SDK input (still uses stream-json transport).
438
- prompt,
439
- options: {
440
- cwd: worktreePath,
441
- allowedTools: config.allowedTools,
442
- disallowedTools: DISALLOWED_AGENT_TOOLS,
443
- permissionMode: 'acceptEdits',
444
- maxTurns: config.maxAgentTurns,
445
- model: config.model || 'claude-sonnet-5',
446
- // Load only local settings so our empty attribution wins without
447
- // pulling in unrelated user settings.
448
- settingSources: ['local'],
449
- abortController,
450
- },
451
- })) {
452
- onEvent(message);
453
- lastMessage = message;
454
- if (message?.type === 'result') {
455
- resultMessage = mergeAgentResult(resultMessage, message);
456
- // First success is authoritative — stop before SDK async-task teardown
457
- // can overwrite it with a spurious streaming-mode error.
458
- if (message.subtype === 'success') {
459
- abortController.abort();
460
- break;
481
+ return withLlmProviderEnv(config, async () => {
482
+ const timeoutMs = config.agentTimeoutMs ?? 900_000;
483
+ let lastMessage = null;
484
+ let resultMessage = null;
485
+ const abortController = new AbortController();
486
+
487
+ // Claude Agent SDK defaults to injecting Co-Authored-By trailers into commit
488
+ // instructions unless attribution is cleared via Claude settings.
489
+ ensureNoAiAttributionSettings(worktreePath);
490
+
491
+ const runLoop = async () => {
492
+ try {
493
+ for await (const message of queryFn({
494
+ // String prompt = single-turn SDK input (still uses stream-json transport).
495
+ prompt,
496
+ options: {
497
+ cwd: worktreePath,
498
+ allowedTools: config.allowedTools,
499
+ disallowedTools: DISALLOWED_AGENT_TOOLS,
500
+ permissionMode: 'acceptEdits',
501
+ maxTurns: config.maxAgentTurns,
502
+ model: config.model || 'claude-sonnet-5',
503
+ // Load only local settings so our empty attribution wins without
504
+ // pulling in unrelated user settings.
505
+ settingSources: ['local'],
506
+ abortController,
507
+ },
508
+ })) {
509
+ onEvent(message);
510
+ lastMessage = message;
511
+ if (message?.type === 'result') {
512
+ resultMessage = mergeAgentResult(resultMessage, message);
513
+ // First success is authoritative stop before SDK async-task teardown
514
+ // can overwrite it with a spurious streaming-mode error.
515
+ if (message.subtype === 'success') {
516
+ abortController.abort();
517
+ break;
518
+ }
461
519
  }
462
520
  }
521
+ } catch (err) {
522
+ // Aborting after success can surface as an iterator/abort error.
523
+ if (resultMessage?.subtype === 'success') return;
524
+ throw err;
463
525
  }
464
- } catch (err) {
465
- // Aborting after success can surface as an iterator/abort error.
466
- if (resultMessage?.subtype === 'success') return;
467
- throw err;
468
- }
469
- };
526
+ };
470
527
 
471
- let timeoutId;
472
- try {
473
- await Promise.race([
474
- runLoop(),
475
- new Promise((_, reject) => {
476
- timeoutId = setTimeout(() => {
477
- abortController.abort();
478
- reject(new Error(`Agent timed out after ${timeoutMs}ms`));
479
- }, timeoutMs);
480
- }),
481
- ]);
482
- } finally {
483
- if (timeoutId) clearTimeout(timeoutId);
484
- }
528
+ let timeoutId;
529
+ try {
530
+ await Promise.race([
531
+ runLoop(),
532
+ new Promise((_, reject) => {
533
+ timeoutId = setTimeout(() => {
534
+ abortController.abort();
535
+ reject(new Error(`Agent timed out after ${timeoutMs}ms`));
536
+ }, timeoutMs);
537
+ }),
538
+ ]);
539
+ } finally {
540
+ if (timeoutId) clearTimeout(timeoutId);
541
+ }
485
542
 
486
- if (!resultMessage || resultMessage.subtype !== 'success') {
487
- const detail = lastMessage ? JSON.stringify(lastMessage) : 'no result received';
488
- throw new Error(`Agent did not complete successfully: ${detail}`);
489
- }
543
+ if (!resultMessage || resultMessage.subtype !== 'success') {
544
+ const detail = lastMessage ? JSON.stringify(lastMessage) : 'no result received';
545
+ throw new Error(`Agent did not complete successfully: ${detail}`);
546
+ }
490
547
 
491
- const resultText = resultMessage.result ?? '';
492
- const meta = extractPrMetadata(resultText);
493
- const usage = extractUsageFromResult(resultMessage);
494
- return { resultText, meta, usage };
548
+ const resultText = resultMessage.result ?? '';
549
+ const meta = extractPrMetadata(resultText);
550
+ const usage = extractUsageFromResult(resultMessage);
551
+ return { resultText, meta, usage };
552
+ });
495
553
  }
496
554
 
497
555
  function stubAgentResult(onEvent, title, body) {
package/src/config.js CHANGED
@@ -6,12 +6,20 @@ import { checkGhAuth, githubTokenFromEnv, originRemoteInfo } from './gh-auth.js'
6
6
  import { normalizeJiraBaseUrl } from './jira.js';
7
7
  import {
8
8
  DEFAULT_MODEL,
9
- MODEL_OPTIONS,
9
+ DEFAULT_OPENROUTER_MODEL,
10
+ LLM_PROVIDERS,
11
+ NO_MODEL,
10
12
  isValidModelId,
13
+ isNoModel,
14
+ isValidLlmProvider,
15
+ defaultModelForProvider,
16
+ isModelIdForProvider,
17
+ curatedModelOptions,
11
18
  } from './models.js';
19
+ import { checkOpenRouterAuth } from './openrouter-auth.js';
12
20
  import { dataDir } from './paths.js';
13
21
 
14
- export { DEFAULT_MODEL, MODEL_OPTIONS } from './models.js';
22
+ export { DEFAULT_MODEL, DEFAULT_OPENROUTER_MODEL, CLAUDE_MODEL_OPTIONS, MODEL_OPTIONS, OPENROUTER_MODEL_OPTIONS, LLM_PROVIDERS, NO_MODEL } from './models.js';
15
23
 
16
24
  /** Tool names the agent may be granted via config. */
17
25
  export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
@@ -61,7 +69,10 @@ const DEFAULTS = {
61
69
  maxAgentTurns: 30,
62
70
  allowedTools: [...KNOWN_TOOLS],
63
71
  agentTimeoutMs: 900_000,
72
+ llmProvider: /** @type {'claude' | 'openrouter'} */ ('claude'),
64
73
  model: DEFAULT_MODEL,
74
+ /** Last valid model selection per LLM provider (for restore on provider switch). */
75
+ lastModelsByProvider: /** @type {Record<string, string>} */ ({}),
65
76
  ticketSource: /** @type {'github' | 'jira'} */ ('github'),
66
77
  jiraBaseUrl: '',
67
78
  /** PR body phrase for Jira tickets, e.g. "Relates to PROJ-123". */
@@ -70,6 +81,79 @@ const DEFAULTS = {
70
81
  githubRules: structuredClone(DEFAULT_GITHUB_RULES),
71
82
  };
72
83
 
84
+ /**
85
+ * @param {unknown} raw
86
+ * @returns {Record<string, string>}
87
+ */
88
+ function normalizeLastModelsByProvider(raw) {
89
+ /** @type {Record<string, string>} */
90
+ const out = {};
91
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
92
+ return out;
93
+ }
94
+ for (const provider of LLM_PROVIDERS) {
95
+ const value = /** @type {Record<string, unknown>} */ (raw)[provider];
96
+ if (typeof value !== 'string' || isNoModel(value) || !isValidModelId(value)) {
97
+ continue;
98
+ }
99
+ const id = value.trim();
100
+ if (isModelIdForProvider(id, provider)) {
101
+ out[provider] = id;
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * @param {object} config
109
+ * @param {'claude' | 'openrouter'} provider
110
+ * @param {string} model
111
+ */
112
+ function rememberModelForProvider(config, provider, model) {
113
+ if (!isValidLlmProvider(provider)) return;
114
+ if (isNoModel(model) || !isValidModelId(model)) return;
115
+ const id = String(model).trim();
116
+ if (!isModelIdForProvider(id, provider)) return;
117
+ if (
118
+ !config.lastModelsByProvider ||
119
+ typeof config.lastModelsByProvider !== 'object' ||
120
+ config.lastModelsByProvider === DEFAULTS.lastModelsByProvider
121
+ ) {
122
+ config.lastModelsByProvider = {
123
+ ...(config.lastModelsByProvider &&
124
+ typeof config.lastModelsByProvider === 'object' &&
125
+ config.lastModelsByProvider !== DEFAULTS.lastModelsByProvider
126
+ ? config.lastModelsByProvider
127
+ : {}),
128
+ };
129
+ }
130
+ config.lastModelsByProvider[provider] = id;
131
+ }
132
+
133
+ /**
134
+ * Pick model for a provider: preferred if in catalog, else last stored, else no selection.
135
+ * @param {object} config
136
+ * @param {'claude' | 'openrouter'} provider
137
+ * @param {string} [preferredModel]
138
+ * @returns {string}
139
+ */
140
+ function resolveModelForProvider(config, provider, preferredModel) {
141
+ const candidate =
142
+ preferredModel != null ? preferredModel : config.model;
143
+ if (!isNoModel(candidate) && isModelIdForProvider(candidate, provider)) {
144
+ return String(candidate).trim();
145
+ }
146
+ const stored = config.lastModelsByProvider?.[provider];
147
+ if (
148
+ typeof stored === 'string' &&
149
+ !isNoModel(stored) &&
150
+ isModelIdForProvider(stored, provider)
151
+ ) {
152
+ return stored.trim();
153
+ }
154
+ return NO_MODEL;
155
+ }
156
+
73
157
  /**
74
158
  * @param {string} prefix e.g. jiraRules / githubRules
75
159
  * @param {unknown} raw
@@ -210,7 +294,9 @@ function persistable(config) {
210
294
  maxAgentTurns: config.maxAgentTurns,
211
295
  allowedTools: config.allowedTools,
212
296
  agentTimeoutMs: config.agentTimeoutMs,
297
+ llmProvider: isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude',
213
298
  model: config.model,
299
+ lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
214
300
  ticketSource: config.ticketSource,
215
301
  jiraBaseUrl: config.jiraBaseUrl || '',
216
302
  jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
@@ -250,9 +336,11 @@ export function loadConfig(repoRoot) {
250
336
  const initial = {
251
337
  ...DEFAULTS,
252
338
  allowedTools: [...KNOWN_TOOLS],
339
+ lastModelsByProvider: {},
253
340
  jiraRules: structuredClone(DEFAULT_JIRA_RULES),
254
341
  githubRules: structuredClone(DEFAULT_GITHUB_RULES),
255
342
  };
343
+ rememberModelForProvider(initial, 'claude', initial.model);
256
344
  fs.writeFileSync(configPath, JSON.stringify(persistable(initial), null, 2) + '\n', 'utf8');
257
345
  return initial;
258
346
  }
@@ -262,17 +350,31 @@ export function loadConfig(repoRoot) {
262
350
  ...DEFAULTS,
263
351
  ...raw,
264
352
  allowedTools: raw.allowedTools ?? [...KNOWN_TOOLS],
353
+ lastModelsByProvider: normalizeLastModelsByProvider(raw.lastModelsByProvider),
265
354
  jiraRules: normalizeJiraRules(raw.jiraRules),
266
355
  githubRules: normalizeGithubRules(raw.githubRules),
267
356
  };
268
357
 
269
- if (!isValidModelId(config.model)) {
270
- config.model = DEFAULT_MODEL;
358
+ if (isNoModel(config.model)) {
359
+ if (String(config.model).trim() !== NO_MODEL) {
360
+ config.model = NO_MODEL;
361
+ saveConfig(repoRoot, persistable(config));
362
+ } else {
363
+ config.model = NO_MODEL;
364
+ }
365
+ } else if (!isValidModelId(config.model)) {
366
+ config.model = defaultModelForProvider(
367
+ isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude'
368
+ );
271
369
  saveConfig(repoRoot, persistable(config));
272
370
  } else {
273
371
  config.model = String(config.model).trim();
274
372
  }
275
373
 
374
+ if (!isValidLlmProvider(config.llmProvider)) {
375
+ config.llmProvider = 'claude';
376
+ }
377
+
276
378
  if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
277
379
  config.ticketSource = 'github';
278
380
  }
@@ -285,6 +387,11 @@ export function loadConfig(repoRoot) {
285
387
  config.jiraPrLinkPhrase = 'Relates to';
286
388
  }
287
389
 
390
+ const loadedProvider = isValidLlmProvider(config.llmProvider)
391
+ ? config.llmProvider
392
+ : 'claude';
393
+ rememberModelForProvider(config, loadedProvider, config.model);
394
+
288
395
  return config;
289
396
  }
290
397
 
@@ -297,13 +404,57 @@ export function loadConfig(repoRoot) {
297
404
  * @returns {typeof DEFAULTS}
298
405
  */
299
406
  export function updateConfig(repoRoot, config, patch) {
407
+ if (!config.lastModelsByProvider || typeof config.lastModelsByProvider !== 'object') {
408
+ config.lastModelsByProvider = normalizeLastModelsByProvider(config.lastModelsByProvider);
409
+ }
410
+
411
+ const prevProvider = isValidLlmProvider(config.llmProvider)
412
+ ? config.llmProvider
413
+ : 'claude';
414
+ let providerChanged = false;
415
+ const willChangeProvider =
416
+ patch.llmProvider !== undefined &&
417
+ isValidLlmProvider(patch.llmProvider) &&
418
+ patch.llmProvider !== prevProvider;
419
+
420
+ if (willChangeProvider) {
421
+ rememberModelForProvider(config, prevProvider, config.model);
422
+ }
423
+
300
424
  if (patch.model != null) {
301
- if (!isValidModelId(patch.model)) {
425
+ if (isNoModel(patch.model)) {
426
+ config.model = NO_MODEL;
427
+ } else if (!isValidModelId(patch.model)) {
302
428
  throw new Error(
303
- `Invalid model "${patch.model}". Expected a non-empty Claude model id`
429
+ `Invalid model "${patch.model}". Expected a non-empty model id or "${NO_MODEL}" for no selection`
430
+ );
431
+ } else {
432
+ config.model = String(patch.model).trim();
433
+ }
434
+ }
435
+
436
+ if (patch.llmProvider !== undefined) {
437
+ if (!isValidLlmProvider(patch.llmProvider)) {
438
+ throw new Error(
439
+ `Invalid llmProvider "${patch.llmProvider}". Allowed: ${LLM_PROVIDERS.join(', ')}`
304
440
  );
305
441
  }
306
- config.model = String(patch.model).trim();
442
+ providerChanged = willChangeProvider;
443
+ config.llmProvider = patch.llmProvider;
444
+ }
445
+
446
+ const currentProvider = isValidLlmProvider(config.llmProvider)
447
+ ? config.llmProvider
448
+ : 'claude';
449
+
450
+ if (providerChanged) {
451
+ config.model = resolveModelForProvider(config, currentProvider, config.model);
452
+ }
453
+
454
+ if (patch.model != null && !providerChanged) {
455
+ rememberModelForProvider(config, currentProvider, config.model);
456
+ } else if (providerChanged && !isNoModel(config.model)) {
457
+ rememberModelForProvider(config, currentProvider, config.model);
307
458
  }
308
459
 
309
460
  if (patch.baseBranch !== undefined) {
@@ -394,12 +545,18 @@ export function updateConfig(repoRoot, config, patch) {
394
545
  * stubAgent?: boolean,
395
546
  * ghAuth?: import('./gh-auth.js').GhAuthResult,
396
547
  * claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
548
+ * openrouterAuth?: import('./openrouter-auth.js').OpenRouterAuthResult,
397
549
  * }} [opts]
398
550
  */
399
551
  export function publicConfig(config, opts = {}) {
400
- const model = isValidModelId(config.model)
401
- ? String(config.model).trim()
402
- : DEFAULT_MODEL;
552
+ const llmProvider = isValidLlmProvider(config.llmProvider)
553
+ ? config.llmProvider
554
+ : 'claude';
555
+ const model = isNoModel(config.model)
556
+ ? NO_MODEL
557
+ : isValidModelId(config.model)
558
+ ? String(config.model).trim()
559
+ : defaultModelForProvider(llmProvider);
403
560
  const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
404
561
  const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
405
562
  ? config.ticketSource
@@ -424,13 +581,19 @@ export function publicConfig(config, opts = {}) {
424
581
 
425
582
  const ghAuth = opts.ghAuth ?? checkGhAuth();
426
583
  const claudeAuth = opts.claudeAuth ?? checkClaudeAuth();
584
+ const openrouterAuth = opts.openrouterAuth ?? checkOpenRouterAuth();
427
585
  const origin = originRemoteInfo(opts.repoRoot);
428
586
  const ghTokenMask = maskSecret(githubTokenFromEnv());
429
587
  const anthropicMask = maskSecret(process.env.ANTHROPIC_API_KEY);
430
588
  const claudeOauthMask = maskSecret(process.env.CLAUDE_CODE_OAUTH_TOKEN);
589
+ const openrouterMask = maskSecret(process.env.OPENROUTER_API_KEY);
590
+
591
+ const curatedModels = curatedModelOptions(llmProvider);
431
592
 
432
593
  return {
594
+ llmProvider,
433
595
  model,
596
+ lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
434
597
  baseBranch: config.baseBranch,
435
598
  testCommand: config.testCommand ?? null,
436
599
  maxAgentTurns: config.maxAgentTurns,
@@ -438,7 +601,7 @@ export function publicConfig(config, opts = {}) {
438
601
  allowedTools: Array.isArray(config.allowedTools)
439
602
  ? [...config.allowedTools]
440
603
  : [...KNOWN_TOOLS],
441
- models: MODEL_OPTIONS,
604
+ models: curatedModels,
442
605
  knownTools: KNOWN_TOOLS,
443
606
  ticketSource,
444
607
  jiraBaseUrl,
@@ -462,6 +625,13 @@ export function publicConfig(config, opts = {}) {
462
625
  anthropicApiKeyMasked: anthropicMask.masked,
463
626
  claudeOauthTokenSet: claudeOauthMask.set,
464
627
  claudeOauthTokenMasked: claudeOauthMask.masked,
628
+ openrouterAuthOk: openrouterAuth.ok === true,
629
+ openrouterApiKeySet: openrouterMask.set,
630
+ openrouterApiKeyMasked: openrouterMask.masked,
631
+ llmAuthOk:
632
+ llmProvider === 'openrouter'
633
+ ? openrouterAuth.ok === true
634
+ : claudeAuth.ok === true,
465
635
  stubAgent: opts.stubAgent === true,
466
636
  ...(repoName ? { repoName } : {}),
467
637
  };