acdev 1.0.4 → 1.0.6
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/README.md +25 -13
- package/bin/acdev.js +23 -18
- package/package.json +1 -1
- package/public/app.js +1167 -124
- package/public/index.html +154 -17
- package/public/styles.css +590 -33
- package/src/agent.js +122 -64
- package/src/claude-auth.js +7 -13
- package/src/config.js +181 -11
- package/src/gh-auth.js +11 -10
- package/src/models.js +230 -39
- package/src/openrouter-auth.js +37 -0
- package/src/server.js +59 -15
package/src/agent.js
CHANGED
|
@@ -2,6 +2,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
|
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { promisify } from 'node:util';
|
|
4
4
|
import { ensureNoAiAttributionSettings, getIssueTitle } from './git.js';
|
|
5
|
+
import { isValidLlmProvider } from './models.js';
|
|
5
6
|
import { extractUsageFromResult } from './usage.js';
|
|
6
7
|
|
|
7
8
|
const execFileAsync = promisify(execFile);
|
|
@@ -396,6 +397,61 @@ async function fetchIssueTitle(issueUrl, worktreePath) {
|
|
|
396
397
|
*/
|
|
397
398
|
export const DISALLOWED_AGENT_TOOLS = ['Task', 'TaskOutput', 'AskUserQuestion'];
|
|
398
399
|
|
|
400
|
+
const OPENROUTER_ANTHROPIC_BASE_URL = 'https://openrouter.ai/api';
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Run an agent query with provider-specific Anthropic env overrides.
|
|
404
|
+
* OpenRouter uses Claude Agent SDK via Anthropic-compatible "skin" routing.
|
|
405
|
+
* @template T
|
|
406
|
+
* @param {object} config
|
|
407
|
+
* @param {() => Promise<T>} fn
|
|
408
|
+
* @returns {Promise<T>}
|
|
409
|
+
*/
|
|
410
|
+
export async function withLlmProviderEnv(config, fn) {
|
|
411
|
+
const provider = isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude';
|
|
412
|
+
if (provider !== 'openrouter') {
|
|
413
|
+
return fn();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const apiKey = (process.env.OPENROUTER_API_KEY || '').trim();
|
|
417
|
+
if (!apiKey) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
'OpenRouter is not authenticated. Add OPENROUTER_API_KEY in Settings → Authentication.'
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** @type {Record<string, string | undefined>} */
|
|
424
|
+
const saved = {
|
|
425
|
+
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
|
426
|
+
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
|
427
|
+
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
|
428
|
+
HTTP_REFERER: process.env.HTTP_REFERER,
|
|
429
|
+
X_TITLE: process.env.X_TITLE,
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
process.env.ANTHROPIC_BASE_URL = OPENROUTER_ANTHROPIC_BASE_URL;
|
|
433
|
+
process.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
|
434
|
+
process.env.ANTHROPIC_API_KEY = '';
|
|
435
|
+
if (!process.env.HTTP_REFERER) {
|
|
436
|
+
process.env.HTTP_REFERER = 'https://github.com/acdev';
|
|
437
|
+
}
|
|
438
|
+
if (!process.env.X_TITLE) {
|
|
439
|
+
process.env.X_TITLE = 'acdev';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
try {
|
|
443
|
+
return await fn();
|
|
444
|
+
} finally {
|
|
445
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
446
|
+
if (value === undefined) {
|
|
447
|
+
delete process.env[key];
|
|
448
|
+
} else {
|
|
449
|
+
process.env[key] = value;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
399
455
|
/**
|
|
400
456
|
* Prefer the first successful SDK result. A known SDK bug can emit a follow-up
|
|
401
457
|
* `error_during_execution` right after success when async subagents finish.
|
|
@@ -422,76 +478,78 @@ export function mergeAgentResult(current, incoming) {
|
|
|
422
478
|
* }} params
|
|
423
479
|
*/
|
|
424
480
|
async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn = query }) {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
481
|
+
return withLlmProviderEnv(config, async () => {
|
|
482
|
+
const timeoutMs = config.agentTimeoutMs ?? 900_000;
|
|
483
|
+
let lastMessage = null;
|
|
484
|
+
let resultMessage = null;
|
|
485
|
+
const abortController = new AbortController();
|
|
486
|
+
|
|
487
|
+
// Claude Agent SDK defaults to injecting Co-Authored-By trailers into commit
|
|
488
|
+
// instructions unless attribution is cleared via Claude settings.
|
|
489
|
+
ensureNoAiAttributionSettings(worktreePath);
|
|
490
|
+
|
|
491
|
+
const runLoop = async () => {
|
|
492
|
+
try {
|
|
493
|
+
for await (const message of queryFn({
|
|
494
|
+
// String prompt = single-turn SDK input (still uses stream-json transport).
|
|
495
|
+
prompt,
|
|
496
|
+
options: {
|
|
497
|
+
cwd: worktreePath,
|
|
498
|
+
allowedTools: config.allowedTools,
|
|
499
|
+
disallowedTools: DISALLOWED_AGENT_TOOLS,
|
|
500
|
+
permissionMode: 'acceptEdits',
|
|
501
|
+
maxTurns: config.maxAgentTurns,
|
|
502
|
+
model: config.model || 'claude-sonnet-5',
|
|
503
|
+
// Load only local settings so our empty attribution wins without
|
|
504
|
+
// pulling in unrelated user settings.
|
|
505
|
+
settingSources: ['local'],
|
|
506
|
+
abortController,
|
|
507
|
+
},
|
|
508
|
+
})) {
|
|
509
|
+
onEvent(message);
|
|
510
|
+
lastMessage = message;
|
|
511
|
+
if (message?.type === 'result') {
|
|
512
|
+
resultMessage = mergeAgentResult(resultMessage, message);
|
|
513
|
+
// First success is authoritative — stop before SDK async-task teardown
|
|
514
|
+
// can overwrite it with a spurious streaming-mode error.
|
|
515
|
+
if (message.subtype === 'success') {
|
|
516
|
+
abortController.abort();
|
|
517
|
+
break;
|
|
518
|
+
}
|
|
461
519
|
}
|
|
462
520
|
}
|
|
521
|
+
} catch (err) {
|
|
522
|
+
// Aborting after success can surface as an iterator/abort error.
|
|
523
|
+
if (resultMessage?.subtype === 'success') return;
|
|
524
|
+
throw err;
|
|
463
525
|
}
|
|
464
|
-
}
|
|
465
|
-
// Aborting after success can surface as an iterator/abort error.
|
|
466
|
-
if (resultMessage?.subtype === 'success') return;
|
|
467
|
-
throw err;
|
|
468
|
-
}
|
|
469
|
-
};
|
|
526
|
+
};
|
|
470
527
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
528
|
+
let timeoutId;
|
|
529
|
+
try {
|
|
530
|
+
await Promise.race([
|
|
531
|
+
runLoop(),
|
|
532
|
+
new Promise((_, reject) => {
|
|
533
|
+
timeoutId = setTimeout(() => {
|
|
534
|
+
abortController.abort();
|
|
535
|
+
reject(new Error(`Agent timed out after ${timeoutMs}ms`));
|
|
536
|
+
}, timeoutMs);
|
|
537
|
+
}),
|
|
538
|
+
]);
|
|
539
|
+
} finally {
|
|
540
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
541
|
+
}
|
|
485
542
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
543
|
+
if (!resultMessage || resultMessage.subtype !== 'success') {
|
|
544
|
+
const detail = lastMessage ? JSON.stringify(lastMessage) : 'no result received';
|
|
545
|
+
throw new Error(`Agent did not complete successfully: ${detail}`);
|
|
546
|
+
}
|
|
490
547
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
548
|
+
const resultText = resultMessage.result ?? '';
|
|
549
|
+
const meta = extractPrMetadata(resultText);
|
|
550
|
+
const usage = extractUsageFromResult(resultMessage);
|
|
551
|
+
return { resultText, meta, usage };
|
|
552
|
+
});
|
|
495
553
|
}
|
|
496
554
|
|
|
497
555
|
function stubAgentResult(onEvent, title, body) {
|
package/src/claude-auth.js
CHANGED
|
@@ -90,22 +90,16 @@ export function publicClaudeAuthMethod(result) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Human-readable startup
|
|
93
|
+
* Human-readable startup warning for a failed {@link checkClaudeAuth}.
|
|
94
|
+
* Soft-auth: this is not fatal — the server still starts so Settings can configure auth.
|
|
94
95
|
* @param {ClaudeAuthResult} [_result]
|
|
95
96
|
*/
|
|
96
97
|
export function formatClaudeAuthError(_result) {
|
|
97
98
|
return [
|
|
98
|
-
'
|
|
99
|
-
'',
|
|
100
|
-
'
|
|
101
|
-
'
|
|
102
|
-
'
|
|
103
|
-
' needs the CLI on this machine.',
|
|
104
|
-
' 2. Subscription token: `claude setup-token`, then set CLAUDE_CODE_OAUTH_TOKEN',
|
|
105
|
-
' in Settings → Claude authentication or `.acdev/.env`',
|
|
106
|
-
' 3. Anthropic API key (API billing): set ANTHROPIC_API_KEY in Settings or `.acdev/.env`',
|
|
107
|
-
'',
|
|
108
|
-
'API keys take precedence over subscription login. For UI-only testing without',
|
|
109
|
-
'auth: pass `--stub-agent`.',
|
|
99
|
+
'⚠ Claude is not authenticated — server will still start.',
|
|
100
|
+
' Open Settings → Authentication to add an API key or OAuth token',
|
|
101
|
+
' (or set ANTHROPIC_API_KEY / CLAUDE_CODE_OAUTH_TOKEN in `.acdev/.env`).',
|
|
102
|
+
' Browser login still needs `claude auth login` on this machine.',
|
|
103
|
+
' For UI-only testing without auth: pass `--stub-agent`.',
|
|
110
104
|
].join('\n');
|
|
111
105
|
}
|
package/src/config.js
CHANGED
|
@@ -6,12 +6,20 @@ import { checkGhAuth, githubTokenFromEnv, originRemoteInfo } from './gh-auth.js'
|
|
|
6
6
|
import { normalizeJiraBaseUrl } from './jira.js';
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_MODEL,
|
|
9
|
-
|
|
9
|
+
DEFAULT_OPENROUTER_MODEL,
|
|
10
|
+
LLM_PROVIDERS,
|
|
11
|
+
NO_MODEL,
|
|
10
12
|
isValidModelId,
|
|
13
|
+
isNoModel,
|
|
14
|
+
isValidLlmProvider,
|
|
15
|
+
defaultModelForProvider,
|
|
16
|
+
isModelIdForProvider,
|
|
17
|
+
curatedModelOptions,
|
|
11
18
|
} from './models.js';
|
|
19
|
+
import { checkOpenRouterAuth } from './openrouter-auth.js';
|
|
12
20
|
import { dataDir } from './paths.js';
|
|
13
21
|
|
|
14
|
-
export { DEFAULT_MODEL, MODEL_OPTIONS } from './models.js';
|
|
22
|
+
export { DEFAULT_MODEL, DEFAULT_OPENROUTER_MODEL, CLAUDE_MODEL_OPTIONS, MODEL_OPTIONS, OPENROUTER_MODEL_OPTIONS, LLM_PROVIDERS, NO_MODEL } from './models.js';
|
|
15
23
|
|
|
16
24
|
/** Tool names the agent may be granted via config. */
|
|
17
25
|
export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
|
|
@@ -61,7 +69,10 @@ const DEFAULTS = {
|
|
|
61
69
|
maxAgentTurns: 30,
|
|
62
70
|
allowedTools: [...KNOWN_TOOLS],
|
|
63
71
|
agentTimeoutMs: 900_000,
|
|
72
|
+
llmProvider: /** @type {'claude' | 'openrouter'} */ ('claude'),
|
|
64
73
|
model: DEFAULT_MODEL,
|
|
74
|
+
/** Last valid model selection per LLM provider (for restore on provider switch). */
|
|
75
|
+
lastModelsByProvider: /** @type {Record<string, string>} */ ({}),
|
|
65
76
|
ticketSource: /** @type {'github' | 'jira'} */ ('github'),
|
|
66
77
|
jiraBaseUrl: '',
|
|
67
78
|
/** PR body phrase for Jira tickets, e.g. "Relates to PROJ-123". */
|
|
@@ -70,6 +81,79 @@ const DEFAULTS = {
|
|
|
70
81
|
githubRules: structuredClone(DEFAULT_GITHUB_RULES),
|
|
71
82
|
};
|
|
72
83
|
|
|
84
|
+
/**
|
|
85
|
+
* @param {unknown} raw
|
|
86
|
+
* @returns {Record<string, string>}
|
|
87
|
+
*/
|
|
88
|
+
function normalizeLastModelsByProvider(raw) {
|
|
89
|
+
/** @type {Record<string, string>} */
|
|
90
|
+
const out = {};
|
|
91
|
+
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
for (const provider of LLM_PROVIDERS) {
|
|
95
|
+
const value = /** @type {Record<string, unknown>} */ (raw)[provider];
|
|
96
|
+
if (typeof value !== 'string' || isNoModel(value) || !isValidModelId(value)) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const id = value.trim();
|
|
100
|
+
if (isModelIdForProvider(id, provider)) {
|
|
101
|
+
out[provider] = id;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* @param {object} config
|
|
109
|
+
* @param {'claude' | 'openrouter'} provider
|
|
110
|
+
* @param {string} model
|
|
111
|
+
*/
|
|
112
|
+
function rememberModelForProvider(config, provider, model) {
|
|
113
|
+
if (!isValidLlmProvider(provider)) return;
|
|
114
|
+
if (isNoModel(model) || !isValidModelId(model)) return;
|
|
115
|
+
const id = String(model).trim();
|
|
116
|
+
if (!isModelIdForProvider(id, provider)) return;
|
|
117
|
+
if (
|
|
118
|
+
!config.lastModelsByProvider ||
|
|
119
|
+
typeof config.lastModelsByProvider !== 'object' ||
|
|
120
|
+
config.lastModelsByProvider === DEFAULTS.lastModelsByProvider
|
|
121
|
+
) {
|
|
122
|
+
config.lastModelsByProvider = {
|
|
123
|
+
...(config.lastModelsByProvider &&
|
|
124
|
+
typeof config.lastModelsByProvider === 'object' &&
|
|
125
|
+
config.lastModelsByProvider !== DEFAULTS.lastModelsByProvider
|
|
126
|
+
? config.lastModelsByProvider
|
|
127
|
+
: {}),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
config.lastModelsByProvider[provider] = id;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pick model for a provider: preferred if in catalog, else last stored, else no selection.
|
|
135
|
+
* @param {object} config
|
|
136
|
+
* @param {'claude' | 'openrouter'} provider
|
|
137
|
+
* @param {string} [preferredModel]
|
|
138
|
+
* @returns {string}
|
|
139
|
+
*/
|
|
140
|
+
function resolveModelForProvider(config, provider, preferredModel) {
|
|
141
|
+
const candidate =
|
|
142
|
+
preferredModel != null ? preferredModel : config.model;
|
|
143
|
+
if (!isNoModel(candidate) && isModelIdForProvider(candidate, provider)) {
|
|
144
|
+
return String(candidate).trim();
|
|
145
|
+
}
|
|
146
|
+
const stored = config.lastModelsByProvider?.[provider];
|
|
147
|
+
if (
|
|
148
|
+
typeof stored === 'string' &&
|
|
149
|
+
!isNoModel(stored) &&
|
|
150
|
+
isModelIdForProvider(stored, provider)
|
|
151
|
+
) {
|
|
152
|
+
return stored.trim();
|
|
153
|
+
}
|
|
154
|
+
return NO_MODEL;
|
|
155
|
+
}
|
|
156
|
+
|
|
73
157
|
/**
|
|
74
158
|
* @param {string} prefix e.g. jiraRules / githubRules
|
|
75
159
|
* @param {unknown} raw
|
|
@@ -210,7 +294,9 @@ function persistable(config) {
|
|
|
210
294
|
maxAgentTurns: config.maxAgentTurns,
|
|
211
295
|
allowedTools: config.allowedTools,
|
|
212
296
|
agentTimeoutMs: config.agentTimeoutMs,
|
|
297
|
+
llmProvider: isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude',
|
|
213
298
|
model: config.model,
|
|
299
|
+
lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
|
|
214
300
|
ticketSource: config.ticketSource,
|
|
215
301
|
jiraBaseUrl: config.jiraBaseUrl || '',
|
|
216
302
|
jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
|
|
@@ -250,9 +336,11 @@ export function loadConfig(repoRoot) {
|
|
|
250
336
|
const initial = {
|
|
251
337
|
...DEFAULTS,
|
|
252
338
|
allowedTools: [...KNOWN_TOOLS],
|
|
339
|
+
lastModelsByProvider: {},
|
|
253
340
|
jiraRules: structuredClone(DEFAULT_JIRA_RULES),
|
|
254
341
|
githubRules: structuredClone(DEFAULT_GITHUB_RULES),
|
|
255
342
|
};
|
|
343
|
+
rememberModelForProvider(initial, 'claude', initial.model);
|
|
256
344
|
fs.writeFileSync(configPath, JSON.stringify(persistable(initial), null, 2) + '\n', 'utf8');
|
|
257
345
|
return initial;
|
|
258
346
|
}
|
|
@@ -262,17 +350,31 @@ export function loadConfig(repoRoot) {
|
|
|
262
350
|
...DEFAULTS,
|
|
263
351
|
...raw,
|
|
264
352
|
allowedTools: raw.allowedTools ?? [...KNOWN_TOOLS],
|
|
353
|
+
lastModelsByProvider: normalizeLastModelsByProvider(raw.lastModelsByProvider),
|
|
265
354
|
jiraRules: normalizeJiraRules(raw.jiraRules),
|
|
266
355
|
githubRules: normalizeGithubRules(raw.githubRules),
|
|
267
356
|
};
|
|
268
357
|
|
|
269
|
-
if (
|
|
270
|
-
config.model
|
|
358
|
+
if (isNoModel(config.model)) {
|
|
359
|
+
if (String(config.model).trim() !== NO_MODEL) {
|
|
360
|
+
config.model = NO_MODEL;
|
|
361
|
+
saveConfig(repoRoot, persistable(config));
|
|
362
|
+
} else {
|
|
363
|
+
config.model = NO_MODEL;
|
|
364
|
+
}
|
|
365
|
+
} else if (!isValidModelId(config.model)) {
|
|
366
|
+
config.model = defaultModelForProvider(
|
|
367
|
+
isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude'
|
|
368
|
+
);
|
|
271
369
|
saveConfig(repoRoot, persistable(config));
|
|
272
370
|
} else {
|
|
273
371
|
config.model = String(config.model).trim();
|
|
274
372
|
}
|
|
275
373
|
|
|
374
|
+
if (!isValidLlmProvider(config.llmProvider)) {
|
|
375
|
+
config.llmProvider = 'claude';
|
|
376
|
+
}
|
|
377
|
+
|
|
276
378
|
if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
|
|
277
379
|
config.ticketSource = 'github';
|
|
278
380
|
}
|
|
@@ -285,6 +387,11 @@ export function loadConfig(repoRoot) {
|
|
|
285
387
|
config.jiraPrLinkPhrase = 'Relates to';
|
|
286
388
|
}
|
|
287
389
|
|
|
390
|
+
const loadedProvider = isValidLlmProvider(config.llmProvider)
|
|
391
|
+
? config.llmProvider
|
|
392
|
+
: 'claude';
|
|
393
|
+
rememberModelForProvider(config, loadedProvider, config.model);
|
|
394
|
+
|
|
288
395
|
return config;
|
|
289
396
|
}
|
|
290
397
|
|
|
@@ -297,13 +404,57 @@ export function loadConfig(repoRoot) {
|
|
|
297
404
|
* @returns {typeof DEFAULTS}
|
|
298
405
|
*/
|
|
299
406
|
export function updateConfig(repoRoot, config, patch) {
|
|
407
|
+
if (!config.lastModelsByProvider || typeof config.lastModelsByProvider !== 'object') {
|
|
408
|
+
config.lastModelsByProvider = normalizeLastModelsByProvider(config.lastModelsByProvider);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const prevProvider = isValidLlmProvider(config.llmProvider)
|
|
412
|
+
? config.llmProvider
|
|
413
|
+
: 'claude';
|
|
414
|
+
let providerChanged = false;
|
|
415
|
+
const willChangeProvider =
|
|
416
|
+
patch.llmProvider !== undefined &&
|
|
417
|
+
isValidLlmProvider(patch.llmProvider) &&
|
|
418
|
+
patch.llmProvider !== prevProvider;
|
|
419
|
+
|
|
420
|
+
if (willChangeProvider) {
|
|
421
|
+
rememberModelForProvider(config, prevProvider, config.model);
|
|
422
|
+
}
|
|
423
|
+
|
|
300
424
|
if (patch.model != null) {
|
|
301
|
-
if (
|
|
425
|
+
if (isNoModel(patch.model)) {
|
|
426
|
+
config.model = NO_MODEL;
|
|
427
|
+
} else if (!isValidModelId(patch.model)) {
|
|
302
428
|
throw new Error(
|
|
303
|
-
`Invalid model "${patch.model}". Expected a non-empty
|
|
429
|
+
`Invalid model "${patch.model}". Expected a non-empty model id or "${NO_MODEL}" for no selection`
|
|
430
|
+
);
|
|
431
|
+
} else {
|
|
432
|
+
config.model = String(patch.model).trim();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (patch.llmProvider !== undefined) {
|
|
437
|
+
if (!isValidLlmProvider(patch.llmProvider)) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`Invalid llmProvider "${patch.llmProvider}". Allowed: ${LLM_PROVIDERS.join(', ')}`
|
|
304
440
|
);
|
|
305
441
|
}
|
|
306
|
-
|
|
442
|
+
providerChanged = willChangeProvider;
|
|
443
|
+
config.llmProvider = patch.llmProvider;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const currentProvider = isValidLlmProvider(config.llmProvider)
|
|
447
|
+
? config.llmProvider
|
|
448
|
+
: 'claude';
|
|
449
|
+
|
|
450
|
+
if (providerChanged) {
|
|
451
|
+
config.model = resolveModelForProvider(config, currentProvider, config.model);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (patch.model != null && !providerChanged) {
|
|
455
|
+
rememberModelForProvider(config, currentProvider, config.model);
|
|
456
|
+
} else if (providerChanged && !isNoModel(config.model)) {
|
|
457
|
+
rememberModelForProvider(config, currentProvider, config.model);
|
|
307
458
|
}
|
|
308
459
|
|
|
309
460
|
if (patch.baseBranch !== undefined) {
|
|
@@ -394,12 +545,18 @@ export function updateConfig(repoRoot, config, patch) {
|
|
|
394
545
|
* stubAgent?: boolean,
|
|
395
546
|
* ghAuth?: import('./gh-auth.js').GhAuthResult,
|
|
396
547
|
* claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
|
|
548
|
+
* openrouterAuth?: import('./openrouter-auth.js').OpenRouterAuthResult,
|
|
397
549
|
* }} [opts]
|
|
398
550
|
*/
|
|
399
551
|
export function publicConfig(config, opts = {}) {
|
|
400
|
-
const
|
|
401
|
-
?
|
|
402
|
-
:
|
|
552
|
+
const llmProvider = isValidLlmProvider(config.llmProvider)
|
|
553
|
+
? config.llmProvider
|
|
554
|
+
: 'claude';
|
|
555
|
+
const model = isNoModel(config.model)
|
|
556
|
+
? NO_MODEL
|
|
557
|
+
: isValidModelId(config.model)
|
|
558
|
+
? String(config.model).trim()
|
|
559
|
+
: defaultModelForProvider(llmProvider);
|
|
403
560
|
const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
|
|
404
561
|
const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
|
|
405
562
|
? config.ticketSource
|
|
@@ -424,13 +581,19 @@ export function publicConfig(config, opts = {}) {
|
|
|
424
581
|
|
|
425
582
|
const ghAuth = opts.ghAuth ?? checkGhAuth();
|
|
426
583
|
const claudeAuth = opts.claudeAuth ?? checkClaudeAuth();
|
|
584
|
+
const openrouterAuth = opts.openrouterAuth ?? checkOpenRouterAuth();
|
|
427
585
|
const origin = originRemoteInfo(opts.repoRoot);
|
|
428
586
|
const ghTokenMask = maskSecret(githubTokenFromEnv());
|
|
429
587
|
const anthropicMask = maskSecret(process.env.ANTHROPIC_API_KEY);
|
|
430
588
|
const claudeOauthMask = maskSecret(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
|
589
|
+
const openrouterMask = maskSecret(process.env.OPENROUTER_API_KEY);
|
|
590
|
+
|
|
591
|
+
const curatedModels = curatedModelOptions(llmProvider);
|
|
431
592
|
|
|
432
593
|
return {
|
|
594
|
+
llmProvider,
|
|
433
595
|
model,
|
|
596
|
+
lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
|
|
434
597
|
baseBranch: config.baseBranch,
|
|
435
598
|
testCommand: config.testCommand ?? null,
|
|
436
599
|
maxAgentTurns: config.maxAgentTurns,
|
|
@@ -438,7 +601,7 @@ export function publicConfig(config, opts = {}) {
|
|
|
438
601
|
allowedTools: Array.isArray(config.allowedTools)
|
|
439
602
|
? [...config.allowedTools]
|
|
440
603
|
: [...KNOWN_TOOLS],
|
|
441
|
-
models:
|
|
604
|
+
models: curatedModels,
|
|
442
605
|
knownTools: KNOWN_TOOLS,
|
|
443
606
|
ticketSource,
|
|
444
607
|
jiraBaseUrl,
|
|
@@ -462,6 +625,13 @@ export function publicConfig(config, opts = {}) {
|
|
|
462
625
|
anthropicApiKeyMasked: anthropicMask.masked,
|
|
463
626
|
claudeOauthTokenSet: claudeOauthMask.set,
|
|
464
627
|
claudeOauthTokenMasked: claudeOauthMask.masked,
|
|
628
|
+
openrouterAuthOk: openrouterAuth.ok === true,
|
|
629
|
+
openrouterApiKeySet: openrouterMask.set,
|
|
630
|
+
openrouterApiKeyMasked: openrouterMask.masked,
|
|
631
|
+
llmAuthOk:
|
|
632
|
+
llmProvider === 'openrouter'
|
|
633
|
+
? openrouterAuth.ok === true
|
|
634
|
+
: claudeAuth.ok === true,
|
|
465
635
|
stubAgent: opts.stubAgent === true,
|
|
466
636
|
...(repoName ? { repoName } : {}),
|
|
467
637
|
};
|
package/src/gh-auth.js
CHANGED
|
@@ -138,21 +138,22 @@ export function checkGhAuth() {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
/**
|
|
141
|
-
* Human-readable startup
|
|
141
|
+
* Human-readable startup warning for a failed {@link checkGhAuth}.
|
|
142
|
+
* Soft-auth: this is not fatal — the server still starts so Settings can configure auth.
|
|
142
143
|
* @param {GhAuthResult} result
|
|
143
144
|
*/
|
|
144
145
|
export function formatGhAuthError(result) {
|
|
145
146
|
if (result.reason === 'not-found') {
|
|
146
|
-
return
|
|
147
|
+
return [
|
|
148
|
+
'⚠ GitHub CLI (gh) not found in PATH — server will still start.',
|
|
149
|
+
' Install it from https://cli.github.com/, then open Settings → Authentication',
|
|
150
|
+
' (or set GH_TOKEN in `.acdev/.env`) before enqueueing jobs.',
|
|
151
|
+
].join('\n');
|
|
147
152
|
}
|
|
148
153
|
return [
|
|
149
|
-
'
|
|
150
|
-
'',
|
|
151
|
-
'
|
|
152
|
-
'
|
|
153
|
-
' 2. Personal access token: Settings → GitHub / PR auth, or set GH_TOKEN in `.acdev/.env`',
|
|
154
|
-
' 3. Non-interactive CLI: echo YOUR_PAT | gh auth login --with-token -h github.com',
|
|
155
|
-
'',
|
|
156
|
-
'SSH remotes can push/fetch git, but do not authenticate gh for issues or PRs.',
|
|
154
|
+
'⚠ GitHub is not authenticated — server will still start.',
|
|
155
|
+
' Open Settings → Authentication to paste a PAT, or set GH_TOKEN in `.acdev/.env`.',
|
|
156
|
+
' Or run: gh auth login -h github.com',
|
|
157
|
+
' (SSH remotes push/fetch git but do not authenticate gh for issues/PRs.)',
|
|
157
158
|
].join('\n');
|
|
158
159
|
}
|