acdev 1.0.10 → 1.0.11

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/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { checkClaudeAuth, publicClaudeAuthMethod } from './claude-auth.js';
4
+ import { checkOpenRouterAuth } from './openrouter-auth.js';
4
5
  import { maskSecret } from './env.js';
5
6
  import { checkGhAuth, githubTokenFromEnv, originRemoteInfo } from './gh-auth.js';
6
7
  import { normalizeJiraBaseUrl } from './jira.js';
@@ -10,7 +11,10 @@ import {
10
11
  isValidModelId,
11
12
  isNoModel,
12
13
  isClaudeCatalogId,
14
+ isOpenRouterModelId,
15
+ isModelIdForProvider,
13
16
  curatedModelOptions,
17
+ OPENROUTER_MODEL_OPTIONS,
14
18
  } from './models.js';
15
19
  import { dataDir } from './paths.js';
16
20
 
@@ -21,6 +25,9 @@ export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
21
25
 
22
26
  export const TICKET_SOURCES = /** @type {const} */ (['github', 'jira']);
23
27
 
28
+ /** LLM backends. Claude = Agent SDK; OpenRouter = @openrouter/agent. */
29
+ export const LLM_PROVIDERS = /** @type {const} */ (['claude', 'openrouter']);
30
+
24
31
  /**
25
32
  * Shared post-PR actions for Jira and GitHub Issues rules.
26
33
  * - Jira `set_status` / `close_issue`: workflow transitions (close → Done-like status).
@@ -39,6 +46,7 @@ export const GITHUB_AFTER_PR_ACTIONS = AFTER_PR_ACTIONS;
39
46
 
40
47
  const ALLOWED_TOOLS_SET = new Set(KNOWN_TOOLS);
41
48
  const ALLOWED_TICKET_SOURCES = new Set(TICKET_SOURCES);
49
+ const ALLOWED_LLM_PROVIDERS = new Set(LLM_PROVIDERS);
42
50
  const ALLOWED_AFTER_PR_ACTIONS = new Set(AFTER_PR_ACTIONS);
43
51
 
44
52
  /** @typedef {'none' | 'set_status' | 'add_label' | 'close_issue'} AfterPrAction */
