acdev 1.0.9 → 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 +161 -6
- package/public/index.html +47 -2
- package/public/styles.css +47 -1
- package/src/agent.js +68 -21
- package/src/config.js +122 -10
- package/src/git.js +62 -0
- 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 +60 -9
- package/src/store.js +2 -0
- package/src/usage.js +35 -0
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) {
|
package/src/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { checkClaudeAuth, publicClaudeAuthMethod } from './claude-auth.js';
|
|
4
|
+
import { checkOpenRouterAuth } from './openrouter-auth.js';
|
|
4
5
|
import { maskSecret } from './env.js';
|
|
5
6
|
import { checkGhAuth, githubTokenFromEnv, originRemoteInfo } from './gh-auth.js';
|
|
6
7
|
import { normalizeJiraBaseUrl } from './jira.js';
|
|
@@ -10,7 +11,10 @@ import {
|
|
|
10
11
|
isValidModelId,
|
|
11
12
|
isNoModel,
|
|
12
13
|
isClaudeCatalogId,
|
|
14
|
+
isOpenRouterModelId,
|
|
15
|
+
isModelIdForProvider,
|
|
13
16
|
curatedModelOptions,
|
|
17
|
+
OPENROUTER_MODEL_OPTIONS,
|
|
14
18
|
} from './models.js';
|
|
15
19
|
import { dataDir } from './paths.js';
|
|
16
20
|
|
|
@@ -21,6 +25,9 @@ export const KNOWN_TOOLS = ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'];
|
|
|
21
25
|
|
|
22
26
|
export const TICKET_SOURCES = /** @type {const} */ (['github', 'jira']);
|
|
23
27
|
|
|
28
|
+
/** LLM backends. Claude = Agent SDK; OpenRouter = @openrouter/agent. */
|
|
29
|
+
export const LLM_PROVIDERS = /** @type {const} */ (['claude', 'openrouter']);
|
|
30
|
+
|
|
24
31
|
/**
|
|
25
32
|
* Shared post-PR actions for Jira and GitHub Issues rules.
|
|
26
33
|
* - Jira `set_status` / `close_issue`: workflow transitions (close → Done-like status).
|
|
@@ -39,6 +46,7 @@ export const GITHUB_AFTER_PR_ACTIONS = AFTER_PR_ACTIONS;
|
|
|
39
46
|
|
|
40
47
|
const ALLOWED_TOOLS_SET = new Set(KNOWN_TOOLS);
|
|
41
48
|
const ALLOWED_TICKET_SOURCES = new Set(TICKET_SOURCES);
|
|
49
|
+
const ALLOWED_LLM_PROVIDERS = new Set(LLM_PROVIDERS);
|
|
42
50
|
const ALLOWED_AFTER_PR_ACTIONS = new Set(AFTER_PR_ACTIONS);
|
|
43
51
|
|
|
44
52
|
/** @typedef {'none' | 'set_status' | 'add_label' | 'close_issue'} AfterPrAction */
|
|
@@ -65,6 +73,8 @@ const DEFAULTS = {
|
|
|
65
73
|
allowedTools: [...KNOWN_TOOLS],
|
|
66
74
|
agentTimeoutMs: 900_000,
|
|
67
75
|
model: DEFAULT_MODEL,
|
|
76
|
+
llmProvider: /** @type {'claude' | 'openrouter'} */ ('claude'),
|
|
77
|
+
lastModelsByProvider: /** @type {{ claude?: string, openrouter?: string }} */ ({}),
|
|
68
78
|
ticketSource: /** @type {'github' | 'jira'} */ ('github'),
|
|
69
79
|
jiraBaseUrl: '',
|
|
70
80
|
/** PR body phrase for Jira tickets, e.g. "Relates to PROJ-123". */
|
|
@@ -73,6 +83,55 @@ const DEFAULTS = {
|
|
|
73
83
|
githubRules: structuredClone(DEFAULT_GITHUB_RULES),
|
|
74
84
|
};
|
|
75
85
|
|
|
86
|
+
/**
|
|
87
|
+
* @param {unknown} value
|
|
88
|
+
* @returns {'claude' | 'openrouter'}
|
|
89
|
+
*/
|
|
90
|
+
export function normalizeLlmProvider(value) {
|
|
91
|
+
return value === 'openrouter' ? 'openrouter' : 'claude';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @param {unknown} raw
|
|
96
|
+
* @returns {{ claude?: string, openrouter?: string }}
|
|
97
|
+
*/
|
|
98
|
+
function normalizeLastModelsByProvider(raw) {
|
|
99
|
+
/** @type {{ claude?: string, openrouter?: string }} */
|
|
100
|
+
const out = {};
|
|
101
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
|
|
102
|
+
const obj = /** @type {Record<string, unknown>} */ (raw);
|
|
103
|
+
if (typeof obj.claude === 'string' && isValidModelId(obj.claude.trim()) && isClaudeCatalogId(obj.claude.trim())) {
|
|
104
|
+
out.claude = obj.claude.trim();
|
|
105
|
+
}
|
|
106
|
+
if (
|
|
107
|
+
typeof obj.openrouter === 'string' &&
|
|
108
|
+
isValidModelId(obj.openrouter.trim()) &&
|
|
109
|
+
isOpenRouterModelId(obj.openrouter.trim())
|
|
110
|
+
) {
|
|
111
|
+
out.openrouter = obj.openrouter.trim();
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function rememberCurrentModel(config) {
|
|
117
|
+
const provider = normalizeLlmProvider(config.llmProvider);
|
|
118
|
+
const prev = normalizeLastModelsByProvider(config.lastModelsByProvider);
|
|
119
|
+
if (!isNoModel(config.model) && isModelIdForProvider(config.model, provider)) {
|
|
120
|
+
prev[provider] = String(config.model).trim();
|
|
121
|
+
}
|
|
122
|
+
config.lastModelsByProvider = prev;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function restoreModelForProvider(config, provider) {
|
|
126
|
+
const last = normalizeLastModelsByProvider(config.lastModelsByProvider);
|
|
127
|
+
const stored = last[provider];
|
|
128
|
+
if (stored && isModelIdForProvider(stored, provider)) {
|
|
129
|
+
config.model = stored;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
config.model = provider === 'openrouter' ? NO_MODEL : DEFAULT_MODEL;
|
|
133
|
+
}
|
|
134
|
+
|
|
76
135
|
/**
|
|
77
136
|
* Migrate legacy OpenRouter model ids to a Claude model.
|
|
78
137
|
* @param {unknown} raw
|
|
@@ -240,6 +299,8 @@ function persistable(config) {
|
|
|
240
299
|
allowedTools: config.allowedTools,
|
|
241
300
|
agentTimeoutMs: config.agentTimeoutMs,
|
|
242
301
|
model: config.model,
|
|
302
|
+
llmProvider: normalizeLlmProvider(config.llmProvider),
|
|
303
|
+
lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
|
|
243
304
|
ticketSource: config.ticketSource,
|
|
244
305
|
jiraBaseUrl: config.jiraBaseUrl || '',
|
|
245
306
|
jiraPrLinkPhrase: config.jiraPrLinkPhrase || 'Relates to',
|
|
@@ -297,6 +358,17 @@ export function loadConfig(repoRoot) {
|
|
|
297
358
|
|
|
298
359
|
let needsSave = false;
|
|
299
360
|
|
|
361
|
+
if (raw.llmProvider === 'openrouter' || (isValidModelId(raw.model) && isOpenRouterModelId(String(raw.model)))) {
|
|
362
|
+
config.llmProvider = 'openrouter';
|
|
363
|
+
} else if (raw.llmProvider === 'claude' || raw.llmProvider == null) {
|
|
364
|
+
config.llmProvider = 'claude';
|
|
365
|
+
} else if (!ALLOWED_LLM_PROVIDERS.has(raw.llmProvider)) {
|
|
366
|
+
config.llmProvider = 'claude';
|
|
367
|
+
needsSave = true;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
config.lastModelsByProvider = normalizeLastModelsByProvider(raw.lastModelsByProvider);
|
|
371
|
+
|
|
300
372
|
if (isNoModel(config.model)) {
|
|
301
373
|
if (String(config.model).trim() !== NO_MODEL) {
|
|
302
374
|
config.model = NO_MODEL;
|
|
@@ -305,8 +377,14 @@ export function loadConfig(repoRoot) {
|
|
|
305
377
|
config.model = NO_MODEL;
|
|
306
378
|
}
|
|
307
379
|
} else if (!isValidModelId(config.model)) {
|
|
308
|
-
config.model = DEFAULT_MODEL;
|
|
380
|
+
config.model = config.llmProvider === 'openrouter' ? NO_MODEL : DEFAULT_MODEL;
|
|
309
381
|
needsSave = true;
|
|
382
|
+
} else if (config.llmProvider === 'openrouter') {
|
|
383
|
+
config.model = String(config.model).trim();
|
|
384
|
+
if (!isOpenRouterModelId(config.model)) {
|
|
385
|
+
restoreModelForProvider(config, 'openrouter');
|
|
386
|
+
needsSave = true;
|
|
387
|
+
}
|
|
310
388
|
} else {
|
|
311
389
|
const migrated = migrateLegacyModel(raw, config.model);
|
|
312
390
|
if (migrated !== String(config.model).trim()) {
|
|
@@ -317,9 +395,7 @@ export function loadConfig(repoRoot) {
|
|
|
317
395
|
}
|
|
318
396
|
}
|
|
319
397
|
|
|
320
|
-
|
|
321
|
-
needsSave = true;
|
|
322
|
-
}
|
|
398
|
+
rememberCurrentModel(config);
|
|
323
399
|
|
|
324
400
|
if (!ALLOWED_TICKET_SOURCES.has(config.ticketSource)) {
|
|
325
401
|
config.ticketSource = 'github';
|
|
@@ -349,6 +425,24 @@ export function loadConfig(repoRoot) {
|
|
|
349
425
|
* @returns {typeof DEFAULTS}
|
|
350
426
|
*/
|
|
351
427
|
export function updateConfig(repoRoot, config, patch) {
|
|
428
|
+
if (patch.llmProvider !== undefined) {
|
|
429
|
+
if (!ALLOWED_LLM_PROVIDERS.has(patch.llmProvider)) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`Invalid llmProvider "${patch.llmProvider}". Allowed: ${LLM_PROVIDERS.join(', ')}`
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const next = /** @type {'claude' | 'openrouter'} */ (patch.llmProvider);
|
|
435
|
+
if (next !== normalizeLlmProvider(config.llmProvider)) {
|
|
436
|
+
rememberCurrentModel(config);
|
|
437
|
+
config.llmProvider = next;
|
|
438
|
+
if (patch.model == null) {
|
|
439
|
+
restoreModelForProvider(config, next);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const provider = normalizeLlmProvider(config.llmProvider);
|
|
445
|
+
|
|
352
446
|
if (patch.model != null) {
|
|
353
447
|
if (isNoModel(patch.model)) {
|
|
354
448
|
config.model = NO_MODEL;
|
|
@@ -356,15 +450,19 @@ export function updateConfig(repoRoot, config, patch) {
|
|
|
356
450
|
throw new Error(
|
|
357
451
|
`Invalid model "${patch.model}". Expected a non-empty model id or "${NO_MODEL}" for no selection`
|
|
358
452
|
);
|
|
359
|
-
} else if (!
|
|
453
|
+
} else if (!isModelIdForProvider(String(patch.model).trim(), provider)) {
|
|
360
454
|
throw new Error(
|
|
361
|
-
|
|
455
|
+
provider === 'openrouter'
|
|
456
|
+
? `Invalid model "${patch.model}". Expected an OpenRouter model id (provider/model)`
|
|
457
|
+
: `Invalid model "${patch.model}". Expected a Claude model id (no provider/ prefix)`
|
|
362
458
|
);
|
|
363
459
|
} else {
|
|
364
460
|
config.model = String(patch.model).trim();
|
|
365
461
|
}
|
|
366
462
|
}
|
|
367
463
|
|
|
464
|
+
rememberCurrentModel(config);
|
|
465
|
+
|
|
368
466
|
if (patch.baseBranch !== undefined) {
|
|
369
467
|
if (typeof patch.baseBranch !== 'string' || !patch.baseBranch.trim()) {
|
|
370
468
|
throw new Error('baseBranch must be a non-empty string');
|
|
@@ -456,11 +554,14 @@ export function updateConfig(repoRoot, config, patch) {
|
|
|
456
554
|
* }} [opts]
|
|
457
555
|
*/
|
|
458
556
|
export function publicConfig(config, opts = {}) {
|
|
557
|
+
const llmProvider = normalizeLlmProvider(config.llmProvider);
|
|
459
558
|
const model = isNoModel(config.model)
|
|
460
559
|
? NO_MODEL
|
|
461
|
-
:
|
|
560
|
+
: isModelIdForProvider(config.model, llmProvider)
|
|
462
561
|
? String(config.model).trim()
|
|
463
|
-
:
|
|
562
|
+
: llmProvider === 'openrouter'
|
|
563
|
+
? NO_MODEL
|
|
564
|
+
: DEFAULT_MODEL;
|
|
464
565
|
const repoName = opts.repoRoot ? path.basename(opts.repoRoot) : undefined;
|
|
465
566
|
const ticketSource = ALLOWED_TICKET_SOURCES.has(config.ticketSource)
|
|
466
567
|
? config.ticketSource
|
|
@@ -489,11 +590,19 @@ export function publicConfig(config, opts = {}) {
|
|
|
489
590
|
const ghTokenMask = maskSecret(githubTokenFromEnv());
|
|
490
591
|
const anthropicMask = maskSecret(process.env.ANTHROPIC_API_KEY);
|
|
491
592
|
const claudeOauthMask = maskSecret(process.env.CLAUDE_CODE_OAUTH_TOKEN);
|
|
593
|
+
const openrouterMask = maskSecret(process.env.OPENROUTER_API_KEY);
|
|
594
|
+
const openrouterAuth = checkOpenRouterAuth();
|
|
595
|
+
const llmAuthOk = llmProvider === 'openrouter' ? openrouterAuth.ok : claudeAuth.ok === true;
|
|
492
596
|
|
|
493
|
-
const curatedModels =
|
|
597
|
+
const curatedModels =
|
|
598
|
+
llmProvider === 'openrouter'
|
|
599
|
+
? OPENROUTER_MODEL_OPTIONS.map((m) => ({ ...m }))
|
|
600
|
+
: curatedModelOptions();
|
|
494
601
|
|
|
495
602
|
return {
|
|
496
603
|
model,
|
|
604
|
+
llmProvider,
|
|
605
|
+
lastModelsByProvider: normalizeLastModelsByProvider(config.lastModelsByProvider),
|
|
497
606
|
baseBranch: config.baseBranch,
|
|
498
607
|
testCommand: config.testCommand ?? null,
|
|
499
608
|
maxAgentTurns: config.maxAgentTurns,
|
|
@@ -525,7 +634,10 @@ export function publicConfig(config, opts = {}) {
|
|
|
525
634
|
anthropicApiKeyMasked: anthropicMask.masked,
|
|
526
635
|
claudeOauthTokenSet: claudeOauthMask.set,
|
|
527
636
|
claudeOauthTokenMasked: claudeOauthMask.masked,
|
|
528
|
-
|
|
637
|
+
openrouterApiKeySet: openrouterMask.set,
|
|
638
|
+
openrouterApiKeyMasked: openrouterMask.masked,
|
|
639
|
+
openrouterAuthOk: openrouterAuth.ok === true,
|
|
640
|
+
llmAuthOk,
|
|
529
641
|
stubAgent: opts.stubAgent === true,
|
|
530
642
|
...(repoName ? { repoName } : {}),
|
|
531
643
|
};
|
package/src/git.js
CHANGED
|
@@ -103,6 +103,68 @@ export function buildBranchName(type, title) {
|
|
|
103
103
|
return `${type}/${slugifyTitle(title)}`;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
const PREFERRED_BRANCH_MAX = 100;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Sanitize a user-supplied git branch name. Preserves case (e.g. NC-2133).
|
|
110
|
+
* Empty / whitespace-only input returns ''.
|
|
111
|
+
* @param {unknown} raw
|
|
112
|
+
*/
|
|
113
|
+
export function normalizePreferredBranchName(raw) {
|
|
114
|
+
if (raw == null) return '';
|
|
115
|
+
let name = String(raw).trim().replace(/\s+/g, '-');
|
|
116
|
+
if (!name) return '';
|
|
117
|
+
|
|
118
|
+
name = name
|
|
119
|
+
.replace(/[~^:?*\[\\]+/g, '-')
|
|
120
|
+
.replace(/@{/g, '-at-')
|
|
121
|
+
.replace(/[^A-Za-z0-9._/-]+/g, '-')
|
|
122
|
+
.replace(/\/+/g, '/')
|
|
123
|
+
.replace(/\.{2,}/g, '.')
|
|
124
|
+
.replace(/-{2,}/g, '-')
|
|
125
|
+
.replace(/^[-./]+|[-./]+$/g, '');
|
|
126
|
+
|
|
127
|
+
if (/\.lock$/i.test(name)) {
|
|
128
|
+
name = name.replace(/\.lock$/i, '').replace(/[-./]+$/g, '');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
name = name.slice(0, PREFERRED_BRANCH_MAX).replace(/[-./]+$/g, '');
|
|
132
|
+
if (!name || name === '@' || /^HEAD$/i.test(name)) return '';
|
|
133
|
+
return name;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Parse optional branchName from an API body.
|
|
138
|
+
* Omitted / blank → no preference (caller uses feat/fix slug).
|
|
139
|
+
* Provided but invalid after sanitize → error.
|
|
140
|
+
* @param {unknown} raw
|
|
141
|
+
* @returns {{ ok: true, value?: string } | { ok: false, error: string }}
|
|
142
|
+
*/
|
|
143
|
+
export function parsePreferredBranchName(raw) {
|
|
144
|
+
if (raw == null) return { ok: true };
|
|
145
|
+
if (typeof raw !== 'string') {
|
|
146
|
+
return { ok: false, error: 'branchName must be a string' };
|
|
147
|
+
}
|
|
148
|
+
if (!raw.trim()) return { ok: true };
|
|
149
|
+
const value = normalizePreferredBranchName(raw);
|
|
150
|
+
if (!value) {
|
|
151
|
+
return { ok: false, error: 'branchName is not a valid git branch name' };
|
|
152
|
+
}
|
|
153
|
+
return { ok: true, value };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Prefer a user-supplied branch name; otherwise feat/fix slug from the ticket.
|
|
158
|
+
* @param {string | undefined | null} preferred
|
|
159
|
+
* @param {'feat' | 'fix'} issueType
|
|
160
|
+
* @param {string} issueTitle
|
|
161
|
+
*/
|
|
162
|
+
export function resolveDesiredBranchName(preferred, issueType, issueTitle) {
|
|
163
|
+
const custom = normalizePreferredBranchName(preferred);
|
|
164
|
+
if (custom) return custom;
|
|
165
|
+
return buildBranchName(issueType, issueTitle);
|
|
166
|
+
}
|
|
167
|
+
|
|
106
168
|
/**
|
|
107
169
|
* @param {string} repoRoot
|
|
108
170
|
* @param {string | number} worktreeId GitHub issue number or Jira key
|
package/src/models.js
CHANGED
|
@@ -41,6 +41,16 @@ export const CLAUDE_MODEL_OPTIONS = [
|
|
|
41
41
|
/** @deprecated Use CLAUDE_MODEL_OPTIONS */
|
|
42
42
|
export const MODEL_OPTIONS = CLAUDE_MODEL_OPTIONS;
|
|
43
43
|
|
|
44
|
+
/** Small fallback if OpenRouter's live catalog cannot be fetched. */
|
|
45
|
+
export const OPENROUTER_MODEL_OPTIONS = [
|
|
46
|
+
{ id: 'google/gemini-2.5-pro', label: 'Google: Gemini 2.5 Pro' },
|
|
47
|
+
{ id: 'google/gemini-2.5-flash', label: 'Google: Gemini 2.5 Flash' },
|
|
48
|
+
{ id: 'openai/gpt-4.1', label: 'OpenAI: GPT-4.1' },
|
|
49
|
+
{ id: 'openai/gpt-4o', label: 'OpenAI: GPT-4o' },
|
|
50
|
+
{ id: 'anthropic/claude-sonnet-4.5', label: 'Anthropic: Claude Sonnet 4.5' },
|
|
51
|
+
{ id: 'anthropic/claude-opus-4.5', label: 'Anthropic: Claude Opus 4.5' },
|
|
52
|
+
];
|
|
53
|
+
|
|
44
54
|
export const DEFAULT_MODEL = 'claude-sonnet-5';
|
|
45
55
|
|
|
46
56
|
/** Config value meaning no model selected (jobs blocked until user picks one). */
|
|
@@ -50,16 +60,19 @@ export const NO_MODEL = '-';
|
|
|
50
60
|
export const MODEL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._:\/-]{0,127}$/;
|
|
51
61
|
|
|
52
62
|
const ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models';
|
|
63
|
+
const OPENROUTER_MODELS_URL = 'https://openrouter.ai/api/v1/models';
|
|
53
64
|
const ANTHROPIC_VERSION = '2023-06-01';
|
|
54
65
|
const CACHE_TTL_MS = 5 * 60_000;
|
|
55
66
|
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
56
67
|
|
|
57
68
|
/** @typedef {{ id: string, name?: string, label?: string }} ModelOption */
|
|
58
|
-
/** @typedef {'anthropic' | 'fallback'} ModelsSource */
|
|
59
|
-
/** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource }} ModelsListResult */
|
|
69
|
+
/** @typedef {'anthropic' | 'fallback' | 'openrouter' | 'openrouter-fallback'} ModelsSource */
|
|
70
|
+
/** @typedef {{ models: ModelOption[], selected: string, source: ModelsSource, provider: 'claude' | 'openrouter' }} ModelsListResult */
|
|
60
71
|
|
|
61
|
-
/** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected'> } | null} */
|
|
62
|
-
let
|
|
72
|
+
/** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected' | 'provider'> } | null} */
|
|
73
|
+
let claudeCache = null;
|
|
74
|
+
/** @type {{ expiresAt: number, result: Omit<ModelsListResult, 'selected' | 'provider'> } | null} */
|
|
75
|
+
let openrouterCache = null;
|
|
63
76
|
|
|
64
77
|
/** @type {typeof fetch | null} */
|
|
65
78
|
let fetchImpl = null;
|
|
@@ -98,7 +111,8 @@ export function _resetCredentialsTokenResolver() {
|
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
export function _resetModelsCache() {
|
|
101
|
-
|
|
114
|
+
claudeCache = null;
|
|
115
|
+
openrouterCache = null;
|
|
102
116
|
}
|
|
103
117
|
|
|
104
118
|
/**
|
|
@@ -166,13 +180,25 @@ export function isModelInCuratedCatalog(modelId) {
|
|
|
166
180
|
}
|
|
167
181
|
|
|
168
182
|
/**
|
|
169
|
-
*
|
|
183
|
+
* OpenRouter catalog ids are `provider/model` slugs.
|
|
184
|
+
* @param {string} id
|
|
185
|
+
* @returns {boolean}
|
|
186
|
+
*/
|
|
187
|
+
export function isOpenRouterModelId(id) {
|
|
188
|
+
return isValidModelId(id) && String(id).includes('/');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Whether a model id is valid for the given LLM provider.
|
|
170
193
|
* @param {string} modelId
|
|
194
|
+
* @param {'claude' | 'openrouter'} [provider]
|
|
171
195
|
* @returns {boolean}
|
|
172
196
|
*/
|
|
173
|
-
export function isModelIdForProvider(modelId) {
|
|
197
|
+
export function isModelIdForProvider(modelId, provider = 'claude') {
|
|
174
198
|
if (isNoModel(modelId) || !isValidModelId(modelId)) return false;
|
|
175
|
-
|
|
199
|
+
const id = String(modelId).trim();
|
|
200
|
+
if (provider === 'openrouter') return isOpenRouterModelId(id);
|
|
201
|
+
return isClaudeCatalogId(id);
|
|
176
202
|
}
|
|
177
203
|
|
|
178
204
|
/**
|
|
@@ -394,21 +420,59 @@ export async function fetchAnthropicModels() {
|
|
|
394
420
|
}
|
|
395
421
|
|
|
396
422
|
/**
|
|
397
|
-
*
|
|
423
|
+
* Fetch live models from OpenRouter (public catalog; key optional).
|
|
424
|
+
* @returns {Promise<ModelOption[]>}
|
|
425
|
+
*/
|
|
426
|
+
export async function fetchOpenRouterModels() {
|
|
427
|
+
const doFetch = fetchImpl || globalThis.fetch;
|
|
428
|
+
if (typeof doFetch !== 'function') {
|
|
429
|
+
throw new Error('fetch is not available');
|
|
430
|
+
}
|
|
431
|
+
/** @type {Record<string, string>} */
|
|
432
|
+
const headers = { Accept: 'application/json' };
|
|
433
|
+
const key = String(envResolver().OPENROUTER_API_KEY || '').trim();
|
|
434
|
+
if (key) headers.Authorization = `Bearer ${key}`;
|
|
435
|
+
const res = await doFetch(OPENROUTER_MODELS_URL, { method: 'GET', headers });
|
|
436
|
+
if (!res.ok) {
|
|
437
|
+
throw new Error(`OpenRouter Models API HTTP ${res.status}`);
|
|
438
|
+
}
|
|
439
|
+
const body = await res.json();
|
|
440
|
+
const rows = Array.isArray(body?.data) ? body.data : [];
|
|
441
|
+
/** @type {ModelOption[]} */
|
|
442
|
+
const models = [];
|
|
443
|
+
for (const row of rows) {
|
|
444
|
+
const id = typeof row?.id === 'string' ? row.id.trim() : '';
|
|
445
|
+
if (!isOpenRouterModelId(id)) continue;
|
|
446
|
+
const display =
|
|
447
|
+
typeof row.name === 'string'
|
|
448
|
+
? row.name
|
|
449
|
+
: typeof row.display_name === 'string'
|
|
450
|
+
? row.display_name
|
|
451
|
+
: '';
|
|
452
|
+
models.push(toOption(id, display));
|
|
453
|
+
}
|
|
454
|
+
if (models.length === 0) {
|
|
455
|
+
throw new Error('OpenRouter Models API returned no models');
|
|
456
|
+
}
|
|
457
|
+
return models;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
398
461
|
* @param {{ selected?: string, force?: boolean }} [opts]
|
|
399
462
|
* @returns {Promise<ModelsListResult>}
|
|
400
463
|
*/
|
|
401
|
-
|
|
464
|
+
async function listClaudeModels(opts = {}) {
|
|
402
465
|
const selectedRaw = opts.selected;
|
|
403
466
|
const force = opts.force === true;
|
|
404
467
|
const now = Date.now();
|
|
405
468
|
|
|
406
|
-
if (!force &&
|
|
407
|
-
const selected = reconcileModelForProvider(
|
|
469
|
+
if (!force && claudeCache && claudeCache.expiresAt > now) {
|
|
470
|
+
const selected = reconcileModelForProvider(claudeCache.result.models, selectedRaw);
|
|
408
471
|
return {
|
|
409
|
-
...
|
|
410
|
-
models:
|
|
472
|
+
...claudeCache.result,
|
|
473
|
+
models: claudeCache.result.models,
|
|
411
474
|
selected,
|
|
475
|
+
provider: 'claude',
|
|
412
476
|
};
|
|
413
477
|
}
|
|
414
478
|
|
|
@@ -420,13 +484,62 @@ export async function listModels(opts = {}) {
|
|
|
420
484
|
const models = filterClaudeModels(mergeModelLists(curated, live));
|
|
421
485
|
const selected = reconcileModelForProvider(models, selectedRaw);
|
|
422
486
|
const result = { models, source: /** @type {ModelsSource} */ ('anthropic') };
|
|
423
|
-
|
|
424
|
-
return { ...result, selected };
|
|
487
|
+
claudeCache = { expiresAt: now + CACHE_TTL_MS, result };
|
|
488
|
+
return { ...result, selected, provider: 'claude' };
|
|
425
489
|
} catch {
|
|
426
490
|
const models = curated;
|
|
427
491
|
const selected = reconcileModelForProvider(models, selectedRaw);
|
|
428
492
|
const result = { models, source: /** @type {ModelsSource} */ ('fallback') };
|
|
429
|
-
|
|
430
|
-
return { ...result, selected };
|
|
493
|
+
claudeCache = { expiresAt: now + 30_000, result };
|
|
494
|
+
return { ...result, selected, provider: 'claude' };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* @param {{ selected?: string, force?: boolean }} [opts]
|
|
500
|
+
* @returns {Promise<ModelsListResult>}
|
|
501
|
+
*/
|
|
502
|
+
async function listOpenRouterModels(opts = {}) {
|
|
503
|
+
const selectedRaw = opts.selected;
|
|
504
|
+
const force = opts.force === true;
|
|
505
|
+
const now = Date.now();
|
|
506
|
+
|
|
507
|
+
if (!force && openrouterCache && openrouterCache.expiresAt > now) {
|
|
508
|
+
const selected = reconcileModelForProvider(openrouterCache.result.models, selectedRaw);
|
|
509
|
+
return {
|
|
510
|
+
...openrouterCache.result,
|
|
511
|
+
models: openrouterCache.result.models,
|
|
512
|
+
selected,
|
|
513
|
+
provider: 'openrouter',
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const curated = OPENROUTER_MODEL_OPTIONS.map((m) => ({ ...m }));
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
const live = await fetchOpenRouterModels();
|
|
521
|
+
const models = mergeModelLists(curated, live);
|
|
522
|
+
const selected = reconcileModelForProvider(models, selectedRaw);
|
|
523
|
+
const result = { models, source: /** @type {ModelsSource} */ ('openrouter') };
|
|
524
|
+
openrouterCache = { expiresAt: now + CACHE_TTL_MS, result };
|
|
525
|
+
return { ...result, selected, provider: 'openrouter' };
|
|
526
|
+
} catch {
|
|
527
|
+
const models = curated;
|
|
528
|
+
const selected = reconcileModelForProvider(models, selectedRaw);
|
|
529
|
+
const result = { models, source: /** @type {ModelsSource} */ ('openrouter-fallback') };
|
|
530
|
+
openrouterCache = { expiresAt: now + 30_000, result };
|
|
531
|
+
return { ...result, selected, provider: 'openrouter' };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* List models for the UI.
|
|
537
|
+
* @param {{ selected?: string, force?: boolean, provider?: 'claude' | 'openrouter' }} [opts]
|
|
538
|
+
* @returns {Promise<ModelsListResult>}
|
|
539
|
+
*/
|
|
540
|
+
export async function listModels(opts = {}) {
|
|
541
|
+
if (opts.provider === 'openrouter') {
|
|
542
|
+
return listOpenRouterModels(opts);
|
|
431
543
|
}
|
|
544
|
+
return listClaudeModels(opts);
|
|
432
545
|
}
|