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/.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.11",
|
|
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
|
* }} */
|
|
@@ -331,6 +338,13 @@ const els = {
|
|
|
331
338
|
settingsClaudeOauth: document.getElementById('settings-claude-oauth'),
|
|
332
339
|
settingsClaudeOauthClear: document.getElementById('settings-claude-oauth-clear'),
|
|
333
340
|
settingsClaudeOauthHint: document.getElementById('settings-claude-oauth-hint'),
|
|
341
|
+
settingsOpenrouterStatus: document.getElementById('settings-openrouter-status'),
|
|
342
|
+
settingsOpenrouterKey: document.getElementById('settings-openrouter-key'),
|
|
343
|
+
settingsOpenrouterKeyClear: document.getElementById('settings-openrouter-key-clear'),
|
|
344
|
+
settingsOpenrouterKeyHint: document.getElementById('settings-openrouter-key-hint'),
|
|
345
|
+
settingsLlmProvider: document.getElementById('settings-llm-provider'),
|
|
346
|
+
overviewLlmProvider: document.getElementById('overview-llm-provider'),
|
|
347
|
+
sidebarAgentLabel: document.getElementById('sidebar-agent-label'),
|
|
334
348
|
settingsTabs: document.getElementById('settings-tabs'),
|
|
335
349
|
};
|
|
336
350
|
|
|
@@ -3008,6 +3022,7 @@ function updateModelLabel() {
|
|
|
3008
3022
|
? 'none selected'
|
|
3009
3023
|
: currentModel;
|
|
3010
3024
|
const combined = agentDisplayText(currentModel);
|
|
3025
|
+
const providerLabel = llmProvider === 'openrouter' ? 'OpenRouter' : 'Claude';
|
|
3011
3026
|
|
|
3012
3027
|
if (els.modelCurrent) {
|
|
3013
3028
|
els.modelCurrent.textContent = modelText;
|
|
@@ -3015,7 +3030,21 @@ function updateModelLabel() {
|
|
|
3015
3030
|
}
|
|
3016
3031
|
if (els.modelCombined) {
|
|
3017
3032
|
els.modelCombined.textContent = combined;
|
|
3018
|
-
els.modelCombined.title = combined
|
|
3033
|
+
els.modelCombined.title = `${providerLabel}: ${combined}`;
|
|
3034
|
+
}
|
|
3035
|
+
if (els.modelProvider) {
|
|
3036
|
+
els.modelProvider.textContent = providerLabel;
|
|
3037
|
+
}
|
|
3038
|
+
if (els.sidebarAgentLabel) {
|
|
3039
|
+
els.sidebarAgentLabel.textContent = providerLabel;
|
|
3040
|
+
}
|
|
3041
|
+
if (els.overviewAgentIcon) {
|
|
3042
|
+
els.overviewAgentIcon.classList.toggle('agent-pill-icon--claude', llmProvider !== 'openrouter');
|
|
3043
|
+
els.overviewAgentIcon.classList.toggle('agent-pill-icon--openrouter', llmProvider === 'openrouter');
|
|
3044
|
+
}
|
|
3045
|
+
const pillLabel = els.overviewAgentTrigger?.querySelector('.agent-pill-label');
|
|
3046
|
+
if (pillLabel) {
|
|
3047
|
+
pillLabel.textContent = providerLabel;
|
|
3019
3048
|
}
|
|
3020
3049
|
syncAgentPickerDisplay();
|
|
3021
3050
|
refreshModelSourceHint();
|
|
@@ -3521,6 +3550,16 @@ function reconcileModelForProvider(models, candidate = currentModel, storedCandi
|
|
|
3521
3550
|
* @returns {Array<{id:string,label?:string,name?:string}>}
|
|
3522
3551
|
*/
|
|
3523
3552
|
function defaultModels() {
|
|
3553
|
+
if (llmProvider === 'openrouter') {
|
|
3554
|
+
return [
|
|
3555
|
+
{ id: 'google/gemini-2.5-pro', label: 'Google: Gemini 2.5 Pro' },
|
|
3556
|
+
{ id: 'google/gemini-2.5-flash', label: 'Google: Gemini 2.5 Flash' },
|
|
3557
|
+
{ id: 'openai/gpt-4.1', label: 'OpenAI: GPT-4.1' },
|
|
3558
|
+
{ id: 'openai/gpt-4o', label: 'OpenAI: GPT-4o' },
|
|
3559
|
+
{ id: 'anthropic/claude-sonnet-4.5', label: 'Anthropic: Claude Sonnet 4.5' },
|
|
3560
|
+
{ id: 'anthropic/claude-opus-4.5', label: 'Anthropic: Claude Opus 4.5' },
|
|
3561
|
+
];
|
|
3562
|
+
}
|
|
3524
3563
|
return [
|
|
3525
3564
|
{ id: 'claude-sonnet-5', label: 'Sonnet 5' },
|
|
3526
3565
|
{ id: 'claude-opus-5', label: 'Opus 5' },
|
|
@@ -3532,16 +3571,24 @@ function defaultModels() {
|
|
|
3532
3571
|
}
|
|
3533
3572
|
|
|
3534
3573
|
/**
|
|
3535
|
-
* @param {'anthropic' | 'fallback' | string | undefined} source
|
|
3574
|
+
* @param {'anthropic' | 'fallback' | 'openrouter' | 'openrouter-fallback' | string | undefined} source
|
|
3536
3575
|
*/
|
|
3537
3576
|
function updateModelSourceHint(source) {
|
|
3538
3577
|
if (!els.settingsModelHint) return;
|
|
3539
|
-
if (source === '
|
|
3578
|
+
if (source === 'openrouter') {
|
|
3579
|
+
modelSourceHintBase =
|
|
3580
|
+
'OpenRouter model for agent runs. List loaded from OpenRouter Models API.';
|
|
3581
|
+
} else if (source === 'openrouter-fallback') {
|
|
3582
|
+
modelSourceHintBase =
|
|
3583
|
+
'OpenRouter model for agent runs. Showing a short fallback list (live catalog unavailable).';
|
|
3584
|
+
} else if (source === 'anthropic') {
|
|
3540
3585
|
modelSourceHintBase =
|
|
3541
3586
|
'Claude model for agent runs. List loaded from Anthropic Models API.';
|
|
3542
3587
|
} else if (source === 'fallback') {
|
|
3543
3588
|
modelSourceHintBase =
|
|
3544
3589
|
'Claude model for agent runs. Showing curated Claude Code models (live list unavailable — set an API key or use claude auth login).';
|
|
3590
|
+
} else if (llmProvider === 'openrouter') {
|
|
3591
|
+
modelSourceHintBase = 'OpenRouter model for agent runs.';
|
|
3545
3592
|
} else {
|
|
3546
3593
|
modelSourceHintBase = 'Claude model for agent runs.';
|
|
3547
3594
|
}
|
|
@@ -3611,6 +3658,7 @@ async function fetchModels(opts = {}) {
|
|
|
3611
3658
|
}
|
|
3612
3659
|
const query = new URLSearchParams();
|
|
3613
3660
|
if (refresh) query.set('refresh', '1');
|
|
3661
|
+
if (llmProvider) query.set('provider', llmProvider);
|
|
3614
3662
|
try {
|
|
3615
3663
|
const res = await fetch(`/api/models?${query.toString()}`);
|
|
3616
3664
|
if (!res.ok) {
|
|
@@ -3753,6 +3801,13 @@ function claudeStatusText(cfg) {
|
|
|
3753
3801
|
}
|
|
3754
3802
|
}
|
|
3755
3803
|
|
|
3804
|
+
function openrouterStatusText(cfg) {
|
|
3805
|
+
if (cfg.openrouterAuthOk) {
|
|
3806
|
+
return 'Authenticated via OPENROUTER_API_KEY';
|
|
3807
|
+
}
|
|
3808
|
+
return 'Not authenticated — add an OpenRouter API key';
|
|
3809
|
+
}
|
|
3810
|
+
|
|
3756
3811
|
function fillAuthSettings(cfg) {
|
|
3757
3812
|
if (els.settingsGhStatus) {
|
|
3758
3813
|
els.settingsGhStatus.textContent = ghStatusText(cfg);
|
|
@@ -3811,6 +3866,25 @@ function fillAuthSettings(cfg) {
|
|
|
3811
3866
|
? '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
3867
|
: '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
3868
|
}
|
|
3869
|
+
|
|
3870
|
+
if (els.settingsOpenrouterStatus) {
|
|
3871
|
+
els.settingsOpenrouterStatus.textContent = openrouterStatusText(cfg);
|
|
3872
|
+
els.settingsOpenrouterStatus.className = cfg.openrouterAuthOk
|
|
3873
|
+
? 'auth-status ok'
|
|
3874
|
+
: 'auth-status err';
|
|
3875
|
+
}
|
|
3876
|
+
fillSecretInput(
|
|
3877
|
+
els.settingsOpenrouterKey,
|
|
3878
|
+
els.settingsOpenrouterKeyClear,
|
|
3879
|
+
Boolean(cfg.openrouterApiKeySet),
|
|
3880
|
+
cfg.openrouterApiKeyMasked,
|
|
3881
|
+
'Paste an OpenRouter API key'
|
|
3882
|
+
);
|
|
3883
|
+
if (els.settingsOpenrouterKeyHint) {
|
|
3884
|
+
els.settingsOpenrouterKeyHint.innerHTML = cfg.openrouterApiKeySet
|
|
3885
|
+
? 'API key stored in <code>.acdev/.env</code> as <code>OPENROUTER_API_KEY</code> (not committed). Leave blank to keep.'
|
|
3886
|
+
: 'From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.';
|
|
3887
|
+
}
|
|
3814
3888
|
}
|
|
3815
3889
|
|
|
3816
3890
|
function fillSettingsForm(cfg) {
|
|
@@ -3819,6 +3893,7 @@ function fillSettingsForm(cfg) {
|
|
|
3819
3893
|
fillAuthSettings(cfg);
|
|
3820
3894
|
|
|
3821
3895
|
updateTicketSourceUI(cfg.ticketSource === 'jira' ? 'jira' : 'github');
|
|
3896
|
+
updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
3822
3897
|
|
|
3823
3898
|
if (els.settingsJiraBaseUrl) {
|
|
3824
3899
|
els.settingsJiraBaseUrl.value = cfg.jiraBaseUrl || '';
|
|
@@ -4045,6 +4120,23 @@ function updateTicketSourceUI(source) {
|
|
|
4045
4120
|
}
|
|
4046
4121
|
}
|
|
4047
4122
|
|
|
4123
|
+
/**
|
|
4124
|
+
* @param {'claude' | 'openrouter'} provider
|
|
4125
|
+
*/
|
|
4126
|
+
function updateLlmProviderUI(provider) {
|
|
4127
|
+
llmProvider = provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4128
|
+
|
|
4129
|
+
for (const toggle of [els.settingsLlmProvider, els.overviewLlmProvider]) {
|
|
4130
|
+
if (!toggle) continue;
|
|
4131
|
+
toggle.querySelectorAll('.source-btn').forEach((btn) => {
|
|
4132
|
+
const active = btn.dataset.provider === llmProvider;
|
|
4133
|
+
btn.classList.toggle('active', active);
|
|
4134
|
+
btn.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
4135
|
+
});
|
|
4136
|
+
}
|
|
4137
|
+
updateModelLabel();
|
|
4138
|
+
}
|
|
4139
|
+
|
|
4048
4140
|
/**
|
|
4049
4141
|
* @param {'github' | 'jira'} next
|
|
4050
4142
|
*/
|
|
@@ -4066,6 +4158,26 @@ async function saveTicketSource(next) {
|
|
|
4066
4158
|
}
|
|
4067
4159
|
}
|
|
4068
4160
|
|
|
4161
|
+
async function saveLlmProvider(next) {
|
|
4162
|
+
const provider = next === 'openrouter' ? 'openrouter' : 'claude';
|
|
4163
|
+
updateLlmProviderUI(provider);
|
|
4164
|
+
clearAvailableModels();
|
|
4165
|
+
try {
|
|
4166
|
+
const res = await fetch('/api/config', {
|
|
4167
|
+
method: 'PATCH',
|
|
4168
|
+
headers: { 'Content-Type': 'application/json' },
|
|
4169
|
+
body: JSON.stringify({ llmProvider: provider }),
|
|
4170
|
+
});
|
|
4171
|
+
const data = await readJson(res);
|
|
4172
|
+
if (res.ok) {
|
|
4173
|
+
applyConfigSnapshot({ ...appConfig, ...data });
|
|
4174
|
+
await fetchModels({ refresh: true, reconcile: true });
|
|
4175
|
+
}
|
|
4176
|
+
} catch {
|
|
4177
|
+
void fetchModels({ refresh: true, reconcile: true });
|
|
4178
|
+
}
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4069
4181
|
function updateTimeoutMsHint(minutes) {
|
|
4070
4182
|
if (!els.settingsTimeoutMs) return;
|
|
4071
4183
|
const m = Number(minutes);
|
|
@@ -4093,6 +4205,7 @@ function setOverviewLlmFeedback(message, kind = 'ok') {
|
|
|
4093
4205
|
*/
|
|
4094
4206
|
function fillOverviewLlmControls(cfg) {
|
|
4095
4207
|
if (!cfg) return;
|
|
4208
|
+
updateLlmProviderUI(cfg.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
4096
4209
|
syncAllModelComboboxValues(currentModel);
|
|
4097
4210
|
}
|
|
4098
4211
|
|
|
@@ -4140,6 +4253,7 @@ function applyConfigSnapshot(data) {
|
|
|
4140
4253
|
updateModelLabel();
|
|
4141
4254
|
fillOverviewLlmControls(data);
|
|
4142
4255
|
updateTicketSourceUI(data?.ticketSource === 'jira' ? 'jira' : 'github');
|
|
4256
|
+
updateLlmProviderUI(data?.llmProvider === 'openrouter' ? 'openrouter' : 'claude');
|
|
4143
4257
|
if (els.repoName) {
|
|
4144
4258
|
els.repoName.textContent = data?.repoName || 'local repo';
|
|
4145
4259
|
}
|
|
@@ -4186,6 +4300,7 @@ function readSettingsForm() {
|
|
|
4186
4300
|
testCommand: testTrimmed === '' ? null : testTrimmed,
|
|
4187
4301
|
allowedTools,
|
|
4188
4302
|
ticketSource,
|
|
4303
|
+
llmProvider,
|
|
4189
4304
|
};
|
|
4190
4305
|
|
|
4191
4306
|
const ghToken = secretPatchValue(els.settingsGhToken);
|
|
@@ -4194,6 +4309,8 @@ function readSettingsForm() {
|
|
|
4194
4309
|
if (anthropicApiKey !== undefined) patch.anthropicApiKey = anthropicApiKey;
|
|
4195
4310
|
const claudeOauthToken = secretPatchValue(els.settingsClaudeOauth);
|
|
4196
4311
|
if (claudeOauthToken !== undefined) patch.claudeOauthToken = claudeOauthToken;
|
|
4312
|
+
const openrouterApiKey = secretPatchValue(els.settingsOpenrouterKey);
|
|
4313
|
+
if (openrouterApiKey !== undefined) patch.openrouterApiKey = openrouterApiKey;
|
|
4197
4314
|
|
|
4198
4315
|
if (ticketSource === 'jira' || els.settingsJiraBaseUrl?.value) {
|
|
4199
4316
|
patch.jiraBaseUrl = els.settingsJiraBaseUrl?.value?.trim() || '';
|
|
@@ -4361,6 +4478,12 @@ bindSecretClear(
|
|
|
4361
4478
|
els.settingsClaudeOauthHint,
|
|
4362
4479
|
'Saved CLAUDE_CODE_OAUTH_TOKEN will be removed when you click Save settings.'
|
|
4363
4480
|
);
|
|
4481
|
+
bindSecretClear(
|
|
4482
|
+
els.settingsOpenrouterKeyClear,
|
|
4483
|
+
els.settingsOpenrouterKey,
|
|
4484
|
+
els.settingsOpenrouterKeyHint,
|
|
4485
|
+
'Saved OPENROUTER_API_KEY will be removed when you click Save settings.'
|
|
4486
|
+
);
|
|
4364
4487
|
|
|
4365
4488
|
|
|
4366
4489
|
document.addEventListener('click', (ev) => {
|
|
@@ -4466,9 +4589,6 @@ els.addBtn.addEventListener('click', async () => {
|
|
|
4466
4589
|
|
|
4467
4590
|
const urls = splitIssueUrls(text);
|
|
4468
4591
|
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
4592
|
|
|
4473
4593
|
try {
|
|
4474
4594
|
const res = await fetch('/api/issues', {
|
|
@@ -4524,6 +4644,18 @@ function handleTicketSourceToggleClick(e) {
|
|
|
4524
4644
|
els.settingsTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
|
|
4525
4645
|
els.overviewTicketSource?.addEventListener('click', handleTicketSourceToggleClick);
|
|
4526
4646
|
|
|
4647
|
+
function handleLlmProviderToggleClick(e) {
|
|
4648
|
+
const btn = e.target.closest('.source-btn');
|
|
4649
|
+
if (!btn?.dataset.provider) return;
|
|
4650
|
+
const next = btn.dataset.provider === 'openrouter' ? 'openrouter' : 'claude';
|
|
4651
|
+
if (next === llmProvider) return;
|
|
4652
|
+
e.stopPropagation();
|
|
4653
|
+
void saveLlmProvider(next);
|
|
4654
|
+
}
|
|
4655
|
+
|
|
4656
|
+
els.settingsLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
|
|
4657
|
+
els.overviewLlmProvider?.addEventListener('click', handleLlmProviderToggleClick);
|
|
4658
|
+
|
|
4527
4659
|
els.enqueueJiraSettingsLink?.addEventListener('click', (e) => {
|
|
4528
4660
|
e.preventDefault();
|
|
4529
4661
|
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"
|
|
@@ -548,6 +552,21 @@
|
|
|
548
552
|
</div>
|
|
549
553
|
</div>
|
|
550
554
|
</div>
|
|
555
|
+
|
|
556
|
+
<div class="card settings-card">
|
|
557
|
+
<div class="rules-heading">OpenRouter authentication</div>
|
|
558
|
+
<p id="settings-openrouter-status" class="auth-status" role="status">Checking OpenRouter auth…</p>
|
|
559
|
+
<div class="settings-grid">
|
|
560
|
+
<div class="settings-field settings-field-full">
|
|
561
|
+
<label class="field-label" for="settings-openrouter-key">OpenRouter API key</label>
|
|
562
|
+
<input id="settings-openrouter-key" class="input" type="password" name="openrouterApiKey" autocomplete="new-password" placeholder="Leave blank to keep existing">
|
|
563
|
+
<button type="button" class="auth-clear hidden" id="settings-openrouter-key-clear">Clear saved API key</button>
|
|
564
|
+
<p class="field-hint" id="settings-openrouter-key-hint">
|
|
565
|
+
From openrouter.ai → Keys. Required when the LLM provider is OpenRouter. Stored as <code>OPENROUTER_API_KEY</code>.
|
|
566
|
+
</p>
|
|
567
|
+
</div>
|
|
568
|
+
</div>
|
|
569
|
+
</div>
|
|
551
570
|
</div>
|
|
552
571
|
</div>
|
|
553
572
|
|
|
@@ -643,6 +662,14 @@
|
|
|
643
662
|
Changes save to <code>.acdev/config.json</code> and apply to the next job — no restart needed.
|
|
644
663
|
</p>
|
|
645
664
|
<div class="card settings-card">
|
|
665
|
+
<div class="settings-field settings-field-full">
|
|
666
|
+
<div class="field-label">LLM provider</div>
|
|
667
|
+
<div class="source-toggle" id="settings-llm-provider" role="group" aria-label="LLM provider">
|
|
668
|
+
<button type="button" class="source-btn active" data-provider="claude">Claude</button>
|
|
669
|
+
<button type="button" class="source-btn" data-provider="openrouter">OpenRouter</button>
|
|
670
|
+
</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
|
+
</div>
|
|
646
673
|
<div class="settings-grid">
|
|
647
674
|
<div class="settings-field">
|
|
648
675
|
<label class="field-label" for="settings-base-branch">Base branch</label>
|
|
@@ -682,7 +709,7 @@
|
|
|
682
709
|
></ul>
|
|
683
710
|
</div>
|
|
684
711
|
</div>
|
|
685
|
-
<p class="field-hint" id="settings-model-hint">
|
|
712
|
+
<p class="field-hint" id="settings-model-hint">Model for agent runs.</p>
|
|
686
713
|
</div>
|
|
687
714
|
|
|
688
715
|
<div class="settings-field">
|
package/public/styles.css
CHANGED
|
@@ -588,6 +588,10 @@ a { color: var(--primary); text-underline-offset: 3px; }
|
|
|
588
588
|
background: linear-gradient(145deg, #e8a87c 0%, #d97706 100%);
|
|
589
589
|
}
|
|
590
590
|
|
|
591
|
+
.agent-pill-icon--openrouter {
|
|
592
|
+
background: linear-gradient(145deg, #7c9cff 0%, #4f46e5 100%);
|
|
593
|
+
}
|
|
594
|
+
|
|
591
595
|
.agent-pill-body {
|
|
592
596
|
flex: 1;
|
|
593
597
|
min-width: 0;
|
|
@@ -636,7 +640,7 @@ a { color: var(--primary); text-underline-offset: 3px; }
|
|
|
636
640
|
display: flex;
|
|
637
641
|
flex-direction: column;
|
|
638
642
|
width: min(360px, calc(100vw - 48px));
|
|
639
|
-
max-height:
|
|
643
|
+
max-height: 360px;
|
|
640
644
|
background: var(--surface);
|
|
641
645
|
border: 1px solid var(--border);
|
|
642
646
|
border-radius: 10px;
|
|
@@ -713,6 +717,16 @@ a { color: var(--primary); text-underline-offset: 3px; }
|
|
|
713
717
|
background: transparent;
|
|
714
718
|
}
|
|
715
719
|
|
|
720
|
+
.agent-picker-provider {
|
|
721
|
+
margin: 10px 10px 0;
|
|
722
|
+
max-width: none;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
.agent-picker-provider .source-btn {
|
|
726
|
+
padding: 7px 10px;
|
|
727
|
+
font-size: 12px;
|
|
728
|
+
}
|
|
729
|
+
|
|
716
730
|
.agent-picker-search {
|
|
717
731
|
flex-shrink: 0;
|
|
718
732
|
min-height: 36px;
|
package/src/agent.js
CHANGED
|
@@ -3,6 +3,7 @@ import { execFile } from 'node:child_process';
|
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { ensureNoAiAttributionSettings, getIssueTitle } from './git.js';
|
|
5
5
|
import { extractUsageFromResult } from './usage.js';
|
|
6
|
+
import { runOpenRouterQuery } from './openrouter-agent.js';
|
|
6
7
|
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
8
9
|
|
|
@@ -494,6 +495,42 @@ async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn =
|
|
|
494
495
|
return { resultText, meta, usage };
|
|
495
496
|
}
|
|
496
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Dispatch Claude Agent SDK vs OpenRouter Agent SDK.
|
|
500
|
+
* @param {{
|
|
501
|
+
* prompt: string,
|
|
502
|
+
* worktreePath: string,
|
|
503
|
+
* config: object,
|
|
504
|
+
* onEvent: (message: unknown) => void,
|
|
505
|
+
* queryFn?: typeof query,
|
|
506
|
+
* callModelFn?: (args: object) => object,
|
|
507
|
+
* }} params
|
|
508
|
+
*/
|
|
509
|
+
async function runConfiguredQuery({
|
|
510
|
+
prompt,
|
|
511
|
+
worktreePath,
|
|
512
|
+
config,
|
|
513
|
+
onEvent,
|
|
514
|
+
queryFn,
|
|
515
|
+
callModelFn,
|
|
516
|
+
}) {
|
|
517
|
+
if (config.llmProvider === 'openrouter') {
|
|
518
|
+
const out = await runOpenRouterQuery({
|
|
519
|
+
prompt,
|
|
520
|
+
worktreePath,
|
|
521
|
+
config,
|
|
522
|
+
onEvent,
|
|
523
|
+
callModelFn,
|
|
524
|
+
});
|
|
525
|
+
return {
|
|
526
|
+
resultText: out.resultText,
|
|
527
|
+
meta: extractPrMetadata(out.resultText),
|
|
528
|
+
usage: out.usage,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
return runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn });
|
|
532
|
+
}
|
|
533
|
+
|
|
497
534
|
function stubAgentResult(onEvent, title, body) {
|
|
498
535
|
const fakeResult = {
|
|
499
536
|
type: 'result',
|
|
@@ -543,6 +580,7 @@ function stubAgentResult(onEvent, title, body) {
|
|
|
543
580
|
* jiraKey?: string,
|
|
544
581
|
* jiraIssue?: object,
|
|
545
582
|
* queryFn?: typeof query,
|
|
583
|
+
* callModelFn?: (args: object) => object,
|
|
546
584
|
* }} params
|
|
547
585
|
*/
|
|
548
586
|
export async function runAgentOnIssue({
|
|
@@ -557,6 +595,7 @@ export async function runAgentOnIssue({
|
|
|
557
595
|
jiraKey,
|
|
558
596
|
jiraIssue,
|
|
559
597
|
queryFn = query,
|
|
598
|
+
callModelFn,
|
|
560
599
|
}) {
|
|
561
600
|
if (stub) {
|
|
562
601
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
@@ -567,19 +606,22 @@ export async function runAgentOnIssue({
|
|
|
567
606
|
return stubAgentResult(onEvent, 'Fix issue (stub)', body);
|
|
568
607
|
}
|
|
569
608
|
|
|
570
|
-
const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
609
|
+
const prompt = buildPrompt(issueUrl, config, {
|
|
610
|
+
branchName,
|
|
611
|
+
issueNumber,
|
|
612
|
+
ticketSource,
|
|
613
|
+
jiraKey,
|
|
614
|
+
jiraIssue,
|
|
615
|
+
jiraPrLinkPhrase: config.jiraPrLinkPhrase,
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
const { resultText, meta, usage } = await runConfiguredQuery({
|
|
619
|
+
prompt,
|
|
579
620
|
worktreePath,
|
|
580
621
|
config,
|
|
581
622
|
onEvent,
|
|
582
623
|
queryFn,
|
|
624
|
+
callModelFn,
|
|
583
625
|
});
|
|
584
626
|
|
|
585
627
|
if (meta) {
|
|
@@ -612,6 +654,7 @@ export async function runAgentOnIssue({
|
|
|
612
654
|
* ticketSource?: 'github' | 'jira',
|
|
613
655
|
* jiraKey?: string,
|
|
614
656
|
* queryFn?: typeof query,
|
|
657
|
+
* callModelFn?: (args: object) => object,
|
|
615
658
|
* }} params
|
|
616
659
|
*/
|
|
617
660
|
export async function runAgentOnReviewFeedback({
|
|
@@ -627,6 +670,7 @@ export async function runAgentOnReviewFeedback({
|
|
|
627
670
|
ticketSource,
|
|
628
671
|
jiraKey,
|
|
629
672
|
queryFn = query,
|
|
673
|
+
callModelFn,
|
|
630
674
|
}) {
|
|
631
675
|
if (stub) {
|
|
632
676
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
@@ -637,22 +681,25 @@ export async function runAgentOnReviewFeedback({
|
|
|
637
681
|
return stubAgentResult(onEvent, 'Address review feedback (stub)', body);
|
|
638
682
|
}
|
|
639
683
|
|
|
640
|
-
const
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
684
|
+
const prompt = buildReviewFeedbackPrompt({
|
|
685
|
+
issueUrl,
|
|
686
|
+
generalComment,
|
|
687
|
+
lineComments,
|
|
688
|
+
config,
|
|
689
|
+
branchName,
|
|
690
|
+
issueNumber,
|
|
691
|
+
ticketSource,
|
|
692
|
+
jiraKey,
|
|
693
|
+
jiraPrLinkPhrase: config.jiraPrLinkPhrase,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
const { resultText, meta, usage } = await runConfiguredQuery({
|
|
697
|
+
prompt,
|
|
652
698
|
worktreePath,
|
|
653
699
|
config,
|
|
654
700
|
onEvent,
|
|
655
701
|
queryFn,
|
|
702
|
+
callModelFn,
|
|
656
703
|
});
|
|
657
704
|
|
|
658
705
|
if (meta) {
|