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/.acdev/.env.example +3 -0
- package/bin/acdev.js +12 -4
- package/package.json +4 -2
- package/public/app.js +138 -6
- package/public/index.html +29 -2
- package/public/styles.css +15 -1
- package/src/agent.js +68 -21
- package/src/config.js +122 -10
- package/src/models.js +131 -18
- package/src/openrouter-agent.js +142 -0
- package/src/openrouter-auth.js +41 -0
- package/src/openrouter-tools.js +291 -0
- package/src/server.js +45 -53
- package/src/usage.js +35 -0
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
|
-
|
|
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 (!
|
|
453
|
+
} else if (!isModelIdForProvider(String(patch.model).trim(), provider)) {
|
|
360
454
|
throw new Error(
|
|
361
|
-
|
|
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
|
-
:
|
|
560
|
+
: isModelIdForProvider(config.model, llmProvider)
|
|
462
561
|
? String(config.model).trim()
|
|
463
|
-
:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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 &&
|
|
407
|
-
const selected = reconcileModelForProvider(
|
|
469
|
+
if (!force && claudeCache && claudeCache.expiresAt > now) {
|
|
470
|
+
const selected = reconcileModelForProvider(claudeCache.result.models, selectedRaw);
|
|
408
471
|
return {
|
|
409
|
-
...
|
|
410
|
-
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|