@@ -65,6 +73,8 @@ const DEFAULTS = {
65
73
  allowedTools: [...KNOWN_TOOLS],
66
74
  agentTimeoutMs: 900_000,
67
75
  model: DEFAULT_MODEL,
76
+ llmProvider: /** @type {'claude' | 'openrouter'} */ ('claude'),
77
+ lastModelsByProvider: /** @type {{ claude?: string, openrouter?: string }} */ ({}),
68
78
  ticketSource: /** @type {'github' | 'jira'} */ ('github'),
69
79
  jiraBaseUrl: '',
70
80
  /** PR body phrase for Jira tickets, e.g. "Relates to PROJ-123". */
@@ -73,6 +83,55 @@ const DEFAULTS = {
73
83
  githubRules: structuredClone(DEFAULT_GITHUB_RULES),
74
84
  };
75
85
 
86
+ /**
87
+ * @param {unknown} value
88
+ * @returns {'claude' | 'openrouter'}
89
+ */
90
+ export function normalizeLlmProvider(value) {
91
+ return value === 'openrouter' ? 'openrouter' : 'claude';
92
+ }
93
+
94
+ /**
95
+ * @param {unknown} raw
96
+ * @returns {{ claude?: string, openrouter?: string }}
97
+ */
98
+ function normalizeLastModelsByProvider(raw) {
99
+ /** @type {{ claude?: string, openrouter?: string }} */
100
+ const out = {};
101
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
102
+ const obj = /** @type {Record<string, unknown>} */ (raw);
103
+ if (typeof obj.claude === 'string' && isValidModelId(obj.claude.trim()) && isClaudeCatalogId(obj.claude.trim())) {
104
+ out.claude = obj.claude.trim();
105
+ }
106
+ if (
107
+ typeof obj.openrouter === 'string' &&
108
+ isValidModelId(obj.openrouter.trim()) &&
109
+ isOpenRouterModelId(obj.openrouter.trim())
110
+ ) {
111
+ out.openrouter = obj.openrouter.trim();
112
+ }
113
+ return out;
114
+ }
115
+
116
+ function rememberCurrentModel(config) {
117
+ const provider = normalizeLlmProvider(config.llmProvider);
118
+ const prev = normalizeLastModelsByProvider(config.lastModelsByProvider);
119
+ if (!isNoModel(config.model) && isModelIdForProvider(config.model, provider)) {
120
+ prev[provider] = String(config.model).trim();
121
+ }
122
+ config.lastModelsByProvider = prev;
123
+ }
124
+
125
+ function restoreModelForProvider(config, provider) {
126
+ const last = normalizeLastModelsByProvider(config.lastModelsByProvider);
127
+ const stored = last[provider];
128
+ if (stored && isModelIdForProvider(stored, provider)) {
129
+ config.model = stored;
130
+ return;
131
+ }
132
+ config.model = provider === 'openrouter' ? NO_MODEL : DEFAULT_MODEL;
133
+ }
134
+
76
135
  /**
77
136
  * Migrate legacy OpenRouter model ids to a Claude model.
78
137
  * @param {unknown} raw
@@ -240,6 +299,8 @@ function persistable(config) {
240
299
  allowedTools: config.allowedTools,
241
300
  agentTimeoutMs: config.agentTimeoutMs,
242
301
  model: config.model,
302
+ llmProvider: normalizeLlmProvider(config.llmProvider),
303
+ lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
243
304
  ticketSource: config.ticketSource,
244
305
  jiraBaseUrl: config.jiraBaseUrl || '',
245
306
  jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
@@ -297,6 +358,17 @@ export function loadConfig(repoRoot) {
297
358
 
298
359
  let needsSave = false;
299
360
 
361
+ if (raw.llmProvider === 'openrouter' || (isValidModelId(raw.model) && isOpenRouterModelId(String(raw.model)))) {
362
+ config.llmProvider = 'openrouter';
363
+ } else if (raw.llmProvider === 'claude' || raw.llmProvider == null) {
364
+ config.llmProvider = 'claude';
365
+ } else if (!ALLOWED_LLM_PROVIDERS.has(raw.llmProvider)) {
366
+ config.llmProvider = 'claude';
367
+ needsSave = true;
368
+ }
369
+
370
+ config.lastModelsByProvider = normalizeLastModelsByProvider(raw.lastModelsByProvider);
371
+
300
372
  if (isNoModel(config.model)) {
301
373
  if (String(config.model).trim() !== NO_MODEL) {
302
374
  config.model = NO_MODEL;
@@ -305,8 +377,14 @@ export function loadConfig(repoRoot) {
305
377
  config.model = NO_MODEL;
306
378
  }
307
379
  } else if (!isValidModelId(config.model)) {
308
- config.model = DEFAULT_MODEL;
380
+ config.model = config.llmProvider === 'openrouter' ? NO_MODEL : DEFAULT_MODEL;
309
381
  needsSave = true;
382
+ } else if (config.llmProvider === 'openrouter') {
383
+ config.model = String(config.model).trim();
384
+ if (!isOpenRouterModelId(config.model)) {
385
+ restoreModelForProvider(config, 'openrouter');
386
+ needsSave = true;
387
+ }
310
388
  } else {
311
389
  const migrated = migrateLegacyModel(raw, config.model);
312
390
  if (migrated !== String(config.model).trim()) {
@@ -317,9 +395,7 @@ export function loadConfig(repoRoot) {
317
395
  }
318
396
  }
319
397
 
320
- if (raw.llmProvider === 'openrouter' || raw.lastModelsByProvider != null) {
321
- needsSave = true;
322
- }
398
+ rememberCurrentModel(config);
323
399
 
324
400
  if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
325
401
  config.ticketSource = 'github';
@@ -349,6 +425,24 @@ export function loadConfig(repoRoot) {
349
425
  * @returns {typeof DEFAULTS}
350
426
  */
351
427
  export function updateConfig(repoRoot, config, patch) {
428
+ if (patch.llmProvider !== undefined) {
429
+ if (!ALLOWED_LLM_PROVIDERS.has(patch.llmProvider)) {
430
+ throw new Error(
431
+ `Invalid llmProvider "${patch.llmProvider}". Allowed: ${LLM_PROVIDERS.join(', ')}`
432
+ );
433
+ }
434
+ const next = /** @type {'claude' | 'openrouter'} */ (patch.llmProvider);
435
+ if (next !== normalizeLlmProvider(config.llmProvider)) {
436
+ rememberCurrentModel(config);
437
+ config.llmProvider = next;
438
+ if (patch.model == null) {
439
+ restoreModelForProvider(config, next);
440
+ }
441
+ }
442
+ }
443
+
444
+ const provider = normalizeLlmProvider(config.llmProvider);
445
+
352
446
  if (patch.model != null) {
353
447
  if (isNoModel(patch.model)) {
354
448
  config.model = NO_MODEL;
@@ -356,15 +450,19 @@ export function updateConfig(repoRoot, config, patch) {
356
450
  throw new Error(
357
451
  `Invalid model "${patch.model}". Expected a non-empty model id or "${NO_MODEL}" for no selection`
358
452
  );
359
- } else if (!isClaudeCatalogId(String(patch.model).trim())) {
453
+ } else if (!isModelIdForProvider(String(patch.model).trim(), provider)) {
360
454
  throw new Error(
361
- `Invalid model "${patch.model}". Expected a Claude model id (no provider/ prefix)`
455
+ provider === 'openrouter'
456
+ ? `Invalid model "${patch.model}". Expected an OpenRouter model id (provider/model)`
457
+ : `Invalid model "${patch.model}". Expected a Claude model id (no provider/ prefix)`
362
458
  );
363
459
  } else {
364
460
  config.model = String(patch.model).trim();
365
461
  }
366
462
  }
367
463
 
464
+ rememberCurrentModel(config);
465
+
368
466
  if (patch.baseBranch !== undefined) {
369
467
  if (typeof patch.baseBranch !== 'string' || !patch.baseBranch.trim()) {
370
468
  throw new Error('baseBranch must be a non-empty string');
@@ -456,11 +554,14 @@ export function updateConfig(repoRoot, config, patch) {
456
554
  * }} [opts]
457
555
  */
458
556
  export function publicConfig(config, opts = {}) {
557
+ const llmProvider = normalizeLlmProvider(config.llmProvider);
459
558
  const model = isNoModel(config.model)
460
559
  ? NO_MODEL
461
- : isValidModelId(config.model) && isClaudeCatalogId(config.model)
560
+ : isModelIdForProvider(config.model, llmProvider)
462
561
  ? String(config.model).trim()
463
- : DEFAULT_MODEL;
562
+ : llmProvider === 'openrouter'
563
+ ? NO_MODEL
564
+ : DEFAULT_MODEL;
464
565
  const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
465
566
  const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
466
567
  ? config.ticketSource
@@ -489,11 +590,19 @@ export function publicConfig(config, opts = {}) {
489
590
  const ghTokenMask = maskSecret(githubTokenFromEnv());
490
591
  const anthropicMask = maskSecret(process.env.ANTHROPIC_API_KEY);
491
592
  const claudeOauthMask = maskSecret(process.env.CLAUDE_CODE_OAUTH_TOKEN);
593
+ const openrouterMask = maskSecret(process.env.OPENROUTER_API_KEY);
594
+ const openrouterAuth = checkOpenRouterAuth();
595
+ const llmAuthOk = llmProvider === 'openrouter' ? openrouterAuth.ok : claudeAuth.ok === true;
492
596
 
493
- const curatedModels = curatedModelOptions();
597
+ const curatedModels =
598
+ llmProvider === 'openrouter'
599
+ ? OPENROUTER_MODEL_OPTIONS.map((m) => ({ ...m }))
600
+ : curatedModelOptions();
494
601
 
495
602
  return {
496
603
  model,
604
+ llmProvider,
605
+ lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
497
606
  baseBranch: config.baseBranch,
498
607
  testCommand: config.testCommand ?? null,
499
608
  maxAgentTurns: config.maxAgentTurns,
@@ -525,7 +634,10 @@ export function publicConfig(config, opts = {}) {
525
634
  anthropicApiKeyMasked: anthropicMask.masked,
526
635
  claudeOauthTokenSet: claudeOauthMask.set,
527
636
  claudeOauthTokenMasked: claudeOauthMask.masked,
528
- llmAuthOk: claudeAuth.ok === true,
637
+ openrouterApiKeySet: openrouterMask.set,
638
+ openrouterApiKeyMasked: openrouterMask.masked,
639
+ openrouterAuthOk: openrouterAuth.ok === true,
640
+ llmAuthOk,
529
641
  stubAgent: opts.stubAgent === true,
530
642
  ...(repoName ? { repoName } : {}),
531
643
  };
package/src/models.js CHANGED
@@ -41,6 +41,16 @@ export const CLAUDE_MODEL_OPTIONS = [
41
41
  /** @deprecated Use CLAUDE_MODEL_OPTIONS */
42
42
  export const MODEL_OPTIONS = CLAUDE_MODEL_OPTIONS;
43
43
 
44
+ /** Small fallback if OpenRouter's live catalog cannot be fetched. */
45
+ export const OPENROUTER_MODEL_OPTIONS = [
46
+ { id: 'google/gemini-2.5-pro', label: 'Google: Gemini 2.5 Pro' },
47
+ { id: 'google/gemini-2.5-flash', label: 'Google: Gemini 2.5 Flash' },
48
+ { id: 'openai/gpt-4.1', label: 'OpenAI: GPT-4.1' },
49
+ { id: 'openai/gpt-4o', label: 'OpenAI: GPT-4o' },
50
+ { id: 'anthropic/claude-sonnet-4.5', label: 'Anthropic: Claude Sonnet 4.5' },
51
+ { id: 'anthropic/claude-opus-4.5', label: 'Anthropic: Claude Opus 4.5' },
52
+ ];
53
+
44
54
  export const DEFAULT_MODEL = 'claude-sonnet-5';
45
55
 
46
56
  /** Config value meaning no model selected (jobs blocked until user picks one). */
@@ -50,16 +60,19 @@ export const NO_MODEL = '-';
50
60
  export const MODEL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:\/-]{0,127}$/;
51
61
 
52
62
  const ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models';
63
+ const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
53
64
  const ANTHROPIC_VERSION = '2023-06-01';
54
65
  const CACHE_TTL_MS = 5 * 60_000;
55
66
  const KEYCHAIN_SERVICE = 'Claude Code-credentials';
56
67
 
57
68
  /** @typedef {{ id: string, name?: string, label?: string }} ModelOption */
58
- /** @typedef {'anthropic' | 'fallback'} ModelsSource */
59
- /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource }} ModelsListResult */
69
+ /** @typedef {'anthropic' | 'fallback' | 'openrouter' | 'openrouter-fallback'} ModelsSource */
70
+ /** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource, provider: 'claude' | 'openrouter' }} ModelsListResult */
60
71
 
61
- /** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected'> } | null} */
62
- let cache = null;
72
+ /** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected' | 'provider'> } | null} */
73
+ let claudeCache = null;
74
+ /** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected' | 'provider'> } | null} */
75
+ let openrouterCache = null;
63
76
 
64
77
  /** @type {typeof fetch | null} */
65
78
  let fetchImpl = null;
@@ -98,7 +111,8 @@ export function _resetCredentialsTokenResolver() {
98
111
  }
99
112
 
100
113
  export function _resetModelsCache() {
101
- cache = null;
114
+ claudeCache = null;
115
+ openrouterCache = null;
102
116
  }
103
117
 
104
118
  /**
@@ -166,13 +180,25 @@ export function isModelInCuratedCatalog(modelId) {
166
180
  }
167
181
 
168
182
  /**
169
- * Whether a model id is valid for Claude (direct ids without `/`).
183
+ * OpenRouter catalog ids are `provider/model` slugs.
184
+ * @param {string} id
185
+ * @returns {boolean}
186
+ */
187
+ export function isOpenRouterModelId(id) {
188
+ return isValidModelId(id) && String(id).includes('/');
189
+ }
190
+
191
+ /**
192
+ * Whether a model id is valid for the given LLM provider.
170
193
  * @param {string} modelId
194
+ * @param {'claude' | 'openrouter'} [provider]
171
195
  * @returns {boolean}
172
196
  */
173
- export function isModelIdForProvider(modelId) {
197
+ export function isModelIdForProvider(modelId, provider = 'claude') {
174
198
  if (isNoModel(modelId) || !isValidModelId(modelId)) return false;
175
- return isClaudeCatalogId(String(modelId).trim());
199
+ const id = String(modelId).trim();
200
+ if (provider === 'openrouter') return isOpenRouterModelId(id);
201
+ return isClaudeCatalogId(id);
176
202
  }
177
203
 
178
204
  /**
@@ -394,21 +420,59 @@ export async function fetchAnthropicModels() {
394
420
  }
395
421
 
396
422
  /**
397
- * List Claude models for the UI.
423
+ * Fetch live models from OpenRouter (public catalog; key optional).
424
+ * @returns {Promise<ModelOption[]>}
425
+ */
426
+ export async function fetchOpenRouterModels() {
427
+ const doFetch = fetchImpl || globalThis.fetch;
428
+ if (typeof doFetch !== 'function') {
429
+ throw new Error('fetch is not available');
430
+ }
431
+ /** @type {Record<string, string>} */
432
+ const headers = { Accept: 'application/json' };
433
+ const key = String(envResolver().OPENROUTER_API_KEY || '').trim();
434
+ if (key) headers.Authorization = `Bearer ${key}`;
435
+ const res = await doFetch(OPENROUTER_MODELS_URL, { method: 'GET', headers });
436
+ if (!res.ok) {
437
+ throw new Error(`OpenRouter Models API HTTP ${res.status}`);
438
+ }
439
+ const body = await res.json();
440
+ const rows = Array.isArray(body?.data) ? body.data : [];
441
+ /** @type {ModelOption[]} */
442
+ const models = [];
443
+ for (const row of rows) {
444
+ const id = typeof row?.id === 'string' ? row.id.trim() : '';
445
+ if (!isOpenRouterModelId(id)) continue;
446
+ const display =
447
+ typeof row.name === 'string'
448
+ ? row.name
449
+ : typeof row.display_name === 'string'
450
+ ? row.display_name
451
+ : '';
452
+ models.push(toOption(id, display));
453
+ }
454
+ if (models.length === 0) {
455
+ throw new Error('OpenRouter Models API returned no models');
456
+ }
457
+ return models;
458
+ }
459
+
460
+ /**
398
461
  * @param {{ selected?: string, force?: boolean }} [opts]
399
462
  * @returns {Promise<ModelsListResult>}
400
463
  */
401
- export async function listModels(opts = {}) {
464
+ async function listClaudeModels(opts = {}) {
402
465
  const selectedRaw = opts.selected;
403
466
  const force = opts.force === true;
404
467
  const now = Date.now();
405
468
 
406
- if (!force && cache && cache.expiresAt > now) {
407
- const selected = reconcileModelForProvider(cache.result.models, selectedRaw);
469
+ if (!force && claudeCache && claudeCache.expiresAt > now) {
470
+ const selected = reconcileModelForProvider(claudeCache.result.models, selectedRaw);
408
471
  return {
409
- ...cache.result,
410
- models: cache.result.models,
472
+ ...claudeCache.result,
473
+ models: claudeCache.result.models,
411
474
  selected,
475
+ provider: 'claude',
412
476
  };
413
477
  }
414
478
 
@@ -420,13 +484,62 @@ export async function listModels(opts = {}) {
420
484
  const models = filterClaudeModels(mergeModelLists(curated, live));
421
485
  const selected = reconcileModelForProvider(models, selectedRaw);
422
486
  const result = { models, source: /** @type {ModelsSource} */ ('anthropic') };
423
- cache = { expiresAt: now + CACHE_TTL_MS, result };
424
- return { ...result, selected };
487
+ claudeCache = { expiresAt: now + CACHE_TTL_MS, result };
488
+ return { ...result, selected, provider: 'claude' };
425
489
  } catch {
426
490
  const models = curated;
427
491
  const selected = reconcileModelForProvider(models, selectedRaw);
428
492
  const result = { models, source: /** @type {ModelsSource} */ ('fallback') };
429
- cache = { expiresAt: now + 30_000, result };
430
- return { ...result, selected };
493
+ claudeCache = { expiresAt: now + 30_000, result };
494
+ return { ...result, selected, provider: 'claude' };
495
+ }
496
+ }
497
+
498
+ /**
499
+ * @param {{ selected?: string, force?: boolean }} [opts]
500
+ * @returns {Promise<ModelsListResult>}
501
+ */
502
+ async function listOpenRouterModels(opts = {}) {
503
+ const selectedRaw = opts.selected;
504
+ const force = opts.force === true;
505
+ const now = Date.now();
506
+
507
+ if (!force && openrouterCache && openrouterCache.expiresAt > now) {
508
+ const selected = reconcileModelForProvider(openrouterCache.result.models, selectedRaw);
509
+ return {
510
+ ...openrouterCache.result,
511
+ models: openrouterCache.result.models,
512
+ selected,
513
+ provider: 'openrouter',
514
+ };
515
+ }
516
+
517
+ const curated = OPENROUTER_MODEL_OPTIONS.map((m) => ({ ...m }));
518
+
519
+ try {
520
+ const live = await fetchOpenRouterModels();
521
+ const models = mergeModelLists(curated, live);
522
+ const selected = reconcileModelForProvider(models, selectedRaw);
523
+ const result = { models, source: /** @type {ModelsSource} */ ('openrouter') };
524
+ openrouterCache = { expiresAt: now + CACHE_TTL_MS, result };
525
+ return { ...result, selected, provider: 'openrouter' };
526
+ } catch {
527
+ const models = curated;
528
+ const selected = reconcileModelForProvider(models, selectedRaw);
529
+ const result = { models, source: /** @type {ModelsSource} */ ('openrouter-fallback') };
530
+ openrouterCache = { expiresAt: now + 30_000, result };
531
+ return { ...result, selected, provider: 'openrouter' };
532
+ }
533
+ }
534
+
535
+ /**
536
+ * List models for the UI.
537
+ * @param {{ selected?: string, force?: boolean, provider?: 'claude' | 'openrouter' }} [opts]
538
+ * @returns {Promise<ModelsListResult>}
539
+ */
540
+ export async function listModels(opts = {}) {
541
+ if (opts.provider === 'openrouter') {
542
+ return listOpenRouterModels(opts);
431
543
  }
544
+ return listClaudeModels(opts);
432
545
  }
@@ -0,0 +1,142 @@
1
+ import { OpenRouter, stepCountIs } from '@openrouter/agent';
2
+ import { extractUsageFromOpenRouter } from './usage.js';
3
+ import { checkOpenRouterAuth, openRouterApiKey } from './openrouter-auth.js';
4
+ import { buildOpenRouterCodingTools } from './openrouter-tools.js';
5
+
6
+ /**
7
+ * @param {{
8
+ * name?: string,
9
+ * arguments?: unknown,
10
+ * input?: unknown,
11
+ * }} call
12
+ */
13
+ function toolUseEvent(call) {
14
+ const name = call?.name || 'tool';
15
+ const input = call?.arguments ?? call?.input ?? {};
16
+ return {
17
+ type: 'assistant',
18
+ message: {
19
+ content: [{ type: 'tool_use', name, input }],
20
+ },
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Run the OpenRouter Agent SDK against a worktree with the same coding tools
26
+ * Claude Agent SDK exposes (Read/Glob/Grep/Edit/Write/Bash).
27
+ *
28
+ * @param {{
29
+ * prompt: string,
30
+ * worktreePath: string,
31
+ * config: object,
32
+ * onEvent: (message: unknown) => void,
33
+ * callModelFn?: (args: object) => object,
34
+ * }} params
35
+ */
36
+ export async function runOpenRouterQuery({
37
+ prompt,
38
+ worktreePath,
39
+ config,
40
+ onEvent,
41
+ callModelFn,
42
+ }) {
43
+ const auth = checkOpenRouterAuth();
44
+ if (!auth.ok && !callModelFn) {
45
+ throw new Error(
46
+ 'OpenRouter is not authenticated. Add OPENROUTER_API_KEY in Settings → Authentication.'
47
+ );
48
+ }
49
+
50
+ const timeoutMs = config.agentTimeoutMs ?? 900_000;
51
+ const maxTurns = Math.max(1, Number(config.maxAgentTurns) || 30);
52
+ const model = String(config.model || '').trim();
53
+ if (!model) {
54
+ throw new Error('No OpenRouter model selected');
55
+ }
56
+
57
+ const tools = buildOpenRouterCodingTools(worktreePath, config.allowedTools || []);
58
+ const abortController = new AbortController();
59
+ const started = Date.now();
60
+
61
+ const run = async () => {
62
+ let result;
63
+ if (callModelFn) {
64
+ result = callModelFn({
65
+ model,
66
+ input: prompt,
67
+ tools,
68
+ stopWhen: stepCountIs(maxTurns),
69
+ signal: abortController.signal,
70
+ });
71
+ } else {
72
+ const client = new OpenRouter({ apiKey: openRouterApiKey() });
73
+ result = client.callModel({
74
+ model,
75
+ input: prompt,
76
+ tools,
77
+ stopWhen: stepCountIs(maxTurns),
78
+ signal: abortController.signal,
79
+ });
80
+ }
81
+
82
+ if (result?.getToolCallsStream) {
83
+ void (async () => {
84
+ try {
85
+ for await (const call of result.getToolCallsStream()) {
86
+ onEvent(toolUseEvent(call));
87
+ }
88
+ } catch {
89
+ // stream may abort after success
90
+ }
91
+ })();
92
+ }
93
+
94
+ const resultText =
95
+ typeof result?.getText === 'function' ? await result.getText() : String(result ?? '');
96
+ let usageRaw = null;
97
+ if (typeof result?.getUsage === 'function') {
98
+ try {
99
+ usageRaw = await result.getUsage();
100
+ } catch {
101
+ usageRaw = null;
102
+ }
103
+ }
104
+
105
+ const usage = extractUsageFromOpenRouter(usageRaw, {
106
+ durationMs: Date.now() - started,
107
+ numTurns: usageRaw?.modelCalls,
108
+ });
109
+
110
+ const fakeResult = {
111
+ type: 'result',
112
+ subtype: 'success',
113
+ result: resultText,
114
+ num_turns: usage?.numTurns,
115
+ duration_ms: usage?.durationMs,
116
+ total_cost_usd: usage?.totalCostUsd,
117
+ usage: {
118
+ input_tokens: usage?.inputTokens,
119
+ output_tokens: usage?.outputTokens,
120
+ cache_read_input_tokens: usage?.cacheReadInputTokens,
121
+ },
122
+ };
123
+ onEvent(fakeResult);
124
+
125
+ return { resultText, usage };
126
+ };
127
+
128
+ let timeoutId;
129
+ try {
130
+ return await Promise.race([
131
+ run(),
132
+ new Promise((_, reject) => {
133
+ timeoutId = setTimeout(() => {
134
+ abortController.abort();
135
+ reject(new Error(`Agent timed out after ${timeoutMs}ms`));
136
+ }, timeoutMs);
137
+ }),
138
+ ]);
139
+ } finally {
140
+ if (timeoutId) clearTimeout(timeoutId);
141
+ }
142
+ }
@@ -0,0 +1,41 @@
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
+ * OpenRouter API key from env (Settings writes OPENROUTER_API_KEY).
17
+ * @returns {string}
18
+ */
19
+ export function openRouterApiKey() {
20
+ return String(envResolver().OPENROUTER_API_KEY || '').trim();
21
+ }
22
+
23
+ /**
24
+ * @returns {OpenRouterAuthResult}
25
+ */
26
+ export function checkOpenRouterAuth() {
27
+ if (openRouterApiKey()) return { ok: true };
28
+ return { ok: false, reason: 'missing' };
29
+ }
30
+
31
+ /**
32
+ * @param {OpenRouterAuthResult} [_result]
33
+ */
34
+ export function formatOpenRouterAuthError(_result) {
35
+ return [
36
+ '⚠ OpenRouter is not authenticated — server will still start.',
37
+ ' Open Settings → Authentication to add an OpenRouter API key',
38
+ ' (or set OPENROUTER_API_KEY in `.acdev/.env`).',
39
+ ' For UI-only testing without auth: pass `--stub-agent`.',
40
+ ].join('\n');
41
+ }