acdev 1.0.10 → 1.0.12
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 +245 -33
- package/public/index.html +30 -2
- package/public/styles.css +22 -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 +79 -58
- package/src/store.js +12 -6
- package/src/usage.js +103 -0
package/.acdev/.env.example
CHANGED
|
@@ -13,6 +13,9 @@
|
|
|
13
13
|
# An API key takes precedence over subscription login:
|
|
14
14
|
# ANTHROPIC_API_KEY=sk-ant-your-key-here
|
|
15
15
|
#
|
|
16
|
+
# OpenRouter (Settings → switch LLM provider to OpenRouter):
|
|
17
|
+
# OPENROUTER_API_KEY=sk-or-your-key-here
|
|
18
|
+
#
|
|
16
19
|
# Jira Cloud (only when ticketSource is "jira" in Settings / config.json):
|
|
17
20
|
# JIRA_BASE_URL=https://your-domain.atlassian.net
|
|
18
21
|
# JIRA_EMAIL=you@company.com
|
package/bin/acdev.js
CHANGED
|
@@ -4,8 +4,9 @@ import { execSync } from 'node:child_process';
|
|
|
4
4
|
import http from 'node:http';
|
|
5
5
|
import open from 'open';
|
|
6
6
|
import { checkClaudeAuth, formatClaudeAuthError } from '../src/claude-auth.js';
|
|
7
|
+
import { checkOpenRouterAuth, formatOpenRouterAuthError } from '../src/openrouter-auth.js';
|
|
7
8
|
import { checkGhAuth, formatGhAuthError } from '../src/gh-auth.js';
|
|
8
|
-
import { loadConfig } from '../src/config.js';
|
|
9
|
+
import { loadConfig, normalizeLlmProvider } from '../src/config.js';
|
|
9
10
|
import { loadEnv } from '../src/env.js';
|
|
10
11
|
import { migrateLegacyDataDir, migrateLegacyWorktreesDir } from '../src/paths.js';
|
|
11
12
|
import { Store } from '../src/store.js';
|
|
@@ -75,9 +76,16 @@ async function main() {
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
if (!opts.stubAgent) {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
if (normalizeLlmProvider(config.llmProvider) === 'openrouter') {
|
|
80
|
+
const openrouterAuth = checkOpenRouterAuth();
|
|
81
|
+
if (!openrouterAuth.ok) {
|
|
82
|
+
console.warn(formatOpenRouterAuthError(openrouterAuth));
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
const claudeAuth = checkClaudeAuth();
|
|
86
|
+
if (!claudeAuth.ok) {
|
|
87
|
+
console.warn(formatClaudeAuthError(claudeAuth));
|
|
88
|
+
}
|
|
81
89
|
}
|
|
82
90
|
} else {
|
|
83
91
|
console.log('✓ Stub agent enabled (LLM auth not required)');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acdev",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"description": "Local CLI + web UI for running AI agents on GitHub issues via git worktrees",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,11 +22,13 @@
|
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@anthropic-ai/claude-agent-sdk": "0.1.77",
|
|
25
|
+
"@openrouter/agent": "^0.11.0",
|
|
25
26
|
"dotenv": "^17.4.2",
|
|
26
27
|
"express": "^4.21.2",
|
|
27
28
|
"open": "^10.1.0",
|
|
28
29
|
"simple-git": "^3.27.0",
|
|
29
|
-
"uuid": "^11.1.0"
|
|
30
|
+
"uuid": "^11.1.0",
|
|
31
|
+
"zod": "^4.5.4"
|
|
30
32
|
},
|
|
31
33
|
"publishConfig": {
|
|
32
34
|
"access": "public"
|
package/public/app.js
CHANGED
|
@@ -160,6 +160,8 @@ let currentModel = 'claude-sonnet-5';
|
|
|
160
160
|
let availableModels = [];
|
|
161
161
|
/** @type {'github' | 'jira'} */
|
|
162
162
|
let ticketSource = 'github';
|
|
163
|
+
/** @type {'claude' | 'openrouter'} */
|
|
164
|
+
let llmProvider = 'claude';
|
|
163
165
|
/** @type {{
|
|
164
166
|
* repoName?: string,
|
|
165
167
|
* baseBranch?: string,
|
|
@@ -170,6 +172,8 @@ let ticketSource = 'github';
|
|
|
170
172
|
* knownTools?: string[],
|
|
171
173
|
* models?: Array<{id:string,label:string}>,
|
|
172
174
|
* model?: string,
|
|
175
|
+
* llmProvider?: 'claude' | 'openrouter',
|
|
176
|
+
* lastModelsByProvider?: { claude?: string, openrouter?: string },
|
|
173
177
|
* ticketSource?: 'github' | 'jira',
|
|
174
178
|
* jiraBaseUrl?: string,
|
|
175
179
|
* jiraEmail?: string | null,
|
|
@@ -190,6 +194,9 @@ let ticketSource = 'github';
|
|
|
190
194
|
* anthropicApiKeyMasked?: string | null,
|
|
191
195
|
* claudeOauthTokenSet?: boolean,
|
|
192
196
|
* claudeOauthTokenMasked?: string | null,
|
|
197
|
+
* openrouterApiKeySet?: boolean,
|
|
198
|
+
* openrouterApiKeyMasked?: string | null,
|
|
199
|
+
* openrouterAuthOk?: boolean,
|
|
193
200
|
* llmAuthOk?: boolean,
|
|
194
201
|
* stubAgent?: boolean,
|
|
195
202
|
* }} */
|
|
@@ -248,6 +255,7 @@ const els = {
|
|
|
248
255
|
reviewList: document.getElementById('review-list'),
|
|
249
256
|
reviewEmptyDetail: document.getElementById('review-empty-detail'),
|
|
250
257
|
reviewDetailContent: document.getElementById('review-detail-content'),
|
|
258
|
+
reviewAgentMeta: document.getElementById('review-agent-meta'),
|
|
251
259
|
reviewActions: document.getElementById('review-actions'),
|
|
252
260
|
reviewFeedbackSection: document.getElementById('review-feedback-section'),
|
|
253
261
|
reviewGeneralComment: document.getElementById('review-general-comment'),
|
|
@@ -331,6 +339,13 @@ const els = {
|
|
|
331
339
|
settingsClaudeOauth: document.getElementById('settings-claude-oauth'),
|
|
332
340
|
settingsClaudeOauthClear: document.getElementById('settings-claude-oauth-clear'),
|
|
333
341
|
settingsClaudeOauthHint: document.getElementById('settings-claude-oauth-hint'),
|
|
342
|
+
settingsOpenrouterStatus: document.getElementById('settings-openrouter-status'),
|
|
343
|
+
settingsOpenrouterKey: document.getElementById('settings-openrouter-key'),
|
|
344
|
+
settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
|
|
345
|
+
settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
|
|
346
|
+
settingsLlmProvider: document.getElementById('settings-llm-provider'),
|
|
347
|
+
overviewLlmProvider: document.getElementById('overview-llm-provider'),
|
|
348
|
+
sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
|
|
334
349
|
settingsTabs: document.getElementById('settings-tabs'),
|
|
335
350
|
};
|
|
336
351
|
|
|
@@ -922,10 +937,37 @@ function jobRepoLine(job) {
|
|
|
922
937
|
|
|
923
938
|
function jobSubLine(job) {
|
|
924
939
|
const meta = jobRepoLine(job);
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
940
|
+
const agent = jobLlmLine(job);
|
|
941
|
+
const base = meta.isJira
|
|
942
|
+
? `${meta.number}${meta.branch}`
|
|
943
|
+
: `${meta.repo} #${meta.number}${meta.branch}`;
|
|
944
|
+
return agent ? `${base} · ${agent}` : base;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* @param {object | null | undefined} job
|
|
949
|
+
* @returns {'claude' | 'openrouter'}
|
|
950
|
+
*/
|
|
951
|
+
function jobLlmProvider(job) {
|
|
952
|
+
if (job?.llmProvider === 'openrouter') return 'openrouter';
|
|
953
|
+
if (job?.llmProvider === 'claude') return 'claude';
|
|
954
|
+
if (job?.usage?.provider === 'openrouter') return 'openrouter';
|
|
955
|
+
if (job?.usage?.provider === 'claude') return 'claude';
|
|
956
|
+
return String(job?.model || '').includes('/') ? 'openrouter' : 'claude';
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function jobLlmLabel(job) {
|
|
960
|
+
return jobLlmProvider(job) === 'openrouter' ? 'OpenRouter' : 'Claude';
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** `OpenRouter · google/gemini-2.5-pro` */
|
|
964
|
+
function jobLlmLine(job) {
|
|
965
|
+
if (!job) return '';
|
|
966
|
+
const label = jobLlmLabel(job);
|
|
967
|
+
const model = typeof job.model === 'string' ? job.model.trim() : '';
|
|
968
|
+
if (model && model !== '-') return `${label} · ${model}`;
|
|
969
|
+
if (job.llmProvider || job.model || job.usage?.provider) return label;
|
|
970
|
+
return '';
|
|
929
971
|
}
|
|
930
972
|
|
|
931
973
|
function issueBadge(job) {
|
|
@@ -1056,26 +1098,37 @@ function formatUsageDetail(usage) {
|
|
|
1056
1098
|
return parts.length ? parts.join(' · ') : null;
|
|
1057
1099
|
}
|
|
1058
1100
|
|
|
1059
|
-
/** Sum cost / tokens across jobs that have usage
|
|
1101
|
+
/** Sum cost / tokens across jobs that have usage, split by LLM provider. */
|
|
1060
1102
|
function aggregateJobUsage(jobs) {
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1103
|
+
const empty = () => ({
|
|
1104
|
+
totalCostUsd: 0,
|
|
1105
|
+
totalTokens: 0,
|
|
1106
|
+
withCost: 0,
|
|
1107
|
+
withTokens: 0,
|
|
1108
|
+
});
|
|
1109
|
+
const out = { claude: empty(), openrouter: empty() };
|
|
1065
1110
|
for (const job of jobs) {
|
|
1066
1111
|
const u = job.usage;
|
|
1067
1112
|
if (!u) continue;
|
|
1113
|
+
const bucket = out[jobLlmProvider(job)];
|
|
1068
1114
|
if (typeof u.totalCostUsd === 'number' && Number.isFinite(u.totalCostUsd)) {
|
|
1069
|
-
totalCostUsd += u.totalCostUsd;
|
|
1070
|
-
withCost += 1;
|
|
1115
|
+
bucket.totalCostUsd += u.totalCostUsd;
|
|
1116
|
+
bucket.withCost += 1;
|
|
1071
1117
|
}
|
|
1072
1118
|
const tok = (u.inputTokens || 0) + (u.outputTokens || 0);
|
|
1073
1119
|
if (tok > 0) {
|
|
1074
|
-
totalTokens += tok;
|
|
1075
|
-
withTokens += 1;
|
|
1120
|
+
bucket.totalTokens += tok;
|
|
1121
|
+
bucket.withTokens += 1;
|
|
1076
1122
|
}
|
|
1077
1123
|
}
|
|
1078
|
-
return
|
|
1124
|
+
return out;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function formatProviderUsageStat(agg) {
|
|
1128
|
+
if (!agg.withCost && !agg.withTokens) return '—';
|
|
1129
|
+
const cost = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
|
|
1130
|
+
const tok = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
|
|
1131
|
+
return `${cost} · ${tok}`;
|
|
1079
1132
|
}
|
|
1080
1133
|
|
|
1081
1134
|
/** Five progress dots: sync → worktree → agent → review → PR */
|
|
@@ -1230,6 +1283,27 @@ function formatLogEvent(event) {
|
|
|
1230
1283
|
return { kind: 'status', label: 'Status', text, raw };
|
|
1231
1284
|
}
|
|
1232
1285
|
|
|
1286
|
+
if (event.type === 'llm') {
|
|
1287
|
+
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
|
|
1288
|
+
const provider =
|
|
1289
|
+
payload.provider === 'openrouter' || payload.provider === 'OpenRouter'
|
|
1290
|
+
? 'OpenRouter'
|
|
1291
|
+
: payload.provider === 'claude' || payload.provider === 'Claude'
|
|
1292
|
+
? 'Claude'
|
|
1293
|
+
: payload.provider
|
|
1294
|
+
? String(payload.provider)
|
|
1295
|
+
: 'Agent';
|
|
1296
|
+
const model = typeof payload.model === 'string' && payload.model.trim()
|
|
1297
|
+
? payload.model.trim()
|
|
1298
|
+
: '—';
|
|
1299
|
+
return {
|
|
1300
|
+
kind: 'status',
|
|
1301
|
+
label: 'Agent',
|
|
1302
|
+
text: `${provider} · ${model}`,
|
|
1303
|
+
raw,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1233
1307
|
if (event.type === 'error') {
|
|
1234
1308
|
return {
|
|
1235
1309
|
kind: 'error',
|
|
@@ -2025,8 +2099,6 @@ function renderStats(jobs) {
|
|
|
2025
2099
|
const review = jobs.filter((j) => j.status === 'awaiting_review').length;
|
|
2026
2100
|
const alerts = collectAlerts(jobs).length;
|
|
2027
2101
|
const agg = aggregateJobUsage(jobs);
|
|
2028
|
-
const costLabel = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
|
|
2029
|
-
const tokLabel = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
|
|
2030
2102
|
|
|
2031
2103
|
const items = [
|
|
2032
2104
|
{ label: 'Queued', value: queued, color: 'var(--text-muted)', dot: false },
|
|
@@ -2034,18 +2106,18 @@ function renderStats(jobs) {
|
|
|
2034
2106
|
{ label: 'Ready for review', value: review, color: 'var(--primary)', dot: false },
|
|
2035
2107
|
{ label: 'Alerts', value: alerts, color: 'var(--accent)', dot: false },
|
|
2036
2108
|
{
|
|
2037
|
-
label: '
|
|
2038
|
-
value:
|
|
2109
|
+
label: 'Claude',
|
|
2110
|
+
value: formatProviderUsageStat(agg.claude),
|
|
2039
2111
|
color: 'var(--primary)',
|
|
2040
2112
|
dot: false,
|
|
2041
|
-
hint: '
|
|
2113
|
+
hint: 'Cost · in+out tok',
|
|
2042
2114
|
},
|
|
2043
2115
|
{
|
|
2044
|
-
label: '
|
|
2045
|
-
value:
|
|
2046
|
-
color: 'var(--
|
|
2116
|
+
label: 'OpenRouter',
|
|
2117
|
+
value: formatProviderUsageStat(agg.openrouter),
|
|
2118
|
+
color: 'var(--primary)',
|
|
2047
2119
|
dot: false,
|
|
2048
|
-
hint: '
|
|
2120
|
+
hint: 'Cost · in+out tok',
|
|
2049
2121
|
},
|
|
2050
2122
|
];
|
|
2051
2123
|
|
|
@@ -2241,9 +2313,16 @@ function renderRuns(jobs) {
|
|
|
2241
2313
|
|
|
2242
2314
|
const usageLine = document.createElement('div');
|
|
2243
2315
|
usageLine.className = 'run-usage mono';
|
|
2316
|
+
const agentLine = jobLlmLine(job);
|
|
2244
2317
|
const usageDetail = formatUsageDetail(job.usage);
|
|
2245
|
-
|
|
2246
|
-
|
|
2318
|
+
if (agentLine && usageDetail) {
|
|
2319
|
+
usageLine.textContent = `${agentLine} · ${usageDetail}`;
|
|
2320
|
+
} else if (agentLine) {
|
|
2321
|
+
usageLine.textContent = agentLine;
|
|
2322
|
+
} else {
|
|
2323
|
+
usageLine.textContent = usageDetail || '—';
|
|
2324
|
+
if (!usageDetail) usageLine.classList.add('muted');
|
|
2325
|
+
}
|
|
2247
2326
|
card.appendChild(usageLine);
|
|
2248
2327
|
|
|
2249
2328
|
const now = document.createElement('div');
|
|
@@ -2447,8 +2526,12 @@ function renderReview(jobs) {
|
|
|
2447
2526
|
<div class="review-pick-sub"></div>
|
|
2448
2527
|
`;
|
|
2449
2528
|
btn.querySelector('.review-pick-title').textContent = jobTitle(job);
|
|
2450
|
-
|
|
2451
|
-
|
|
2529
|
+
const meta = jobRepoLine(job);
|
|
2530
|
+
const left = meta.isJira ? meta.number : `${meta.repo} #${meta.number}`;
|
|
2531
|
+
const agent = jobLlmLine(job);
|
|
2532
|
+
btn.querySelector('.review-pick-sub').textContent = [left, agent, statusLabel(job.status)]
|
|
2533
|
+
.filter(Boolean)
|
|
2534
|
+
.join(' · ');
|
|
2452
2535
|
btn.addEventListener('click', () => selectReview(job.id));
|
|
2453
2536
|
els.reviewList.appendChild(btn);
|
|
2454
2537
|
}
|
|
@@ -2463,6 +2546,15 @@ function renderReview(jobs) {
|
|
|
2463
2546
|
els.reviewEmptyDetail.classList.add('hidden');
|
|
2464
2547
|
els.reviewDetailContent.classList.remove('hidden');
|
|
2465
2548
|
|
|
2549
|
+
if (els.reviewAgentMeta) {
|
|
2550
|
+
const agent = jobLlmLine(job);
|
|
2551
|
+
const usage = formatUsageDetail(job.usage);
|
|
2552
|
+
const parts = [agent, usage].filter(Boolean);
|
|
2553
|
+
els.reviewAgentMeta.textContent = parts.length
|
|
2554
|
+
? parts.join(' · ')
|
|
2555
|
+
: 'Agent not recorded for this job.';
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2466
2558
|
const editable = job.status === 'awaiting_review';
|
|
2467
2559
|
const isOpened = job.status === 'pr_opened';
|
|
2468
2560
|
const isTerminal = job.status === 'discarded' || job.status === 'failed';
|
|
@@ -2711,6 +2803,8 @@ function jobMatchesReviewSearch(job, q) {
|
|
|
2711
2803
|
ref?.full,
|
|
2712
2804
|
jiraKey,
|
|
2713
2805
|
job.ticketSource,
|
|
2806
|
+
job.llmProvider,
|
|
2807
|
+
job.model,
|
|
2714
2808
|
]
|
|
2715
2809
|
.filter(Boolean)
|
|
2716
2810
|
.map((s) => String(s).toLowerCase());
|
|
@@ -3008,6 +3102,7 @@ function updateModelLabel() {
|
|
|
3008
3102
|
? 'none selected'
|
|
3009
3103
|
: currentModel;
|
|
3010
3104
|
const combined = agentDisplayText(currentModel);
|
|
3105
|
+
const providerLabel = llmProvider === 'openrouter' ? 'OpenRouter' : 'Claude';
|
|
3011
3106
|
|
|
3012
3107
|
if (els.modelCurrent) {
|
|
3013
3108
|
els.modelCurrent.textContent = modelText;
|
|
@@ -3015,7 +3110,21 @@ function updateModelLabel() {
|
|
|
3015
3110
|
}
|
|
3016
3111
|
if (els.modelCombined) {
|
|
3017
3112
|
els.modelCombined.textContent = combined;
|
|
3018
|
-
els.modelCombined.title = combined
|
|
3113
|
+
els.modelCombined.title = `${providerLabel}: ${combined}`;
|
|
3114
|
+
}
|
|
3115
|
+
if (els.modelProvider) {
|
|
3116
|
+
els.modelProvider.textContent = providerLabel;
|
|
3117
|
+
}
|
|
3118
|
+
if (els.sidebarAgentLabel) {
|
|
3119
|
+
els.sidebarAgentLabel.textContent = providerLabel;
|
|
3120
|
+
}
|
|
3121
|
+
if (els.overviewAgentIcon) {
|
|
3122
|
+
els.overviewAgentIcon.classList.toggle('agent-pill-icon--claude', llmProvider !== 'openrouter');
|
|
3123
|
+
els.overviewAgentIcon.classList.toggle('agent-pill-icon--openrouter', llmProvider === 'openrouter');
|
|
3124
|
+
}
|
|
3125
|
+
const pillLabel = els.overviewAgentTrigger?.querySelector('.agent-pill-label');
|
|
3126
|
+
if (pillLabel) {
|
|
3127
|
+
pillLabel.textContent = providerLabel;
|
|
3019
3128
|
}
|
|
3020
3129
|
syncAgentPickerDisplay();
|
|
3021
3130
|
refreshModelSourceHint();
|
|
@@ -3521,6 +3630,16 @@ function reconcileModelForProvider(models, candidate = currentModel, storedCandi
|
|
|
3521
3630
|
* @returns {Array<{id:string,label?:string,name?:string}>}
|
|
3522
3631
|
*/
|
|
3523
3632
|
function defaultModels() {
|
|
3633
|
+
if (llmProvider === 'openrouter') {
|
|
3634
|
+
return [
|
|
3635
|
+
{ id: 'google/gemini-2.5-pro', label: 'Google: Gemini 2.5 Pro' },
|
|
3636
|
+
{ id: 'google/gemini-2.5-flash', label: 'Google: Gemini 2.5 Flash' },
|
|
3637
|
+
{ id: 'openai/gpt-4.1', label: 'OpenAI: GPT-4.1' },
|
|
3638
|
+
{ id: 'openai/gpt-4o', label: 'OpenAI: GPT-4o' },
|
|
3639
|
+
{ id: 'anthropic/claude-sonnet-4.5', label: 'Anthropic: Claude Sonnet 4.5' },
|
|
3640
|
+
{ id: 'anthropic/claude-opus-4.5', label: 'Anthropic: Claude Opus 4.5' },
|
|
3641
|
+
];
|
|
3642
|
+
}
|
|
3524
3643
|
return [
|
|
3525
3644
|
{ id: 'claude-sonnet-5', label: 'Sonnet 5' },
|
|
3526
3645
|
{ id: 'claude-opus-5', label: 'Opus 5' },
|
|
@@ -3532,16 +3651,24 @@ function defaultModels() {
|
|
|
3532
3651
|
}
|
|
3533
3652
|
|
|
3534
3653
|
/**
|
|
3535
|
-
* @param {'anthropic' | 'fallback' | string | undefined} source
|
|
3654
|
+
* @param {'anthropic' | 'fallback' | 'openrouter' | 'openrouter-fallback' | string | undefined} source
|
|
3536
3655
|
*/
|
|
3537
3656
|
function updateModelSourceHint(source) {
|
|
3538
3657
|
if (!els.settingsModelHint) return;
|
|
3539
|
-
if (source === '
|
|
3658
|
+
if (source === 'openrouter') {
|
|
3659
|
+
modelSourceHintBase =
|
|
3660
|
+
'OpenRouter model for agent runs. List loaded from OpenRouter Models API.';
|
|
3661
|
+
} else if (source === 'openrouter-fallback') {
|
|
3662
|
+
modelSourceHintBase =
|
|
3663
|
+
'OpenRouter model for agent runs. Showing a short fallback list (live catalog unavailable).';
|
|
3664
|
+
} else if (source === 'anthropic') {
|
|
3540
3665
|
modelSourceHintBase =
|
|
3541
3666
|
'Claude model for agent runs. List loaded from Anthropic Models API.';
|
|
3542
3667
|
} else if (source === 'fallback') {
|
|
3543
3668
|
modelSourceHintBase =
|
|
3544
3669
|
'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
|
|
3670
|
+
} else if (llmProvider === 'openrouter') {
|
|
3671
|
+
modelSourceHintBase = 'OpenRouter model for agent runs.';
|
|
3545
3672
|
} else {
|
|
3546
3673
|
modelSourceHintBase = 'Claude model for agent runs.';
|
|
3547
3674
|
}
|
|
@@ -3611,6 +3738,7 @@ async function fetchModels(opts = {}) {
|
|
|
3611
3738
|
}
|
|
3612
3739
|
const query = new URLSearchParams();
|
|
3613
3740
|
if (refresh) query.set('refresh', '1');
|
|
3741
|
+
if (llmProvider) query.set('provider', llmProvider);
|
|
3614
3742
|
try {
|
|
3615
3743
|
const res = await fetch(`/api/models?${query.toString()}`);
|
|
3616
3744
|
if (!res.ok) {
|
|
@@ -3753,6 +3881,13 @@ function claudeStatusText(cfg) {
|
|
|
3753
3881
|
}
|
|
3754
3882
|
}
|
|
3755
3883
|
|
|
3884
|
+
function openrouterStatusText(cfg) {
|
|
3885
|
+
if (cfg.openrouterAuthOk) {
|
|
3886
|
+
return 'Authenticated via OPENROUTER_API_KEY';
|
|
3887
|
+
}
|
|
3888
|
+
return 'Not authenticated — add an OpenRouter API key';
|
|
3889
|
+
}
|
|
3890
|
+
|
|
3756
3891
|
function fillAuthSettings(cfg) {
|
|
3757
3892
|
if (els.settingsGhStatus) {
|
|
3758
3893
|
els.settingsGhStatus.textContent = ghStatusText(cfg);
|
|
@@ -3811,6 +3946,25 @@ function fillAuthSettings(cfg) {
|
|
|
3811
3946
|
? 'OAuth token stored in <code>.acdev/.env</code> as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.'
|
|
3812
3947
|
: 'From <code>claude setup-token</code> for non-interactive subscription auth. Stored as <code>CLAUDE_CODE_OAUTH_TOKEN</code>. Settings cannot complete browser OAuth — that still needs <code>claude auth login</code> on this host.';
|
|
3813
3948
|
}
|
|
3949
|
+
|
|
3950
|
+
if (els.settingsOpenrouterStatus) {
|
|
3951
|
+
els.settingsOpenrouterStatus.textContent = openrouterStatusText(cfg);
|
|
3952
|
+
els.settingsOpenrouterStatus.className = cfg.openrouterAuthOk
|
|
3953
|
+
? 'auth-status ok'
|
|
3954
|
+
: 'auth-status err';
|
|
3955
|
+
}
|
|
3956
|
+
fillSecretInput(
|
|
3957
|
+
els.settingsOpenrouterKey,
|
|
3958
|
+
els.settingsOpenrouterKeyClear,
|
|
3959
|
+
Boolean(cfg.openrouterApiKeySet),
|
|
3960
|
+
cfg.openrouterApiKeyMasked,
|
|
3961
|
+
'Paste an OpenRouter API key'
|
|
3962
|
+
);
|
|
3963
|
+
if (els.settingsOpenrouterKeyHint) {
|
|
3964
|
+
els.settingsOpenrouterKeyHint.innerHTML = cfg.openrouterApiKeySet
|
|
3965
|
+
? 'API key stored in <code>.acdev/.env</code> as <code>OPENROUTER_API_KEY</code> (not committed). Leave blank to keep.'
|
|
3966
|
+
: 'From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.';
|
|
3967
|
+
}
|
|
3814
3968
|
}
|
|
3815
3969
|
|
|
3816
3970
|
function fillSettingsForm(cfg) {
|
|
@@ -3819,6 +3973,7 @@ function fillSettingsForm(cfg) {
|
|
|
3819
3973
|
fillAuthSettings(cfg);
|
|
3820
3974
|
|
|
3821
3975
|
updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
|
|
3976
|
+
updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
3822
3977
|
|
|
3823
3978
|
if (els.settingsJiraBaseUrl) {
|
|
3824
3979
|
els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
|
|
@@ -4045,6 +4200,23 @@ function updateTicketSourceUI(source) {
|
|
|
4045
4200
|
}
|
|
4046
4201
|
}
|
|
4047
4202
|
|
|
4203
|
+
/**
|
|
4204
|
+
* @param {'claude' | 'openrouter'} provider
|
|
4205
|
+
*/
|
|
4206
|
+
function updateLlmProviderUI(provider) {
|
|
4207
|
+
llmProvider = provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4208
|
+
|
|
4209
|
+
for (const toggle of [els.settingsLlmProvider, els.overviewLlmProvider]) {
|
|
4210
|
+
if (!toggle) continue;
|
|
4211
|
+
toggle.querySelectorAll('.source-btn').forEach((btn) => {
|
|
4212
|
+
const active = btn.dataset.provider === llmProvider;
|
|
4213
|
+
btn.classList.toggle('active', active);
|
|
4214
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
4215
|
+
});
|
|
4216
|
+
}
|
|
4217
|
+
updateModelLabel();
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4048
4220
|
/**
|
|
4049
4221
|
* @param {'github' | 'jira'} next
|
|
4050
4222
|
*/
|
|
@@ -4066,6 +4238,26 @@ async function saveTicketSource(next) {
|
|
|
4066
4238
|
}
|
|
4067
4239
|
}
|
|
4068
4240
|
|
|
4241
|
+
async function saveLlmProvider(next) {
|
|
4242
|
+
const provider = next === 'openrouter' ? 'openrouter' : 'claude';
|
|
4243
|
+
updateLlmProviderUI(provider);
|
|
4244
|
+
clearAvailableModels();
|
|
4245
|
+
try {
|
|
4246
|
+
const res = await fetch('/api/config', {
|
|
4247
|
+
method: 'PATCH',
|
|
4248
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4249
|
+
body: JSON.stringify({ llmProvider: provider }),
|
|
4250
|
+
});
|
|
4251
|
+
const data = await readJson(res);
|
|
4252
|
+
if (res.ok) {
|
|
4253
|
+
applyConfigSnapshot({ ...appConfig, ...data });
|
|
4254
|
+
await fetchModels({ refresh: true, reconcile: true });
|
|
4255
|
+
}
|
|
4256
|
+
} catch {
|
|
4257
|
+
void fetchModels({ refresh: true, reconcile: true });
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4069
4261
|
function updateTimeoutMsHint(minutes) {
|
|
4070
4262
|
if (!els.settingsTimeoutMs) return;
|
|
4071
4263
|
const m = Number(minutes);
|
|
@@ -4093,6 +4285,7 @@ function setOverviewLlmFeedback(message, kind = 'ok') {
|
|
|
4093
4285
|
*/
|
|
4094
4286
|
function fillOverviewLlmControls(cfg) {
|
|
4095
4287
|
if (!cfg) return;
|
|
4288
|
+
updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
4096
4289
|
syncAllModelComboboxValues(currentModel);
|
|
4097
4290
|
}
|
|
4098
4291
|
|
|
@@ -4140,6 +4333,7 @@ function applyConfigSnapshot(data) {
|
|
|
4140
4333
|
updateModelLabel();
|
|
4141
4334
|
fillOverviewLlmControls(data);
|
|
4142
4335
|
updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
|
|
4336
|
+
updateLlmProviderUI(data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
4143
4337
|
if (els.repoName) {
|
|
4144
4338
|
els.repoName.textContent = data?.repoName || 'local repo';
|
|
4145
4339
|
}
|
|
@@ -4186,6 +4380,7 @@ function readSettingsForm() {
|
|
|
4186
4380
|
testCommand: testTrimmed === '' ? null : testTrimmed,
|
|
4187
4381
|
allowedTools,
|
|
4188
4382
|
ticketSource,
|
|
4383
|
+
llmProvider,
|
|
4189
4384
|
};
|
|
4190
4385
|
|
|
4191
4386
|
const ghToken = secretPatchValue(els.settingsGhToken);
|
|
@@ -4194,6 +4389,8 @@ function readSettingsForm() {
|
|
|
4194
4389
|
if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
|
|
4195
4390
|
const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
|
|
4196
4391
|
if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
|
|
4392
|
+
const openrouterApiKey = secretPatchValue(els.settingsOpenrouterKey);
|
|
4393
|
+
if (openrouterApiKey !== undefined) patch.openrouterApiKey = openrouterApiKey;
|
|
4197
4394
|
|
|
4198
4395
|
if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
|
|
4199
4396
|
patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
|
|
@@ -4361,6 +4558,12 @@ bindSecretClear(
|
|
|
4361
4558
|
els.settingsClaudeOauthHint,
|
|
4362
4559
|
'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
|
|
4363
4560
|
);
|
|
4561
|
+
bindSecretClear(
|
|
4562
|
+
els.settingsOpenrouterKeyClear,
|
|
4563
|
+
els.settingsOpenrouterKey,
|
|
4564
|
+
els.settingsOpenrouterKeyHint,
|
|
4565
|
+
'Saved OPENROUTER_API_KEY will be removed when you click Save settings.'
|
|
4566
|
+
);
|
|
4364
4567
|
|
|
4365
4568
|
|
|
4366
4569
|
document.addEventListener('click', (ev) => {
|
|
@@ -4466,9 +4669,6 @@ els.addBtn.addEventListener('click', async () => {
|
|
|
4466
4669
|
|
|
4467
4670
|
const urls = splitIssueUrls(text);
|
|
4468
4671
|
const branchName = els.preferredBranch?.value?.trim() || '';
|
|
4469
|
-
// #region agent log
|
|
4470
|
-
fetch('http://127.0.0.1:7258/ingest/377aa5e2-15ea-4447-a68b-7ce215882bc3',{method:'POST',headers:{'Content-Type':'application/json','X-Debug-Session-Id':'473a78'},body:JSON.stringify({sessionId:'473a78',runId:'pre-fix',hypothesisId:'B',location:'public/app.js:addBtn',message:'enqueue submit',data:{hasCustom:Boolean(branchName),branchName:branchName||null,urlCount:urls.length},timestamp:Date.now()})}).catch(()=>{});
|
|
4471
|
-
// #endregion
|
|
4472
4672
|
|
|
4473
4673
|
try {
|
|
4474
4674
|
const res = await fetch('/api/issues', {
|
|
@@ -4524,6 +4724,18 @@ function handleTicketSourceToggleClick(e) {
|
|
|
4524
4724
|
els.settingsTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
|
|
4525
4725
|
els.overviewTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
|
|
4526
4726
|
|
|
4727
|
+
function handleLlmProviderToggleClick(e) {
|
|
4728
|
+
const btn = e.target.closest('.source-btn');
|
|
4729
|
+
if (!btn?.dataset.provider) return;
|
|
4730
|
+
const next = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4731
|
+
if (next === llmProvider) return;
|
|
4732
|
+
e.stopPropagation();
|
|
4733
|
+
void saveLlmProvider(next);
|
|
4734
|
+
}
|
|
4735
|
+
|
|
4736
|
+
els.settingsLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
|
|
4737
|
+
els.overviewLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
|
|
4738
|
+
|
|
4527
4739
|
els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
|
|
4528
4740
|
e.preventDefault();
|
|
4529
4741
|
setView('settings');
|
package/public/index.html
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
<div class="sidebar-footer">
|
|
73
73
|
<div class="sidebar-divider"></div>
|
|
74
74
|
<div class="model-block">
|
|
75
|
-
<div class="model-label">Agent</div>
|
|
75
|
+
<div class="model-label" id="sidebar-agent-label">Agent</div>
|
|
76
76
|
<div class="model-meta-row">
|
|
77
77
|
<span class="model-combined" id="model-combined">—</span>
|
|
78
78
|
</div>
|
|
@@ -190,6 +190,10 @@
|
|
|
190
190
|
<svg class="agent-pill-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
|
|
191
191
|
</button>
|
|
192
192
|
<div class="agent-picker-panel" id="overview-agent-panel" role="dialog" aria-label="Choose agent model" aria-hidden="true">
|
|
193
|
+
<div class="source-toggle source-toggle--compact agent-picker-provider" id="overview-llm-provider" role="group" aria-label="LLM provider">
|
|
194
|
+
<button type="button" class="source-btn active" data-provider="claude">Claude</button>
|
|
195
|
+
<button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
|
|
196
|
+
</div>
|
|
193
197
|
<label class="sr-only" for="overview-agent-search">Search models</label>
|
|
194
198
|
<input
|
|
195
199
|
id="overview-agent-search"
|
|
@@ -282,6 +286,7 @@
|
|
|
282
286
|
<div class="review-detail" id="review-detail">
|
|
283
287
|
<div class="empty-dashed" id="review-empty-detail">Select a job to review</div>
|
|
284
288
|
<div class="stack hidden" id="review-detail-content">
|
|
289
|
+
<p class="review-agent-meta" id="review-agent-meta"></p>
|
|
285
290
|
<div class="card" id="review-pr-meta-card">
|
|
286
291
|
<div class="field-label">PR title</div>
|
|
287
292
|
<input
|
|
@@ -548,6 +553,21 @@
|
|
|
548
553
|
</div>
|
|
549
554
|
</div>
|
|
550
555
|
</div>
|
|
556
|
+
|
|
557
|
+
<div class="card settings-card">
|
|
558
|
+
<div class="rules-heading">OpenRouter authentication</div>
|
|
559
|
+
<p id="settings-openrouter-status" class="auth-status" role="status">Checking OpenRouter auth…</p>
|
|
560
|
+
<div class="settings-grid">
|
|
561
|
+
<div class="settings-field settings-field-full">
|
|
562
|
+
<label class="field-label" for="settings-openrouter-key">OpenRouter API key</label>
|
|
563
|
+
<input id="settings-openrouter-key" class="input" type="password" name="openrouterApiKey" autocomplete="new-password" placeholder="Leave blank to keep existing">
|
|
564
|
+
<button type="button" class="auth-clear hidden" id="settings-openrouter-key-clear">Clear saved API key</button>
|
|
565
|
+
<p class="field-hint" id="settings-openrouter-key-hint">
|
|
566
|
+
From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.
|
|
567
|
+
</p>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
570
|
+
</div>
|
|
551
571
|
</div>
|
|
552
572
|
</div>
|
|
553
573
|
|
|
@@ -643,6 +663,14 @@
|
|
|
643
663
|
Changes save to <code>.acdev/config.json</code> and apply to the next job — no restart needed.
|
|
644
664
|
</p>
|
|
645
665
|
<div class="card settings-card">
|
|
666
|
+
<div class="settings-field settings-field-full">
|
|
667
|
+
<div class="field-label">LLM provider</div>
|
|
668
|
+
<div class="source-toggle" id="settings-llm-provider" role="group" aria-label="LLM provider">
|
|
669
|
+
<button type="button" class="source-btn active" data-provider="claude">Claude</button>
|
|
670
|
+
<button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
|
|
671
|
+
</div>
|
|
672
|
+
<p class="field-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model).</p>
|
|
673
|
+
</div>
|
|
646
674
|
<div class="settings-grid">
|
|
647
675
|
<div class="settings-field">
|
|
648
676
|
<label class="field-label" for="settings-base-branch">Base branch</label>
|
|
@@ -682,7 +710,7 @@
|
|
|
682
710
|
></ul>
|
|
683
711
|
</div>
|
|
684
712
|
</div>
|
|
685
|
-
<p class="field-hint" id="settings-model-hint">
|
|
713
|
+
<p class="field-hint" id="settings-model-hint">Model for agent runs.</p>
|
|
686
714
|
</div>
|
|
687
715
|
|
|
688
716
|
<div class="settings-field">
|