acdev 1.0.7 → 1.0.9
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 +0 -3
- package/README.md +11 -21
- package/bin/acdev.js +3 -13
- package/package.json +1 -2
- package/public/app.js +80 -353
- package/public/index.html +1 -33
- package/public/styles.css +7 -4
- package/src/agent.js +64 -127
- package/src/config.js +45 -151
- package/src/models.js +34 -225
- package/src/server.js +11 -50
- package/src/openrouter-auth.js +0 -37
package/public/index.html
CHANGED
|
@@ -155,10 +155,6 @@
|
|
|
155
155
|
|
|
156
156
|
<div class="enqueue-actions">
|
|
157
157
|
<div class="agent-picker" id="overview-agent-picker" aria-label="Agent">
|
|
158
|
-
<select id="overview-llm-provider" class="sr-only" aria-hidden="true" tabindex="-1">
|
|
159
|
-
<option value="claude">Claude</option>
|
|
160
|
-
<option value="openrouter">OpenRouter</option>
|
|
161
|
-
</select>
|
|
162
158
|
<input type="hidden" id="overview-model" value="">
|
|
163
159
|
<button
|
|
164
160
|
type="button"
|
|
@@ -171,15 +167,11 @@
|
|
|
171
167
|
<span class="agent-pill-icon agent-pill-icon--claude" id="overview-agent-icon" aria-hidden="true"></span>
|
|
172
168
|
<span class="agent-pill-body">
|
|
173
169
|
<span class="agent-pill-label">Agent</span>
|
|
174
|
-
<span class="agent-pill-value" id="overview-agent-value"
|
|
170
|
+
<span class="agent-pill-value" id="overview-agent-value">—</span>
|
|
175
171
|
</span>
|
|
176
172
|
<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>
|
|
177
173
|
</button>
|
|
178
174
|
<div class="agent-picker-panel" id="overview-agent-panel" role="dialog" aria-label="Choose agent model" aria-hidden="true">
|
|
179
|
-
<div class="agent-picker-tabs" role="tablist" aria-label="Provider">
|
|
180
|
-
<button type="button" class="agent-picker-tab is-active" data-provider="claude" role="tab" aria-selected="true">Claude</button>
|
|
181
|
-
<button type="button" class="agent-picker-tab" data-provider="openrouter" role="tab" aria-selected="false">OpenRouter</button>
|
|
182
|
-
</div>
|
|
183
175
|
<label class="sr-only" for="overview-agent-search">Search models</label>
|
|
184
176
|
<input
|
|
185
177
|
id="overview-agent-search"
|
|
@@ -538,21 +530,6 @@
|
|
|
538
530
|
</div>
|
|
539
531
|
</div>
|
|
540
532
|
</div>
|
|
541
|
-
|
|
542
|
-
<div class="card settings-card">
|
|
543
|
-
<div class="rules-heading">OpenRouter authentication</div>
|
|
544
|
-
<p id="settings-openrouter-status" class="auth-status" role="status">Checking OpenRouter auth…</p>
|
|
545
|
-
<div class="settings-grid">
|
|
546
|
-
<div class="settings-field settings-field-full">
|
|
547
|
-
<label class="field-label" for="settings-openrouter-key">OpenRouter API key</label>
|
|
548
|
-
<input id="settings-openrouter-key" class="input" type="password" name="openrouterApiKey" autocomplete="new-password" placeholder="Leave blank to keep existing">
|
|
549
|
-
<button type="button" class="auth-clear hidden" id="settings-openrouter-key-clear">Clear saved API key</button>
|
|
550
|
-
<p class="field-hint" id="settings-openrouter-key-hint">
|
|
551
|
-
Required when LLM provider is OpenRouter. Get a key at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer">openrouter.ai/keys</a>. Stored as <code>OPENROUTER_API_KEY</code> in <code>.acdev/.env</code>.
|
|
552
|
-
</p>
|
|
553
|
-
</div>
|
|
554
|
-
</div>
|
|
555
|
-
</div>
|
|
556
533
|
</div>
|
|
557
534
|
</div>
|
|
558
535
|
|
|
@@ -649,15 +626,6 @@
|
|
|
649
626
|
</p>
|
|
650
627
|
<div class="card settings-card">
|
|
651
628
|
<div class="settings-grid">
|
|
652
|
-
<div class="settings-field">
|
|
653
|
-
<label class="field-label" for="settings-llm-provider">LLM provider</label>
|
|
654
|
-
<select id="settings-llm-provider" class="input" name="llmProvider">
|
|
655
|
-
<option value="claude">Claude (Anthropic / Claude Code)</option>
|
|
656
|
-
<option value="openrouter">OpenRouter</option>
|
|
657
|
-
</select>
|
|
658
|
-
<p class="field-hint" id="settings-llm-provider-hint">Agent runs use the selected provider and model below.</p>
|
|
659
|
-
</div>
|
|
660
|
-
|
|
661
629
|
<div class="settings-field">
|
|
662
630
|
<label class="field-label" for="settings-base-branch">Base branch</label>
|
|
663
631
|
<input id="settings-base-branch" class="input" type="text" name="baseBranch" autocomplete="off" required>
|
package/public/styles.css
CHANGED
|
@@ -556,10 +556,6 @@ a { color: var(--primary); text-underline-offset: 3px; }
|
|
|
556
556
|
background: linear-gradient(145deg, #e8a87c 0%, #d97706 100%);
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
-
.agent-pill-icon--openrouter {
|
|
560
|
-
background: linear-gradient(145deg, #7c8cf5 0%, #5b4fe8 100%);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
559
|
.agent-pill-body {
|
|
564
560
|
flex: 1;
|
|
565
561
|
min-width: 0;
|
|
@@ -2298,6 +2294,13 @@ body.diff-fs-open {
|
|
|
2298
2294
|
cursor: pointer;
|
|
2299
2295
|
}
|
|
2300
2296
|
|
|
2297
|
+
.model-picker-option-title {
|
|
2298
|
+
min-width: 0;
|
|
2299
|
+
overflow: hidden;
|
|
2300
|
+
text-overflow: ellipsis;
|
|
2301
|
+
white-space: nowrap;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2301
2304
|
.model-picker-option:hover,
|
|
2302
2305
|
.model-picker-option.is-active {
|
|
2303
2306
|
background: var(--surface-2);
|
package/src/agent.js
CHANGED
|
@@ -2,7 +2,6 @@ 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';
|
|
6
5
|
import { extractUsageFromResult } from './usage.js';
|
|
7
6
|
|
|
8
7
|
const execFileAsync = promisify(execFile);
|
|
@@ -397,66 +396,6 @@ async function fetchIssueTitle(issueUrl, worktreePath) {
|
|
|
397
396
|
*/
|
|
398
397
|
export const DISALLOWED_AGENT_TOOLS = ['Task', 'TaskOutput', 'AskUserQuestion'];
|
|
399
398
|
|
|
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
|
-
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS,
|
|
431
|
-
};
|
|
432
|
-
|
|
433
|
-
process.env.ANTHROPIC_BASE_URL = OPENROUTER_ANTHROPIC_BASE_URL;
|
|
434
|
-
process.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
|
435
|
-
process.env.ANTHROPIC_API_KEY = '';
|
|
436
|
-
// OpenRouter rejects Anthropic-only beta headers on some models.
|
|
437
|
-
if (!process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) {
|
|
438
|
-
process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = '1';
|
|
439
|
-
}
|
|
440
|
-
if (!process.env.HTTP_REFERER) {
|
|
441
|
-
process.env.HTTP_REFERER = 'https://github.com/acdev';
|
|
442
|
-
}
|
|
443
|
-
if (!process.env.X_TITLE) {
|
|
444
|
-
process.env.X_TITLE = 'acdev';
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
try {
|
|
448
|
-
return await fn();
|
|
449
|
-
} finally {
|
|
450
|
-
for (const [key, value] of Object.entries(saved)) {
|
|
451
|
-
if (value === undefined) {
|
|
452
|
-
delete process.env[key];
|
|
453
|
-
} else {
|
|
454
|
-
process.env[key] = value;
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
399
|
/**
|
|
461
400
|
* Prefer the first successful SDK result. A known SDK bug can emit a follow-up
|
|
462
401
|
* `error_during_execution` right after success when async subagents finish.
|
|
@@ -483,78 +422,76 @@ export function mergeAgentResult(current, incoming) {
|
|
|
483
422
|
* }} params
|
|
484
423
|
*/
|
|
485
424
|
async function runAgentQuery({ prompt, worktreePath, config, onEvent, queryFn = query }) {
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
ensureNoAiAttributionSettings(worktreePath);
|
|
495
|
-
|
|
496
|
-
const runLoop = async () => {
|
|
497
|
-
try {
|
|
498
|
-
for await (const message of queryFn({
|
|
499
|
-
// String prompt = single-turn SDK input (still uses stream-json transport).
|
|
500
|
-
prompt,
|
|
501
|
-
options: {
|
|
502
|
-
cwd: worktreePath,
|
|
503
|
-
allowedTools: config.allowedTools,
|
|
504
|
-
disallowedTools: DISALLOWED_AGENT_TOOLS,
|
|
505
|
-
permissionMode: 'acceptEdits',
|
|
506
|
-
maxTurns: config.maxAgentTurns,
|
|
507
|
-
model: config.model || 'claude-sonnet-5',
|
|
508
|
-
// Load only local settings so our empty attribution wins without
|
|
509
|
-
// pulling in unrelated user settings.
|
|
510
|
-
settingSources: ['local'],
|
|
511
|
-
abortController,
|
|
512
|
-
},
|
|
513
|
-
})) {
|
|
514
|
-
onEvent(message);
|
|
515
|
-
lastMessage = message;
|
|
516
|
-
if (message?.type === 'result') {
|
|
517
|
-
resultMessage = mergeAgentResult(resultMessage, message);
|
|
518
|
-
// First success is authoritative — stop before SDK async-task teardown
|
|
519
|
-
// can overwrite it with a spurious streaming-mode error.
|
|
520
|
-
if (message.subtype === 'success') {
|
|
521
|
-
abortController.abort();
|
|
522
|
-
break;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
} catch (err) {
|
|
527
|
-
// Aborting after success can surface as an iterator/abort error.
|
|
528
|
-
if (resultMessage?.subtype === 'success') return;
|
|
529
|
-
throw err;
|
|
530
|
-
}
|
|
531
|
-
};
|
|
425
|
+
const timeoutMs = config.agentTimeoutMs ?? 900_000;
|
|
426
|
+
let lastMessage = null;
|
|
427
|
+
let resultMessage = null;
|
|
428
|
+
const abortController = new AbortController();
|
|
429
|
+
|
|
430
|
+
// Claude Agent SDK defaults to injecting Co-Authored-By trailers into commit
|
|
431
|
+
// instructions unless attribution is cleared via Claude settings.
|
|
432
|
+
ensureNoAiAttributionSettings(worktreePath);
|
|
532
433
|
|
|
533
|
-
|
|
434
|
+
const runLoop = async () => {
|
|
534
435
|
try {
|
|
535
|
-
await
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
436
|
+
for await (const message of queryFn({
|
|
437
|
+
// String prompt = single-turn SDK input (still uses stream-json transport).
|
|
438
|
+
prompt,
|
|
439
|
+
options: {
|
|
440
|
+
cwd: worktreePath,
|
|
441
|
+
allowedTools: config.allowedTools,
|
|
442
|
+
disallowedTools: DISALLOWED_AGENT_TOOLS,
|
|
443
|
+
permissionMode: 'acceptEdits',
|
|
444
|
+
maxTurns: config.maxAgentTurns,
|
|
445
|
+
model: config.model || 'claude-sonnet-5',
|
|
446
|
+
// Load only local settings so our empty attribution wins without
|
|
447
|
+
// pulling in unrelated user settings.
|
|
448
|
+
settingSources: ['local'],
|
|
449
|
+
abortController,
|
|
450
|
+
},
|
|
451
|
+
})) {
|
|
452
|
+
onEvent(message);
|
|
453
|
+
lastMessage = message;
|
|
454
|
+
if (message?.type === 'result') {
|
|
455
|
+
resultMessage = mergeAgentResult(resultMessage, message);
|
|
456
|
+
// First success is authoritative — stop before SDK async-task teardown
|
|
457
|
+
// can overwrite it with a spurious streaming-mode error.
|
|
458
|
+
if (message.subtype === 'success') {
|
|
539
459
|
abortController.abort();
|
|
540
|
-
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
}
|
|
545
|
-
|
|
460
|
+
break;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
} catch (err) {
|
|
465
|
+
// Aborting after success can surface as an iterator/abort error.
|
|
466
|
+
if (resultMessage?.subtype === 'success') return;
|
|
467
|
+
throw err;
|
|
546
468
|
}
|
|
469
|
+
};
|
|
547
470
|
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
471
|
+
let timeoutId;
|
|
472
|
+
try {
|
|
473
|
+
await Promise.race([
|
|
474
|
+
runLoop(),
|
|
475
|
+
new Promise((_, reject) => {
|
|
476
|
+
timeoutId = setTimeout(() => {
|
|
477
|
+
abortController.abort();
|
|
478
|
+
reject(new Error(`Agent timed out after ${timeoutMs}ms`));
|
|
479
|
+
}, timeoutMs);
|
|
480
|
+
}),
|
|
481
|
+
]);
|
|
482
|
+
} finally {
|
|
483
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
484
|
+
}
|
|
552
485
|
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
486
|
+
if (!resultMessage || resultMessage.subtype !== 'success') {
|
|
487
|
+
const detail = lastMessage ? JSON.stringify(lastMessage) : 'no result received';
|
|
488
|
+
throw new Error(`Agent did not complete successfully: ${detail}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const resultText = resultMessage.result ?? '';
|
|
492
|
+
const meta = extractPrMetadata(resultText);
|
|
493
|
+
const usage = extractUsageFromResult(resultMessage);
|
|
494
|
+
return { resultText, meta, usage };
|
|
558
495
|
}
|
|
559
496
|
|
|
560
497
|
function stubAgentResult(onEvent, title, body) {
|
package/src/config.js
CHANGED
|
@@ -6,20 +6,15 @@ import { checkGhAuth, githubTokenFromEnv, originRemoteInfo } from './gh-auth.js'
|
|
|
6
6
|
import { normalizeJiraBaseUrl } from './jira.js';
|
|
7
7
|
import {
|
|
8
8
|
DEFAULT_MODEL,
|
|
9
|
-
DEFAULT_OPENROUTER_MODEL,
|
|
10
|
-
LLM_PROVIDERS,
|
|
11
9
|
NO_MODEL,
|
|
12
10
|
isValidModelId,
|
|
13
11
|
isNoModel,
|
|
14
|
-
|
|
15
|
-
defaultModelForProvider,
|
|
16
|
-
isModelIdForProvider,
|
|
12
|
+
isClaudeCatalogId,
|
|
17
13
|
curatedModelOptions,
|
|
18
14
|
} from './models.js';
|
|
19
|
-
import { checkOpenRouterAuth } from './openrouter-auth.js';
|
|
20
15
|
import { dataDir } from './paths.js';
|
|
21
16
|
|
|
22
|
-
export { DEFAULT_MODEL,
|
|
17
|
+
export { DEFAULT_MODEL, CLAUDE_MODEL_OPTIONS, MODEL_OPTIONS, NO_MODEL } from './models.js';
|
|
23
18
|
|
|
24
19
|
/** Tool names the agent may be granted via config. */
|
|
25
20
|
export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
|
|
@@ -69,10 +64,7 @@ const DEFAULTS = {
|
|
|
69
64
|
maxAgentTurns: 30,
|
|
70
65
|
allowedTools: [...KNOWN_TOOLS],
|
|
71
66
|
agentTimeoutMs: 900_000,
|
|
72
|
-
llmProvider: /** @type {'claude' | 'openrouter'} */ ('claude'),
|
|
73
67
|
model: DEFAULT_MODEL,
|
|
74
|
-
/** Last valid model selection per LLM provider (for restore on provider switch). */
|
|
75
|
-
lastModelsByProvider: /** @type {Record<string, string>} */ ({}),
|
|
76
68
|
ticketSource: /** @type {'github' | 'jira'} */ ('github'),
|
|
77
69
|
jiraBaseUrl: '',
|
|
78
70
|
/** PR body phrase for Jira tickets, e.g. "Relates to PROJ-123". */
|
|
@@ -82,76 +74,29 @@ const DEFAULTS = {
|
|
|
82
74
|
};
|
|
83
75
|
|
|
84
76
|
/**
|
|
77
|
+
* Migrate legacy OpenRouter model ids to a Claude model.
|
|
85
78
|
* @param {unknown} raw
|
|
86
|
-
* @
|
|
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]
|
|
79
|
+
* @param {string} currentModel
|
|
138
80
|
* @returns {string}
|
|
139
81
|
*/
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
typeof
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
82
|
+
function migrateLegacyModel(raw, currentModel) {
|
|
83
|
+
if (isNoModel(currentModel)) return NO_MODEL;
|
|
84
|
+
if (isClaudeCatalogId(currentModel)) return String(currentModel).trim();
|
|
85
|
+
|
|
86
|
+
const stored =
|
|
87
|
+
raw != null &&
|
|
88
|
+
typeof raw === 'object' &&
|
|
89
|
+
!Array.isArray(raw) &&
|
|
90
|
+
typeof /** @type {Record<string, unknown>} */ (raw).lastModelsByProvider === 'object'
|
|
91
|
+
? /** @type {Record<string, unknown>} */ (
|
|
92
|
+
/** @type {Record<string, unknown>} */ (raw).lastModelsByProvider
|
|
93
|
+
).claude
|
|
94
|
+
: null;
|
|
95
|
+
|
|
96
|
+
if (typeof stored === 'string' && isClaudeCatalogId(stored)) {
|
|
152
97
|
return stored.trim();
|
|
153
98
|
}
|
|
154
|
-
return
|
|
99
|
+
return DEFAULT_MODEL;
|
|
155
100
|
}
|
|
156
101
|
|
|
157
102
|
/**
|
|
@@ -294,9 +239,7 @@ function persistable(config) {
|
|
|
294
239
|
maxAgentTurns: config.maxAgentTurns,
|
|
295
240
|
allowedTools: config.allowedTools,
|
|
296
241
|
agentTimeoutMs: config.agentTimeoutMs,
|
|
297
|
-
llmProvider: isValidLlmProvider(config.llmProvider) ? config.llmProvider : 'claude',
|
|
298
242
|
model: config.model,
|
|
299
|
-
lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
|
|
300
243
|
ticketSource: config.ticketSource,
|
|
301
244
|
jiraBaseUrl: config.jiraBaseUrl || '',
|
|
302
245
|
jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
|
|
@@ -336,11 +279,9 @@ export function loadConfig(repoRoot) {
|
|
|
336
279
|
const initial = {
|
|
337
280
|
...DEFAULTS,
|
|
338
281
|
allowedTools: [...KNOWN_TOOLS],
|
|
339
|
-
lastModelsByProvider: {},
|
|
340
282
|
jiraRules: structuredClone(DEFAULT_JIRA_RULES),
|
|
341
283
|
githubRules: structuredClone(DEFAULT_GITHUB_RULES),
|
|
342
284
|
};
|
|
343
|
-
rememberModelForProvider(initial, 'claude', initial.model);
|
|
344
285
|
fs.writeFileSync(configPath, JSON.stringify(persistable(initial), null, 2) + '\n', 'utf8');
|
|
345
286
|
return initial;
|
|
346
287
|
}
|
|
@@ -350,29 +291,34 @@ export function loadConfig(repoRoot) {
|
|
|
350
291
|
...DEFAULTS,
|
|
351
292
|
...raw,
|
|
352
293
|
allowedTools: raw.allowedTools ?? [...KNOWN_TOOLS],
|
|
353
|
-
lastModelsByProvider: normalizeLastModelsByProvider(raw.lastModelsByProvider),
|
|
354
294
|
jiraRules: normalizeJiraRules(raw.jiraRules),
|
|
355
295
|
githubRules: normalizeGithubRules(raw.githubRules),
|
|
356
296
|
};
|
|
357
297
|
|
|
298
|
+
let needsSave = false;
|
|
299
|
+
|
|
358
300
|
if (isNoModel(config.model)) {
|
|
359
301
|
if (String(config.model).trim() !== NO_MODEL) {
|
|
360
302
|
config.model = NO_MODEL;
|
|
361
|
-
|
|
303
|
+
needsSave = true;
|
|
362
304
|
} else {
|
|
363
305
|
config.model = NO_MODEL;
|
|
364
306
|
}
|
|
365
307
|
} else if (!isValidModelId(config.model)) {
|
|
366
|
-
config.model =
|
|
367
|
-
|
|
368
|
-
);
|
|
369
|
-
saveConfig(repoRoot, persistable(config));
|
|
308
|
+
config.model = DEFAULT_MODEL;
|
|
309
|
+
needsSave = true;
|
|
370
310
|
} else {
|
|
371
|
-
|
|
311
|
+
const migrated = migrateLegacyModel(raw, config.model);
|
|
312
|
+
if (migrated !== String(config.model).trim()) {
|
|
313
|
+
config.model = migrated;
|
|
314
|
+
needsSave = true;
|
|
315
|
+
} else {
|
|
316
|
+
config.model = String(config.model).trim();
|
|
317
|
+
}
|
|
372
318
|
}
|
|
373
319
|
|
|
374
|
-
if (
|
|
375
|
-
|
|
320
|
+
if (raw.llmProvider === 'openrouter' || raw.lastModelsByProvider != null) {
|
|
321
|
+
needsSave = true;
|
|
376
322
|
}
|
|
377
323
|
|
|
378
324
|
if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
|
|
@@ -387,10 +333,9 @@ export function loadConfig(repoRoot) {
|
|
|
387
333
|
config.jiraPrLinkPhrase = 'Relates to';
|
|
388
334
|
}
|
|
389
335
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
rememberModelForProvider(config, loadedProvider, config.model);
|
|
336
|
+
if (needsSave) {
|
|
337
|
+
saveConfig(repoRoot, persistable(config));
|
|
338
|
+
}
|
|
394
339
|
|
|
395
340
|
return config;
|
|
396
341
|
}
|
|
@@ -404,23 +349,6 @@ export function loadConfig(repoRoot) {
|
|
|
404
349
|
* @returns {typeof DEFAULTS}
|
|
405
350
|
*/
|
|
406
351
|
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
|
-
|
|
424
352
|
if (patch.model != null) {
|
|
425
353
|
if (isNoModel(patch.model)) {
|
|
426
354
|
config.model = NO_MODEL;
|
|
@@ -428,33 +356,13 @@ export function updateConfig(repoRoot, config, patch) {
|
|
|
428
356
|
throw new Error(
|
|
429
357
|
`Invalid model "${patch.model}". Expected a non-empty model id or "${NO_MODEL}" for no selection`
|
|
430
358
|
);
|
|
431
|
-
} else {
|
|
432
|
-
config.model = String(patch.model).trim();
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
if (patch.llmProvider !== undefined) {
|
|
437
|
-
if (!isValidLlmProvider(patch.llmProvider)) {
|
|
359
|
+
} else if (!isClaudeCatalogId(String(patch.model).trim())) {
|
|
438
360
|
throw new Error(
|
|
439
|
-
`Invalid
|
|
361
|
+
`Invalid model "${patch.model}". Expected a Claude model id (no provider/ prefix)`
|
|
440
362
|
);
|
|
363
|
+
} else {
|
|
364
|
+
config.model = String(patch.model).trim();
|
|
441
365
|
}
|
|
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);
|
|
458
366
|
}
|
|
459
367
|
|
|
460
368
|
if (patch.baseBranch !== undefined) {
|
|
@@ -545,18 +453,14 @@ export function updateConfig(repoRoot, config, patch) {
|
|
|
545
453
|
* stubAgent?: boolean,
|
|
546
454
|
* ghAuth?: import('./gh-auth.js').GhAuthResult,
|
|
547
455
|
* claudeAuth?: import('./claude-auth.js').ClaudeAuthResult,
|
|
548
|
-
* openrouterAuth?: import('./openrouter-auth.js').OpenRouterAuthResult,
|
|
549
456
|
* }} [opts]
|
|
550
457
|
*/
|
|
551
458
|
export function publicConfig(config, opts = {}) {
|
|
552
|
-
const llmProvider = isValidLlmProvider(config.llmProvider)
|
|
553
|
-
? config.llmProvider
|
|
554
|
-
: 'claude';
|
|
555
459
|
const model = isNoModel(config.model)
|
|
556
460
|
? NO_MODEL
|
|
557
|
-
: isValidModelId(config.model)
|
|
461
|
+
: isValidModelId(config.model) && isClaudeCatalogId(config.model)
|
|
558
462
|
? String(config.model).trim()
|
|
559
|
-
:
|
|
463
|
+
: DEFAULT_MODEL;
|
|
560
464
|
const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
|
|
561
465
|
const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
|
|
562
466
|
? config.ticketSource
|
|
@@ -581,19 +485,15 @@ export function publicConfig(config, opts = {}) {
|
|
|
581
485
|
|
|
582
486
|
const ghAuth = opts.ghAuth ?? checkGhAuth();
|
|
583
487
|
const claudeAuth = opts.claudeAuth ?? checkClaudeAuth();
|
|
584
|
-
const openrouterAuth = opts.openrouterAuth ?? checkOpenRouterAuth();
|
|
585
488
|
const origin = originRemoteInfo(opts.repoRoot);
|
|
586
489
|
const ghTokenMask = maskSecret(githubTokenFromEnv());
|
|
587
490
|
const anthropicMask = maskSecret(process.env.ANTHROPIC_API_KEY);
|
|
588
491
|
const claudeOauthMask = maskSecret(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
|
589
|
-
const openrouterMask = maskSecret(process.env.OPENROUTER_API_KEY);
|
|
590
492
|
|
|
591
|
-
const curatedModels = curatedModelOptions(
|
|
493
|
+
const curatedModels = curatedModelOptions();
|
|
592
494
|
|
|
593
495
|
return {
|
|
594
|
-
llmProvider,
|
|
595
496
|
model,
|
|
596
|
-
lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
|
|
597
497
|
baseBranch: config.baseBranch,
|
|
598
498
|
testCommand: config.testCommand ?? null,
|
|
599
499
|
maxAgentTurns: config.maxAgentTurns,
|
|
@@ -625,13 +525,7 @@ export function publicConfig(config, opts = {}) {
|
|
|
625
525
|
anthropicApiKeyMasked: anthropicMask.masked,
|
|
626
526
|
claudeOauthTokenSet: claudeOauthMask.set,
|
|
627
527
|
claudeOauthTokenMasked: claudeOauthMask.masked,
|
|
628
|
-
|
|
629
|
-
openrouterApiKeySet: openrouterMask.set,
|
|
630
|
-
openrouterApiKeyMasked: openrouterMask.masked,
|
|
631
|
-
llmAuthOk:
|
|
632
|
-
llmProvider === 'openrouter'
|
|
633
|
-
? openrouterAuth.ok === true
|
|
634
|
-
: claudeAuth.ok === true,
|
|
528
|
+
llmAuthOk: claudeAuth.ok === true,
|
|
635
529
|
stubAgent: opts.stubAgent === true,
|
|
636
530
|
...(repoName ? { repoName } : {}),
|
|
637
531
|
};
|