acdev 1.0.11 → 1.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/public/app.js +194 -34
- package/public/index.html +2 -1
- package/public/styles.css +18 -1
- package/src/config.js +36 -0
- package/src/server.js +55 -6
- package/src/store.js +12 -6
- package/src/usage.js +68 -0
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -255,6 +255,7 @@ const els = {
|
|
|
255
255
|
reviewList: document.getElementById('review-list'),
|
|
256
256
|
reviewEmptyDetail: document.getElementById('review-empty-detail'),
|
|
257
257
|
reviewDetailContent: document.getElementById('review-detail-content'),
|
|
258
|
+
reviewAgentMeta: document.getElementById('review-agent-meta'),
|
|
258
259
|
reviewActions: document.getElementById('review-actions'),
|
|
259
260
|
reviewFeedbackSection: document.getElementById('review-feedback-section'),
|
|
260
261
|
reviewGeneralComment: document.getElementById('review-general-comment'),
|
|
@@ -343,6 +344,7 @@ const els = {
|
|
|
343
344
|
settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
|
|
344
345
|
settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
|
|
345
346
|
settingsLlmProvider: document.getElementById('settings-llm-provider'),
|
|
347
|
+
settingsLlmProviderHint: document.getElementById('settings-llm-provider-hint'),
|
|
346
348
|
overviewLlmProvider: document.getElementById('overview-llm-provider'),
|
|
347
349
|
sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
|
|
348
350
|
settingsTabs: document.getElementById('settings-tabs'),
|
|
@@ -936,10 +938,37 @@ function jobRepoLine(job) {
|
|
|
936
938
|
|
|
937
939
|
function jobSubLine(job) {
|
|
938
940
|
const meta = jobRepoLine(job);
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
941
|
+
const agent = jobLlmLine(job);
|
|
942
|
+
const base = meta.isJira
|
|
943
|
+
? `${meta.number}${meta.branch}`
|
|
944
|
+
: `${meta.repo} #${meta.number}${meta.branch}`;
|
|
945
|
+
return agent ? `${base} · ${agent}` : base;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* @param {object | null | undefined} job
|
|
950
|
+
* @returns {'claude' | 'openrouter'}
|
|
951
|
+
*/
|
|
952
|
+
function jobLlmProvider(job) {
|
|
953
|
+
if (job?.llmProvider === 'openrouter') return 'openrouter';
|
|
954
|
+
if (job?.llmProvider === 'claude') return 'claude';
|
|
955
|
+
if (job?.usage?.provider === 'openrouter') return 'openrouter';
|
|
956
|
+
if (job?.usage?.provider === 'claude') return 'claude';
|
|
957
|
+
return String(job?.model || '').includes('/') ? 'openrouter' : 'claude';
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
function jobLlmLabel(job) {
|
|
961
|
+
return jobLlmProvider(job) === 'openrouter' ? 'OpenRouter' : 'Claude';
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** `OpenRouter · google/gemini-2.5-pro` */
|
|
965
|
+
function jobLlmLine(job) {
|
|
966
|
+
if (!job) return '';
|
|
967
|
+
const label = jobLlmLabel(job);
|
|
968
|
+
const model = typeof job.model === 'string' ? job.model.trim() : '';
|
|
969
|
+
if (model && model !== '-') return `${label} · ${model}`;
|
|
970
|
+
if (job.llmProvider || job.model || job.usage?.provider) return label;
|
|
971
|
+
return '';
|
|
943
972
|
}
|
|
944
973
|
|
|
945
974
|
function issueBadge(job) {
|
|
@@ -1070,26 +1099,37 @@ function formatUsageDetail(usage) {
|
|
|
1070
1099
|
return parts.length ? parts.join(' · ') : null;
|
|
1071
1100
|
}
|
|
1072
1101
|
|
|
1073
|
-
/** Sum cost / tokens across jobs that have usage
|
|
1102
|
+
/** Sum cost / tokens across jobs that have usage, split by LLM provider. */
|
|
1074
1103
|
function aggregateJobUsage(jobs) {
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1104
|
+
const empty = () => ({
|
|
1105
|
+
totalCostUsd: 0,
|
|
1106
|
+
totalTokens: 0,
|
|
1107
|
+
withCost: 0,
|
|
1108
|
+
withTokens: 0,
|
|
1109
|
+
});
|
|
1110
|
+
const out = { claude: empty(), openrouter: empty() };
|
|
1079
1111
|
for (const job of jobs) {
|
|
1080
1112
|
const u = job.usage;
|
|
1081
1113
|
if (!u) continue;
|
|
1114
|
+
const bucket = out[jobLlmProvider(job)];
|
|
1082
1115
|
if (typeof u.totalCostUsd === 'number' && Number.isFinite(u.totalCostUsd)) {
|
|
1083
|
-
totalCostUsd += u.totalCostUsd;
|
|
1084
|
-
withCost += 1;
|
|
1116
|
+
bucket.totalCostUsd += u.totalCostUsd;
|
|
1117
|
+
bucket.withCost += 1;
|
|
1085
1118
|
}
|
|
1086
1119
|
const tok = (u.inputTokens || 0) + (u.outputTokens || 0);
|
|
1087
1120
|
if (tok > 0) {
|
|
1088
|
-
totalTokens += tok;
|
|
1089
|
-
withTokens += 1;
|
|
1121
|
+
bucket.totalTokens += tok;
|
|
1122
|
+
bucket.withTokens += 1;
|
|
1090
1123
|
}
|
|
1091
1124
|
}
|
|
1092
|
-
return
|
|
1125
|
+
return out;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function formatProviderUsageStat(agg) {
|
|
1129
|
+
if (!agg.withCost && !agg.withTokens) return '—';
|
|
1130
|
+
const cost = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
|
|
1131
|
+
const tok = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
|
|
1132
|
+
return `${cost} · ${tok}`;
|
|
1093
1133
|
}
|
|
1094
1134
|
|
|
1095
1135
|
/** Five progress dots: sync → worktree → agent → review → PR */
|
|
@@ -1244,6 +1284,27 @@ function formatLogEvent(event) {
|
|
|
1244
1284
|
return { kind: 'status', label: 'Status', text, raw };
|
|
1245
1285
|
}
|
|
1246
1286
|
|
|
1287
|
+
if (event.type === 'llm') {
|
|
1288
|
+
const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
|
|
1289
|
+
const provider =
|
|
1290
|
+
payload.provider === 'openrouter' || payload.provider === 'OpenRouter'
|
|
1291
|
+
? 'OpenRouter'
|
|
1292
|
+
: payload.provider === 'claude' || payload.provider === 'Claude'
|
|
1293
|
+
? 'Claude'
|
|
1294
|
+
: payload.provider
|
|
1295
|
+
? String(payload.provider)
|
|
1296
|
+
: 'Agent';
|
|
1297
|
+
const model = typeof payload.model === 'string' && payload.model.trim()
|
|
1298
|
+
? payload.model.trim()
|
|
1299
|
+
: '—';
|
|
1300
|
+
return {
|
|
1301
|
+
kind: 'status',
|
|
1302
|
+
label: 'Agent',
|
|
1303
|
+
text: `${provider} · ${model}`,
|
|
1304
|
+
raw,
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1247
1308
|
if (event.type === 'error') {
|
|
1248
1309
|
return {
|
|
1249
1310
|
kind: 'error',
|
|
@@ -2039,8 +2100,6 @@ function renderStats(jobs) {
|
|
|
2039
2100
|
const review = jobs.filter((j) => j.status === 'awaiting_review').length;
|
|
2040
2101
|
const alerts = collectAlerts(jobs).length;
|
|
2041
2102
|
const agg = aggregateJobUsage(jobs);
|
|
2042
|
-
const costLabel = formatUsd(agg.withCost ? agg.totalCostUsd : null) || '—';
|
|
2043
|
-
const tokLabel = agg.withTokens ? formatTokenCount(agg.totalTokens) || '—' : '—';
|
|
2044
2103
|
|
|
2045
2104
|
const items = [
|
|
2046
2105
|
{ label: 'Queued', value: queued, color: 'var(--text-muted)', dot: false },
|
|
@@ -2048,18 +2107,18 @@ function renderStats(jobs) {
|
|
|
2048
2107
|
{ label: 'Ready for review', value: review, color: 'var(--primary)', dot: false },
|
|
2049
2108
|
{ label: 'Alerts', value: alerts, color: 'var(--accent)', dot: false },
|
|
2050
2109
|
{
|
|
2051
|
-
label: '
|
|
2052
|
-
value:
|
|
2110
|
+
label: 'Claude',
|
|
2111
|
+
value: formatProviderUsageStat(agg.claude),
|
|
2053
2112
|
color: 'var(--primary)',
|
|
2054
2113
|
dot: false,
|
|
2055
|
-
hint: '
|
|
2114
|
+
hint: 'Cost · in+out tok',
|
|
2056
2115
|
},
|
|
2057
2116
|
{
|
|
2058
|
-
label: '
|
|
2059
|
-
value:
|
|
2060
|
-
color: 'var(--
|
|
2117
|
+
label: 'OpenRouter',
|
|
2118
|
+
value: formatProviderUsageStat(agg.openrouter),
|
|
2119
|
+
color: 'var(--primary)',
|
|
2061
2120
|
dot: false,
|
|
2062
|
-
hint: '
|
|
2121
|
+
hint: 'Cost · in+out tok',
|
|
2063
2122
|
},
|
|
2064
2123
|
];
|
|
2065
2124
|
|
|
@@ -2255,9 +2314,16 @@ function renderRuns(jobs) {
|
|
|
2255
2314
|
|
|
2256
2315
|
const usageLine = document.createElement('div');
|
|
2257
2316
|
usageLine.className = 'run-usage mono';
|
|
2317
|
+
const agentLine = jobLlmLine(job);
|
|
2258
2318
|
const usageDetail = formatUsageDetail(job.usage);
|
|
2259
|
-
|
|
2260
|
-
|
|
2319
|
+
if (agentLine && usageDetail) {
|
|
2320
|
+
usageLine.textContent = `${agentLine} · ${usageDetail}`;
|
|
2321
|
+
} else if (agentLine) {
|
|
2322
|
+
usageLine.textContent = agentLine;
|
|
2323
|
+
} else {
|
|
2324
|
+
usageLine.textContent = usageDetail || '—';
|
|
2325
|
+
if (!usageDetail) usageLine.classList.add('muted');
|
|
2326
|
+
}
|
|
2261
2327
|
card.appendChild(usageLine);
|
|
2262
2328
|
|
|
2263
2329
|
const now = document.createElement('div');
|
|
@@ -2461,8 +2527,12 @@ function renderReview(jobs) {
|
|
|
2461
2527
|
<div class="review-pick-sub"></div>
|
|
2462
2528
|
`;
|
|
2463
2529
|
btn.querySelector('.review-pick-title').textContent = jobTitle(job);
|
|
2464
|
-
|
|
2465
|
-
|
|
2530
|
+
const meta = jobRepoLine(job);
|
|
2531
|
+
const left = meta.isJira ? meta.number : `${meta.repo} #${meta.number}`;
|
|
2532
|
+
const agent = jobLlmLine(job);
|
|
2533
|
+
btn.querySelector('.review-pick-sub').textContent = [left, agent, statusLabel(job.status)]
|
|
2534
|
+
.filter(Boolean)
|
|
2535
|
+
.join(' · ');
|
|
2466
2536
|
btn.addEventListener('click', () => selectReview(job.id));
|
|
2467
2537
|
els.reviewList.appendChild(btn);
|
|
2468
2538
|
}
|
|
@@ -2477,6 +2547,15 @@ function renderReview(jobs) {
|
|
|
2477
2547
|
els.reviewEmptyDetail.classList.add('hidden');
|
|
2478
2548
|
els.reviewDetailContent.classList.remove('hidden');
|
|
2479
2549
|
|
|
2550
|
+
if (els.reviewAgentMeta) {
|
|
2551
|
+
const agent = jobLlmLine(job);
|
|
2552
|
+
const usage = formatUsageDetail(job.usage);
|
|
2553
|
+
const parts = [agent, usage].filter(Boolean);
|
|
2554
|
+
els.reviewAgentMeta.textContent = parts.length
|
|
2555
|
+
? parts.join(' · ')
|
|
2556
|
+
: 'Agent not recorded for this job.';
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2480
2559
|
const editable = job.status === 'awaiting_review';
|
|
2481
2560
|
const isOpened = job.status === 'pr_opened';
|
|
2482
2561
|
const isTerminal = job.status === 'discarded' || job.status === 'failed';
|
|
@@ -2725,6 +2804,8 @@ function jobMatchesReviewSearch(job, q) {
|
|
|
2725
2804
|
ref?.full,
|
|
2726
2805
|
jiraKey,
|
|
2727
2806
|
job.ticketSource,
|
|
2807
|
+
job.llmProvider,
|
|
2808
|
+
job.model,
|
|
2728
2809
|
]
|
|
2729
2810
|
.filter(Boolean)
|
|
2730
2811
|
.map((s) => String(s).toLowerCase());
|
|
@@ -3893,7 +3974,7 @@ function fillSettingsForm(cfg) {
|
|
|
3893
3974
|
fillAuthSettings(cfg);
|
|
3894
3975
|
|
|
3895
3976
|
updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
|
|
3896
|
-
updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
3977
|
+
updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude', cfg);
|
|
3897
3978
|
|
|
3898
3979
|
if (els.settingsJiraBaseUrl) {
|
|
3899
3980
|
els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
|
|
@@ -4120,20 +4201,76 @@ function updateTicketSourceUI(source) {
|
|
|
4120
4201
|
}
|
|
4121
4202
|
}
|
|
4122
4203
|
|
|
4204
|
+
/**
|
|
4205
|
+
* @param {'claude' | 'openrouter'} provider
|
|
4206
|
+
* @param {typeof appConfig} [cfg]
|
|
4207
|
+
*/
|
|
4208
|
+
function llmProviderReady(provider, cfg = appConfig) {
|
|
4209
|
+
if (cfg?.stubAgent) return true;
|
|
4210
|
+
if (provider === 'openrouter') return cfg?.openrouterAuthOk === true;
|
|
4211
|
+
return cfg?.claudeAuthOk === true;
|
|
4212
|
+
}
|
|
4213
|
+
|
|
4123
4214
|
/**
|
|
4124
4215
|
* @param {'claude' | 'openrouter'} provider
|
|
4125
4216
|
*/
|
|
4126
|
-
function
|
|
4217
|
+
function llmProviderUnavailableReason(provider) {
|
|
4218
|
+
if (provider === 'openrouter') {
|
|
4219
|
+
return 'OpenRouter is not configured. Add an API key in Settings → Authentication first.';
|
|
4220
|
+
}
|
|
4221
|
+
return 'Claude is not configured. Add an API key or OAuth token in Settings → Authentication, or run claude auth login.';
|
|
4222
|
+
}
|
|
4223
|
+
|
|
4224
|
+
const LLM_PROVIDER_HINT_DEFAULT =
|
|
4225
|
+
'Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model). A provider stays disabled until it is configured and authenticated.';
|
|
4226
|
+
|
|
4227
|
+
/**
|
|
4228
|
+
* @param {typeof appConfig} [cfg]
|
|
4229
|
+
*/
|
|
4230
|
+
function updateLlmProviderHint(cfg = appConfig) {
|
|
4231
|
+
if (!els.settingsLlmProviderHint) return;
|
|
4232
|
+
if (cfg?.stubAgent) {
|
|
4233
|
+
els.settingsLlmProviderHint.innerHTML = LLM_PROVIDER_HINT_DEFAULT;
|
|
4234
|
+
return;
|
|
4235
|
+
}
|
|
4236
|
+
const missing = [];
|
|
4237
|
+
if (!llmProviderReady('claude', cfg)) {
|
|
4238
|
+
missing.push('Claude (API key, OAuth token, or <code>claude auth login</code>)');
|
|
4239
|
+
}
|
|
4240
|
+
if (!llmProviderReady('openrouter', cfg)) {
|
|
4241
|
+
missing.push('OpenRouter (API key in Authentication)');
|
|
4242
|
+
}
|
|
4243
|
+
if (!missing.length) {
|
|
4244
|
+
els.settingsLlmProviderHint.innerHTML = LLM_PROVIDER_HINT_DEFAULT;
|
|
4245
|
+
return;
|
|
4246
|
+
}
|
|
4247
|
+
els.settingsLlmProviderHint.innerHTML = `Cannot enable a provider until it is configured and authenticated. Missing: ${missing.join('; ')}.`;
|
|
4248
|
+
}
|
|
4249
|
+
|
|
4250
|
+
/**
|
|
4251
|
+
* @param {'claude' | 'openrouter'} provider
|
|
4252
|
+
* @param {typeof appConfig} [cfg]
|
|
4253
|
+
*/
|
|
4254
|
+
function updateLlmProviderUI(provider, cfg = appConfig) {
|
|
4127
4255
|
llmProvider = provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4128
4256
|
|
|
4129
4257
|
for (const toggle of [els.settingsLlmProvider, els.overviewLlmProvider]) {
|
|
4130
4258
|
if (!toggle) continue;
|
|
4131
4259
|
toggle.querySelectorAll('.source-btn').forEach((btn) => {
|
|
4132
|
-
const
|
|
4260
|
+
const id = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4261
|
+
const active = id === llmProvider;
|
|
4262
|
+
const ready = llmProviderReady(id, cfg);
|
|
4133
4263
|
btn.classList.toggle('active', active);
|
|
4134
4264
|
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
4265
|
+
btn.disabled = !ready;
|
|
4266
|
+
if (!ready) {
|
|
4267
|
+
btn.title = llmProviderUnavailableReason(id);
|
|
4268
|
+
} else {
|
|
4269
|
+
btn.removeAttribute('title');
|
|
4270
|
+
}
|
|
4135
4271
|
});
|
|
4136
4272
|
}
|
|
4273
|
+
updateLlmProviderHint(cfg);
|
|
4137
4274
|
updateModelLabel();
|
|
4138
4275
|
}
|
|
4139
4276
|
|
|
@@ -4160,6 +4297,13 @@ async function saveTicketSource(next) {
|
|
|
4160
4297
|
|
|
4161
4298
|
async function saveLlmProvider(next) {
|
|
4162
4299
|
const provider = next === 'openrouter' ? 'openrouter' : 'claude';
|
|
4300
|
+
if (!llmProviderReady(provider)) {
|
|
4301
|
+
const msg = llmProviderUnavailableReason(provider);
|
|
4302
|
+
setOverviewLlmFeedback(msg, 'error');
|
|
4303
|
+
setSettingsFeedback(msg, 'error');
|
|
4304
|
+
return;
|
|
4305
|
+
}
|
|
4306
|
+
const prev = llmProvider;
|
|
4163
4307
|
updateLlmProviderUI(provider);
|
|
4164
4308
|
clearAvailableModels();
|
|
4165
4309
|
try {
|
|
@@ -4169,11 +4313,18 @@ async function saveLlmProvider(next) {
|
|
|
4169
4313
|
body: JSON.stringify({ llmProvider: provider }),
|
|
4170
4314
|
});
|
|
4171
4315
|
const data = await readJson(res);
|
|
4172
|
-
if (res.ok) {
|
|
4173
|
-
|
|
4316
|
+
if (!res.ok) {
|
|
4317
|
+
updateLlmProviderUI(prev);
|
|
4318
|
+
const msg = data.error || `Update failed (HTTP ${res.status})`;
|
|
4319
|
+
setOverviewLlmFeedback(msg, 'error');
|
|
4320
|
+
setSettingsFeedback(msg, 'error');
|
|
4174
4321
|
await fetchModels({ refresh: true, reconcile: true });
|
|
4322
|
+
return;
|
|
4175
4323
|
}
|
|
4324
|
+
applyConfigSnapshot({ ...appConfig, ...data });
|
|
4325
|
+
await fetchModels({ refresh: true, reconcile: true });
|
|
4176
4326
|
} catch {
|
|
4327
|
+
updateLlmProviderUI(prev);
|
|
4177
4328
|
void fetchModels({ refresh: true, reconcile: true });
|
|
4178
4329
|
}
|
|
4179
4330
|
}
|
|
@@ -4205,7 +4356,7 @@ function setOverviewLlmFeedback(message, kind = 'ok') {
|
|
|
4205
4356
|
*/
|
|
4206
4357
|
function fillOverviewLlmControls(cfg) {
|
|
4207
4358
|
if (!cfg) return;
|
|
4208
|
-
updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
4359
|
+
updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude', cfg);
|
|
4209
4360
|
syncAllModelComboboxValues(currentModel);
|
|
4210
4361
|
}
|
|
4211
4362
|
|
|
@@ -4253,7 +4404,10 @@ function applyConfigSnapshot(data) {
|
|
|
4253
4404
|
updateModelLabel();
|
|
4254
4405
|
fillOverviewLlmControls(data);
|
|
4255
4406
|
updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
|
|
4256
|
-
updateLlmProviderUI(
|
|
4407
|
+
updateLlmProviderUI(
|
|
4408
|
+
data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude',
|
|
4409
|
+
data || appConfig
|
|
4410
|
+
);
|
|
4257
4411
|
if (els.repoName) {
|
|
4258
4412
|
els.repoName.textContent = data?.repoName || 'local repo';
|
|
4259
4413
|
}
|
|
@@ -4650,6 +4804,12 @@ function handleLlmProviderToggleClick(e) {
|
|
|
4650
4804
|
const next = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4651
4805
|
if (next === llmProvider) return;
|
|
4652
4806
|
e.stopPropagation();
|
|
4807
|
+
if (btn.disabled || !llmProviderReady(next)) {
|
|
4808
|
+
const msg = llmProviderUnavailableReason(next);
|
|
4809
|
+
setOverviewLlmFeedback(msg, 'error');
|
|
4810
|
+
setSettingsFeedback(msg, 'error');
|
|
4811
|
+
return;
|
|
4812
|
+
}
|
|
4653
4813
|
void saveLlmProvider(next);
|
|
4654
4814
|
}
|
|
4655
4815
|
|
package/public/index.html
CHANGED
|
@@ -286,6 +286,7 @@
|
|
|
286
286
|
<div class="review-detail" id="review-detail">
|
|
287
287
|
<div class="empty-dashed" id="review-empty-detail">Select a job to review</div>
|
|
288
288
|
<div class="stack hidden" id="review-detail-content">
|
|
289
|
+
<p class="review-agent-meta" id="review-agent-meta"></p>
|
|
289
290
|
<div class="card" id="review-pr-meta-card">
|
|
290
291
|
<div class="field-label">PR title</div>
|
|
291
292
|
<input
|
|
@@ -668,7 +669,7 @@
|
|
|
668
669
|
<button type="button" class="source-btn active" data-provider="claude">Claude</button>
|
|
669
670
|
<button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
|
|
670
671
|
</div>
|
|
671
|
-
<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>
|
|
672
|
+
<p class="field-hint" id="settings-llm-provider-hint">Claude uses the Claude Agent SDK. OpenRouter uses <code>@openrouter/agent</code> with the same coding tools (any catalog model). A provider stays disabled until it is configured and authenticated.</p>
|
|
672
673
|
</div>
|
|
673
674
|
<div class="settings-grid">
|
|
674
675
|
<div class="settings-field">
|
package/public/styles.css
CHANGED
|
@@ -1064,6 +1064,13 @@ a { color: var(--primary); text-underline-offset: 3px; }
|
|
|
1064
1064
|
color: var(--text-muted);
|
|
1065
1065
|
}
|
|
1066
1066
|
|
|
1067
|
+
.review-agent-meta {
|
|
1068
|
+
margin: 0 0 4px;
|
|
1069
|
+
font-size: 13px;
|
|
1070
|
+
color: var(--text-muted);
|
|
1071
|
+
font-variant-numeric: tabular-nums;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1067
1074
|
.btn-remove {
|
|
1068
1075
|
width: 26px;
|
|
1069
1076
|
height: 26px;
|
|
@@ -2468,7 +2475,7 @@ body.diff-fs-open {
|
|
|
2468
2475
|
letter-spacing: 0.02em;
|
|
2469
2476
|
}
|
|
2470
2477
|
|
|
2471
|
-
.source-btn:hover:not(.active) {
|
|
2478
|
+
.source-btn:hover:not(.active):not(:disabled) {
|
|
2472
2479
|
color: var(--text);
|
|
2473
2480
|
background: color-mix(in srgb, var(--text) 4%, transparent);
|
|
2474
2481
|
}
|
|
@@ -2480,6 +2487,16 @@ body.diff-fs-open {
|
|
|
2480
2487
|
box-shadow: var(--shadow-sm);
|
|
2481
2488
|
}
|
|
2482
2489
|
|
|
2490
|
+
.source-btn:disabled {
|
|
2491
|
+
opacity: 0.45;
|
|
2492
|
+
cursor: not-allowed;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
.source-btn.active:disabled {
|
|
2496
|
+
opacity: 0.7;
|
|
2497
|
+
cursor: default;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2483
2500
|
.enqueue-jira-hint {
|
|
2484
2501
|
margin: 0;
|
|
2485
2502
|
font-size: 12px;
|
package/src/config.js
CHANGED
|
@@ -91,6 +91,42 @@ export function normalizeLlmProvider(value) {
|
|
|
91
91
|
return value === 'openrouter' ? 'openrouter' : 'claude';
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Block enabling an LLM provider that is not authenticated.
|
|
96
|
+
* Stub-agent skips the check (UI-only testing without credentials).
|
|
97
|
+
* @param {unknown} nextProvider
|
|
98
|
+
* @param {{
|
|
99
|
+
* stubAgent?: boolean,
|
|
100
|
+
* claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
|
|
101
|
+
* openrouterAuth?: import('./openrouter-auth.js').OpenRouterAuthResult,
|
|
102
|
+
* }} [opts]
|
|
103
|
+
* @returns {{ error: string, code: string } | null}
|
|
104
|
+
*/
|
|
105
|
+
export function llmProviderAuthGate(nextProvider, opts = {}) {
|
|
106
|
+
if (opts.stubAgent === true) return null;
|
|
107
|
+
const provider = normalizeLlmProvider(nextProvider);
|
|
108
|
+
if (provider === 'openrouter') {
|
|
109
|
+
const or = opts.openrouterAuth ?? checkOpenRouterAuth();
|
|
110
|
+
if (!or.ok) {
|
|
111
|
+
return {
|
|
112
|
+
error:
|
|
113
|
+
'OpenRouter is not authenticated. Add an API key in Settings → Authentication before enabling OpenRouter.',
|
|
114
|
+
code: 'openrouter_auth_required',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const claude = opts.claudeAuth ?? checkClaudeAuth();
|
|
120
|
+
if (!claude.ok) {
|
|
121
|
+
return {
|
|
122
|
+
error:
|
|
123
|
+
'Claude is not authenticated. Add an API key or OAuth token in Settings → Authentication, or run claude auth login, before enabling Claude.',
|
|
124
|
+
code: 'claude_auth_required',
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
94
130
|
/**
|
|
95
131
|
* @param {unknown} raw
|
|
96
132
|
* @returns {{ claude?: string, openrouter?: string }}
|
package/src/server.js
CHANGED
|
@@ -33,11 +33,16 @@ import {
|
|
|
33
33
|
runAgentOnReviewFeedback,
|
|
34
34
|
stripAiAttribution,
|
|
35
35
|
} from './agent.js';
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
publicConfig,
|
|
38
|
+
updateConfig,
|
|
39
|
+
normalizeLlmProvider,
|
|
40
|
+
llmProviderAuthGate,
|
|
41
|
+
} from './config.js';
|
|
37
42
|
import { upsertEnvVars } from './env.js';
|
|
38
43
|
import { listModels } from './models.js';
|
|
39
44
|
import { splitIssueUrls } from './urls.js';
|
|
40
|
-
import { usageFromLogs, withJobUsage } from './usage.js';
|
|
45
|
+
import { usageFromLogs, withJobUsage, snapshotJobLlm, tagUsageProvider } from './usage.js';
|
|
41
46
|
import { checkGhAuth } from './gh-auth.js';
|
|
42
47
|
import { checkClaudeAuth } from './claude-auth.js';
|
|
43
48
|
import { checkOpenRouterAuth } from './openrouter-auth.js';
|
|
@@ -304,6 +309,25 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
304
309
|
return store.updateJob(job.id, { logs });
|
|
305
310
|
}
|
|
306
311
|
|
|
312
|
+
/**
|
|
313
|
+
* Persist current LLM provider/model on the job and emit a log line.
|
|
314
|
+
* @param {string} jobId
|
|
315
|
+
*/
|
|
316
|
+
function stampJobLlm(jobId) {
|
|
317
|
+
const snap = snapshotJobLlm(config);
|
|
318
|
+
let job = store.getJob(jobId);
|
|
319
|
+
if (!job) return undefined;
|
|
320
|
+
job = store.updateJob(jobId, snap);
|
|
321
|
+
job = appendLog(job, 'llm', {
|
|
322
|
+
provider: snap.llmProvider,
|
|
323
|
+
model: snap.model,
|
|
324
|
+
});
|
|
325
|
+
if (job?.logs?.length) {
|
|
326
|
+
emitEvent(jobId, job.logs[job.logs.length - 1]);
|
|
327
|
+
}
|
|
328
|
+
return job;
|
|
329
|
+
}
|
|
330
|
+
|
|
307
331
|
function setStatus(jobId, status, extra = {}) {
|
|
308
332
|
if (!store.getJob(jobId)) return undefined;
|
|
309
333
|
const job = store.updateJob(jobId, { status, ...extra });
|
|
@@ -330,6 +354,9 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
330
354
|
return;
|
|
331
355
|
}
|
|
332
356
|
|
|
357
|
+
job = stampJobLlm(jobId);
|
|
358
|
+
if (!job) return;
|
|
359
|
+
|
|
333
360
|
try {
|
|
334
361
|
job = setStatus(jobId, 'syncing');
|
|
335
362
|
if (!job) return;
|
|
@@ -432,7 +459,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
432
459
|
prTitle: stripAiAttribution(prTitle),
|
|
433
460
|
prBody: stripAiAttribution(prBody),
|
|
434
461
|
};
|
|
435
|
-
if (usage) patch.usage = usage;
|
|
462
|
+
if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
|
|
436
463
|
setStatus(jobId, 'awaiting_review', patch);
|
|
437
464
|
} catch (err) {
|
|
438
465
|
const message = formatAgentJobError(err);
|
|
@@ -445,7 +472,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
445
472
|
status: 'failed',
|
|
446
473
|
error: message,
|
|
447
474
|
};
|
|
448
|
-
if (usage) failPatch.usage = usage;
|
|
475
|
+
if (usage) failPatch.usage = tagUsageProvider(usage, config.llmProvider);
|
|
449
476
|
store.updateJob(jobId, failPatch);
|
|
450
477
|
const updated = store.getJob(jobId);
|
|
451
478
|
if (updated?.logs?.length) {
|
|
@@ -492,6 +519,9 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
492
519
|
return;
|
|
493
520
|
}
|
|
494
521
|
|
|
522
|
+
job = stampJobLlm(jobId);
|
|
523
|
+
if (!job) return;
|
|
524
|
+
|
|
495
525
|
try {
|
|
496
526
|
const onEvent = (message) => {
|
|
497
527
|
const current = store.getJob(jobId);
|
|
@@ -533,7 +563,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
533
563
|
prBody: stripAiAttribution(prBody),
|
|
534
564
|
pendingReviewFeedback: undefined,
|
|
535
565
|
};
|
|
536
|
-
if (usage) patch.usage = usage;
|
|
566
|
+
if (usage) patch.usage = tagUsageProvider(usage, config.llmProvider);
|
|
537
567
|
setStatus(jobId, 'awaiting_review', patch);
|
|
538
568
|
} catch (err) {
|
|
539
569
|
const message = formatAgentJobError(err);
|
|
@@ -547,7 +577,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
547
577
|
error: message,
|
|
548
578
|
pendingReviewFeedback: undefined,
|
|
549
579
|
};
|
|
550
|
-
if (usage) failPatch.usage = usage;
|
|
580
|
+
if (usage) failPatch.usage = tagUsageProvider(usage, config.llmProvider);
|
|
551
581
|
store.updateJob(jobId, failPatch);
|
|
552
582
|
const updated = store.getJob(jobId);
|
|
553
583
|
if (updated?.logs?.length) {
|
|
@@ -657,6 +687,21 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
657
687
|
openrouterApiKey: _or,
|
|
658
688
|
...configPatch
|
|
659
689
|
} = patch;
|
|
690
|
+
|
|
691
|
+
if (configPatch.llmProvider === 'claude' || configPatch.llmProvider === 'openrouter') {
|
|
692
|
+
const next = configPatch.llmProvider;
|
|
693
|
+
if (next !== normalizeLlmProvider(config.llmProvider)) {
|
|
694
|
+
const gate = llmProviderAuthGate(next, {
|
|
695
|
+
stubAgent: useStubAgent,
|
|
696
|
+
claudeAuth: doCheckClaudeAuth(),
|
|
697
|
+
openrouterAuth: checkOpenRouterAuth(),
|
|
698
|
+
});
|
|
699
|
+
if (gate) {
|
|
700
|
+
return res.status(400).json(gate);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
660
705
|
updateConfig(repoRoot, config, configPatch);
|
|
661
706
|
res.json(publicConfigPayload());
|
|
662
707
|
} catch (err) {
|
|
@@ -796,12 +841,15 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
796
841
|
skipped.push(item.jiraKey || item.url);
|
|
797
842
|
continue;
|
|
798
843
|
}
|
|
844
|
+
const llm = snapshotJobLlm(config);
|
|
799
845
|
const job = store.addJob({
|
|
800
846
|
issueUrl: item.url,
|
|
801
847
|
issueNumber: item.number,
|
|
802
848
|
ticketSource: item.ticketSource,
|
|
803
849
|
jiraKey: item.jiraKey,
|
|
804
850
|
...(preferredBranchName ? { preferredBranchName } : {}),
|
|
851
|
+
llmProvider: llm.llmProvider,
|
|
852
|
+
model: llm.model,
|
|
805
853
|
});
|
|
806
854
|
created.push(job);
|
|
807
855
|
}
|
|
@@ -1135,6 +1183,7 @@ export function createServer({ repoRoot, config, store, useStubAgent = false, de
|
|
|
1135
1183
|
usage: undefined,
|
|
1136
1184
|
pendingReviewFeedback: undefined,
|
|
1137
1185
|
latestReviewComments: undefined,
|
|
1186
|
+
...snapshotJobLlm(config),
|
|
1138
1187
|
});
|
|
1139
1188
|
updated = appendLog(updated, 'status', 'retry queued');
|
|
1140
1189
|
res.json(updated);
|
package/src/store.js
CHANGED
|
@@ -53,21 +53,27 @@ export class Store {
|
|
|
53
53
|
* @param {{
|
|
54
54
|
* issueUrl: string,
|
|
55
55
|
* issueNumber?: number,
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
* ticketSource?: 'github' | 'jira',
|
|
57
|
+
* jiraKey?: string,
|
|
58
|
+
* preferredBranchName?: string,
|
|
59
|
+
* llmProvider?: 'claude' | 'openrouter',
|
|
60
|
+
* model?: string,
|
|
61
|
+
* }} data
|
|
62
|
+
* @returns {Job}
|
|
63
|
+
*/
|
|
62
64
|
addJob(data) {
|
|
63
65
|
const now = new Date().toISOString();
|
|
64
66
|
const ticketSource = data.ticketSource === 'jira' ? 'jira' : 'github';
|
|
67
|
+
const llmProvider = data.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
68
|
+
const model = typeof data.model === 'string' ? data.model.trim() : '';
|
|
65
69
|
/** @type {Job} */
|
|
66
70
|
const job = {
|
|
67
71
|
id: randomUUID(),
|
|
68
72
|
issueUrl: data.issueUrl,
|
|
69
73
|
issueNumber: data.issueNumber,
|
|
70
74
|
ticketSource,
|
|
75
|
+
llmProvider,
|
|
76
|
+
...(model ? { model } : {}),
|
|
71
77
|
...(data.jiraKey ? { jiraKey: data.jiraKey } : {}),
|
|
72
78
|
...(data.preferredBranchName ? { preferredBranchName: data.preferredBranchName } : {}),
|
|
73
79
|
status: 'queued',
|
package/src/usage.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* @property {number} [cacheCreationInputTokens]
|
|
12
12
|
* @property {number} [numTurns]
|
|
13
13
|
* @property {number} [durationMs]
|
|
14
|
+
* @property {'claude' | 'openrouter'} [provider]
|
|
14
15
|
* @property {Record<string, object>} [modelUsage]
|
|
15
16
|
*/
|
|
16
17
|
|
|
@@ -23,6 +24,73 @@ function asFiniteNumber(n) {
|
|
|
23
24
|
return n;
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
/**
|
|
28
|
+
* @param {object | null | undefined} job
|
|
29
|
+
* @returns {'claude' | 'openrouter'}
|
|
30
|
+
*/
|
|
31
|
+
export function jobLlmProvider(job) {
|
|
32
|
+
if (job?.llmProvider === 'openrouter') return 'openrouter';
|
|
33
|
+
if (job?.llmProvider === 'claude') return 'claude';
|
|
34
|
+
if (job?.usage?.provider === 'openrouter') return 'openrouter';
|
|
35
|
+
if (job?.usage?.provider === 'claude') return 'claude';
|
|
36
|
+
const model = String(job?.model || '');
|
|
37
|
+
return model.includes('/') ? 'openrouter' : 'claude';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Snapshot of the LLM used for a job (enqueue / run start).
|
|
42
|
+
* @param {object | null | undefined} config
|
|
43
|
+
* @returns {{ llmProvider: 'claude' | 'openrouter', model: string }}
|
|
44
|
+
*/
|
|
45
|
+
export function snapshotJobLlm(config) {
|
|
46
|
+
const llmProvider = config?.llmProvider === 'openrouter' ? 'openrouter' : 'claude';
|
|
47
|
+
const model = typeof config?.model === 'string' ? config.model.trim() : '';
|
|
48
|
+
return { llmProvider, model: model || '-' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {JobUsage | null | undefined} usage
|
|
53
|
+
* @param {'claude' | 'openrouter' | string | undefined} provider
|
|
54
|
+
* @returns {JobUsage | null | undefined}
|
|
55
|
+
*/
|
|
56
|
+
export function tagUsageProvider(usage, provider) {
|
|
57
|
+
if (!usage || typeof usage !== 'object') return usage;
|
|
58
|
+
const p = provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
59
|
+
return { ...usage, provider: p };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {{ totalCostUsd: number, totalTokens: number, withCost: number, withTokens: number }} UsageAgg
|
|
64
|
+
* @param {object[]} jobs
|
|
65
|
+
* @returns {{ claude: UsageAgg, openrouter: UsageAgg }}
|
|
66
|
+
*/
|
|
67
|
+
export function aggregateUsageByProvider(jobs) {
|
|
68
|
+
const empty = () => ({
|
|
69
|
+
totalCostUsd: 0,
|
|
70
|
+
totalTokens: 0,
|
|
71
|
+
withCost: 0,
|
|
72
|
+
withTokens: 0,
|
|
73
|
+
});
|
|
74
|
+
/** @type {{ claude: UsageAgg, openrouter: UsageAgg }} */
|
|
75
|
+
const out = { claude: empty(), openrouter: empty() };
|
|
76
|
+
if (!Array.isArray(jobs)) return out;
|
|
77
|
+
for (const job of jobs) {
|
|
78
|
+
const u = job?.usage;
|
|
79
|
+
if (!u) continue;
|
|
80
|
+
const bucket = out[jobLlmProvider(job)];
|
|
81
|
+
if (typeof u.totalCostUsd === 'number' && Number.isFinite(u.totalCostUsd)) {
|
|
82
|
+
bucket.totalCostUsd += u.totalCostUsd;
|
|
83
|
+
bucket.withCost += 1;
|
|
84
|
+
}
|
|
85
|
+
const tok = (u.inputTokens || 0) + (u.outputTokens || 0);
|
|
86
|
+
if (tok > 0) {
|
|
87
|
+
bucket.totalTokens += tok;
|
|
88
|
+
bucket.withTokens += 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
26
94
|
/**
|
|
27
95
|
* Extract usage from an SDK `result` message (success or error subtypes).
|
|
28
96
|
* @param {object | null | undefined} message
|