@hifullmoon/aicommit 2.2.3 → 2.4.0
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/.aicommit.config.example.json +31 -8
- package/CHANGELOG.md +25 -1
- package/README.md +41 -6
- package/README.zh-CN.md +41 -6
- package/bin/aicommit.js +6 -1
- package/docs/distribution.md +8 -1
- package/docs/large-change-implementation-plan.md +193 -0
- package/docs/privacy.md +6 -0
- package/docs/provider-compatibility.md +44 -16
- package/docs/troubleshooting.md +12 -0
- package/package.json +4 -2
- package/schemas/aicommit-output.schema.json +120 -18
- package/src/analysis-budget.js +76 -0
- package/src/api.js +6 -316
- package/src/change-analysis.js +558 -0
- package/src/cli.js +31 -5
- package/src/completion.js +3 -0
- package/src/config.js +16 -0
- package/src/doctor.js +2 -4
- package/src/git-spool.js +108 -0
- package/src/git.js +25 -4
- package/src/local-analysis.js +271 -0
- package/src/main.js +120 -27
- package/src/model-client.js +403 -0
- package/src/provider-response.js +148 -0
- package/src/providers.js +134 -206
- package/src/runtime.js +6 -0
- package/src/split.js +194 -43
- package/src/update.js +326 -0
package/src/main.js
CHANGED
|
@@ -6,9 +6,7 @@ import chalk from 'chalk';
|
|
|
6
6
|
import { parseArgs } from './cli.js';
|
|
7
7
|
import { getProjectRoot, loadConfig } from './config.js';
|
|
8
8
|
import {
|
|
9
|
-
getStagedDiff,
|
|
10
9
|
getChangedFiles,
|
|
11
|
-
getDiffStats,
|
|
12
10
|
getBranch,
|
|
13
11
|
gitCommit,
|
|
14
12
|
stripLockFileContent,
|
|
@@ -21,7 +19,16 @@ import {
|
|
|
21
19
|
getIndexFingerprint,
|
|
22
20
|
createIndexTransaction,
|
|
23
21
|
protectSensitiveDiff,
|
|
22
|
+
unifiedArg,
|
|
24
23
|
} from './git.js';
|
|
24
|
+
import {
|
|
25
|
+
captureChanges,
|
|
26
|
+
needsAnalysis,
|
|
27
|
+
analysisConfig,
|
|
28
|
+
analyzeChanges,
|
|
29
|
+
summarizeChanges,
|
|
30
|
+
} from './change-analysis.js';
|
|
31
|
+
import { cleanupGitSpools } from './git-spool.js';
|
|
25
32
|
import { generateCommitMessage } from './api.js';
|
|
26
33
|
import {
|
|
27
34
|
statusColor,
|
|
@@ -41,7 +48,13 @@ import {
|
|
|
41
48
|
stringifyConfigRedacted,
|
|
42
49
|
redactSensitiveUrl,
|
|
43
50
|
} from './utils.js';
|
|
44
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
abortSplit,
|
|
53
|
+
applySplitPlan,
|
|
54
|
+
resumeSplit,
|
|
55
|
+
splitFlow,
|
|
56
|
+
getStagedChangedFiles,
|
|
57
|
+
} from './split.js';
|
|
45
58
|
import { runModelTask } from './generation-ui.js';
|
|
46
59
|
import { runSetup } from './setup.js';
|
|
47
60
|
import { detectProviderType } from './providers.js';
|
|
@@ -50,6 +63,7 @@ import { runDoctor } from './doctor.js';
|
|
|
50
63
|
import { runConfigCommand } from './config-command.js';
|
|
51
64
|
import { generateCompletion } from './completion.js';
|
|
52
65
|
import { runPolicyCommand } from './policy-command.js';
|
|
66
|
+
import { runUpdate } from './update.js';
|
|
53
67
|
import {
|
|
54
68
|
applyCommitlintPolicy,
|
|
55
69
|
collectRepositoryContext,
|
|
@@ -97,6 +111,7 @@ async function runMain() {
|
|
|
97
111
|
dryRun,
|
|
98
112
|
yes,
|
|
99
113
|
setup,
|
|
114
|
+
update,
|
|
100
115
|
doctor,
|
|
101
116
|
configAction,
|
|
102
117
|
policyAction,
|
|
@@ -113,10 +128,12 @@ async function runMain() {
|
|
|
113
128
|
return { exitReason: 'completion' };
|
|
114
129
|
}
|
|
115
130
|
const machineOutput = output === 'json';
|
|
116
|
-
if (machineOutput && !yes && !doctor && !configAction && !policyAction) {
|
|
131
|
+
if (machineOutput && !yes && !doctor && !configAction && !policyAction && !update) {
|
|
117
132
|
throw fail(ERROR_CATEGORIES.CONFIG, '--output=json requires --yes for commit and split flows.');
|
|
118
133
|
}
|
|
119
134
|
|
|
135
|
+
if (update) return runUpdate({ machineOutput, debug });
|
|
136
|
+
|
|
120
137
|
// The setup wizard is a standalone flow — no git repo, diff, or loaded
|
|
121
138
|
// config required.
|
|
122
139
|
if (setup) {
|
|
@@ -353,8 +370,7 @@ async function runMain() {
|
|
|
353
370
|
indexTransaction ||= createIndexTransaction(projectRoot);
|
|
354
371
|
return indexTransaction;
|
|
355
372
|
};
|
|
356
|
-
|
|
357
|
-
if (!diff) {
|
|
373
|
+
if (!getChangedFiles(projectRoot).length) {
|
|
358
374
|
// Nothing staged. But git diff --staged is also empty for unstaged work
|
|
359
375
|
// and untracked files — surface what git status actually shows instead of
|
|
360
376
|
// falsely claiming there's nothing to commit.
|
|
@@ -425,7 +441,14 @@ async function runMain() {
|
|
|
425
441
|
|
|
426
442
|
try {
|
|
427
443
|
beginIndexTransaction();
|
|
428
|
-
runGit(
|
|
444
|
+
runGit(
|
|
445
|
+
toStage
|
|
446
|
+
? ['--literal-pathspecs', 'add', '--pathspec-from-file=-', '--pathspec-file-nul']
|
|
447
|
+
: ['add', '-A'],
|
|
448
|
+
projectRoot,
|
|
449
|
+
false,
|
|
450
|
+
toStage ? toStage.join('\0') + '\0' : undefined,
|
|
451
|
+
);
|
|
429
452
|
indexTransaction.markOwned();
|
|
430
453
|
} catch (err) {
|
|
431
454
|
indexTransaction?.restore({ force: true });
|
|
@@ -439,8 +462,7 @@ async function runMain() {
|
|
|
439
462
|
});
|
|
440
463
|
}
|
|
441
464
|
|
|
442
|
-
|
|
443
|
-
if (!diff) {
|
|
465
|
+
if (!getChangedFiles(projectRoot).length) {
|
|
444
466
|
console.log('\n ' + chalk.yellow('✗ Nothing staged — no diff to commit.\n'));
|
|
445
467
|
throw fail(ERROR_CATEGORIES.GIT_STATE, 'Nothing staged; no diff to commit.', {
|
|
446
468
|
reported: true,
|
|
@@ -451,7 +473,14 @@ async function runMain() {
|
|
|
451
473
|
// Re-read the final diff between two complete-index fingerprints so the
|
|
452
474
|
// prompt is guaranteed to describe one stable staged snapshot.
|
|
453
475
|
const plannedIndexFingerprint = getIndexFingerprint(projectRoot);
|
|
454
|
-
|
|
476
|
+
const changedFiles = getChangedFiles(projectRoot);
|
|
477
|
+
const captured = captureChanges(
|
|
478
|
+
[['diff', unifiedArg(config.diffContextLines), '--staged']],
|
|
479
|
+
projectRoot,
|
|
480
|
+
getStagedChangedFiles(projectRoot),
|
|
481
|
+
config,
|
|
482
|
+
);
|
|
483
|
+
const diff = captured.diff || '';
|
|
455
484
|
if (getIndexFingerprint(projectRoot) !== plannedIndexFingerprint) {
|
|
456
485
|
console.log(
|
|
457
486
|
'\n ' + chalk.red('✗ The staged changes are being modified concurrently; commit aborted.\n'),
|
|
@@ -463,8 +492,7 @@ async function runMain() {
|
|
|
463
492
|
);
|
|
464
493
|
}
|
|
465
494
|
|
|
466
|
-
const stats =
|
|
467
|
-
const changedFiles = getChangedFiles(projectRoot);
|
|
495
|
+
const stats = captured.stats;
|
|
468
496
|
const branch = getBranch(projectRoot);
|
|
469
497
|
const stageIcon = chalk.green('staged');
|
|
470
498
|
const changeStr = chalk.green(`+${stats.additions}`) + ' ' + chalk.red(`-${stats.deletions}`);
|
|
@@ -495,7 +523,9 @@ async function runMain() {
|
|
|
495
523
|
// Protect common secrets before any repository content leaves the machine.
|
|
496
524
|
// The protected diff affects only the model request, never the actual index.
|
|
497
525
|
const protectedInput = protectSensitiveDiff(diff);
|
|
526
|
+
protectedInput.findings = [...new Set([...protectedInput.findings, ...captured.findings])];
|
|
498
527
|
let diffForModel = diff;
|
|
528
|
+
let protectAnalysis = true;
|
|
499
529
|
if (protectedInput.findings.length) {
|
|
500
530
|
warnings.push('Sensitive data was detected and protected before the provider request.');
|
|
501
531
|
console.log('\n ' + chalk.yellow.bold('⚠ Potential sensitive data detected:'));
|
|
@@ -513,33 +543,47 @@ async function runMain() {
|
|
|
513
543
|
description:
|
|
514
544
|
'Omit sensitive files/private keys and redact detected credential values',
|
|
515
545
|
},
|
|
516
|
-
{ name: 'Cancel', value: 'cancel', description: 'Do not send repository content' },
|
|
517
546
|
{
|
|
518
547
|
name: 'Send original diff',
|
|
519
548
|
value: 'original',
|
|
520
549
|
description: 'Send the unredacted content to the configured provider',
|
|
521
550
|
},
|
|
551
|
+
{ name: 'Cancel', value: 'cancel', description: 'Do not send repository content' },
|
|
522
552
|
],
|
|
523
553
|
});
|
|
524
554
|
if (sensitiveAction === 'cancel') {
|
|
525
555
|
return finishCancelled();
|
|
526
556
|
}
|
|
527
557
|
if (sensitiveAction === 'protect') diffForModel = protectedInput.diff;
|
|
558
|
+
if (sensitiveAction === 'original') protectAnalysis = false;
|
|
528
559
|
}
|
|
529
560
|
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
|
|
533
|
-
// plus truncated hunks, so token spend stays proportional to what the
|
|
534
|
-
// model needs.
|
|
561
|
+
// Small changes keep the existing prompt. Large changes use a local
|
|
562
|
+
// inventory by default; exhaustive model analysis is an explicit opt-in.
|
|
563
|
+
const large = needsAnalysis(captured, config);
|
|
535
564
|
const strippedDiff = stripLockFileContent(diffForModel, config.stripFiles);
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
565
|
+
let { diff: modelDiff, truncated } = large
|
|
566
|
+
? { diff: '', truncated: false }
|
|
567
|
+
: condenseDiff(
|
|
568
|
+
strippedDiff,
|
|
569
|
+
config.maxDiffChars,
|
|
570
|
+
getDiffStat(projectRoot),
|
|
571
|
+
config.maxFileDiffChars,
|
|
572
|
+
);
|
|
573
|
+
let analysis;
|
|
574
|
+
let analyzedFacts;
|
|
575
|
+
let generationConfig = config;
|
|
576
|
+
if (large) {
|
|
577
|
+
generationConfig = analysisConfig(config);
|
|
578
|
+
console.log(
|
|
579
|
+
chalk.dim(
|
|
580
|
+
generationConfig.largeChange?.strategy === 'deep'
|
|
581
|
+
? ` Large change: analyzing all ${changedFiles.length} files in bounded chunks.`
|
|
582
|
+
: ` Large change: building a local inventory of ${changedFiles.length} files with bounded excerpts.`,
|
|
583
|
+
),
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (truncated && !large) {
|
|
543
587
|
warnings.push('The diff was condensed to fit the configured provider input limit.');
|
|
544
588
|
console.log(
|
|
545
589
|
chalk.dim(
|
|
@@ -571,8 +615,41 @@ async function runMain() {
|
|
|
571
615
|
machineOutput,
|
|
572
616
|
cancelMessage: 'Commit cancelled.',
|
|
573
617
|
failureMessage: 'API call failed',
|
|
574
|
-
task: (stream) =>
|
|
575
|
-
|
|
618
|
+
task: async (stream) => {
|
|
619
|
+
if (large && !analysis) {
|
|
620
|
+
analyzedFacts ||= await analyzeChanges(
|
|
621
|
+
generationConfig,
|
|
622
|
+
captured,
|
|
623
|
+
protectAnalysis,
|
|
624
|
+
null,
|
|
625
|
+
({ completedChunks }) =>
|
|
626
|
+
console.error(` Analysis: ${completedChunks} chunks completed`),
|
|
627
|
+
);
|
|
628
|
+
modelDiff =
|
|
629
|
+
analyzedFacts.summary ||
|
|
630
|
+
(await summarizeChanges(generationConfig, analyzedFacts.facts));
|
|
631
|
+
analysis = analyzedFacts;
|
|
632
|
+
console.error(
|
|
633
|
+
` Coverage: ${analysis.coverage.analyzedFiles} files analyzed; ${analysis.coverage.sampledFiles || 0} representative excerpts; ${analysis.coverage.metadataOnlyFiles} metadata only.`,
|
|
634
|
+
);
|
|
635
|
+
if (analysis.coverage.strategy === 'auto')
|
|
636
|
+
warnings.push(
|
|
637
|
+
'Large changes were summarized locally with selected excerpts; content was not fully analyzed.',
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
const result = await generateCommitMessage(
|
|
641
|
+
generationConfig,
|
|
642
|
+
modelDiff,
|
|
643
|
+
regenerateCount,
|
|
644
|
+
message,
|
|
645
|
+
stream,
|
|
646
|
+
);
|
|
647
|
+
if (large) {
|
|
648
|
+
result.usage = generationConfig.analysisBudget.snapshot().usage;
|
|
649
|
+
result.elapsed = generationConfig.analysisBudget.snapshot().elapsedMs;
|
|
650
|
+
}
|
|
651
|
+
return result;
|
|
652
|
+
},
|
|
576
653
|
successMessage(result) {
|
|
577
654
|
let done = `Generated in ${chalk.bold(formatMs(result.elapsed))}`;
|
|
578
655
|
if (result.usage) done += chalk.dim(` · tokens: ${formatUsage(result.usage)}`);
|
|
@@ -693,6 +770,13 @@ async function runMain() {
|
|
|
693
770
|
usage,
|
|
694
771
|
warnings,
|
|
695
772
|
exitReason: 'dry_run',
|
|
773
|
+
...(analysis
|
|
774
|
+
? {
|
|
775
|
+
data: {
|
|
776
|
+
analysis: { ...analysis.coverage, ...generationConfig.analysisBudget.snapshot() },
|
|
777
|
+
},
|
|
778
|
+
}
|
|
779
|
+
: {}),
|
|
696
780
|
committed: false,
|
|
697
781
|
edited: wasEdited,
|
|
698
782
|
rewrites: regenerateCount + automaticCorrectionCount,
|
|
@@ -732,6 +816,13 @@ async function runMain() {
|
|
|
732
816
|
usage,
|
|
733
817
|
warnings,
|
|
734
818
|
exitReason: 'success',
|
|
819
|
+
...(analysis
|
|
820
|
+
? {
|
|
821
|
+
data: {
|
|
822
|
+
analysis: { ...analysis.coverage, ...generationConfig.analysisBudget.snapshot() },
|
|
823
|
+
},
|
|
824
|
+
}
|
|
825
|
+
: {}),
|
|
735
826
|
committed: true,
|
|
736
827
|
edited: wasEdited,
|
|
737
828
|
rewrites: regenerateCount + automaticCorrectionCount,
|
|
@@ -763,5 +854,7 @@ export async function main() {
|
|
|
763
854
|
edited: false,
|
|
764
855
|
rewrites: 0,
|
|
765
856
|
};
|
|
857
|
+
} finally {
|
|
858
|
+
cleanupGitSpools();
|
|
766
859
|
}
|
|
767
860
|
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
import { stream as streamPi } from '@earendil-works/pi-ai/api/openai-completions';
|
|
2
|
+
import { getProviderAdapter, normalizeUsage } from './providers.js';
|
|
3
|
+
import { ERROR_CATEGORIES, fail } from './errors.js';
|
|
4
|
+
import { completionEvent, normalizeEventStream } from './provider-response.js';
|
|
5
|
+
import { estimateTokens } from './analysis-budget.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RETRY_POLICY = Object.freeze({
|
|
10
|
+
maxAttempts: 3,
|
|
11
|
+
baseDelayMs: 500,
|
|
12
|
+
maxDelayMs: 5000,
|
|
13
|
+
});
|
|
14
|
+
const RETRYABLE_STATUS = new Set([429, 500, 502, 503, 504]);
|
|
15
|
+
const RETRYABLE_NETWORK_CODES = new Set([
|
|
16
|
+
'ECONNRESET',
|
|
17
|
+
'ECONNREFUSED',
|
|
18
|
+
'EHOSTUNREACH',
|
|
19
|
+
'ENETUNREACH',
|
|
20
|
+
'EPIPE',
|
|
21
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
22
|
+
'UND_ERR_SOCKET',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function secureEndpoint(apiUrl) {
|
|
26
|
+
const endpoint = new URL(apiUrl);
|
|
27
|
+
const loopback =
|
|
28
|
+
endpoint.hostname === 'localhost' ||
|
|
29
|
+
endpoint.hostname === '127.0.0.1' ||
|
|
30
|
+
endpoint.hostname.startsWith('127.') ||
|
|
31
|
+
endpoint.hostname === '[::1]';
|
|
32
|
+
if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && loopback)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
'Refusing insecure API endpoint: use HTTPS, or HTTP only for localhost/loopback.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return endpoint;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function retryPolicy(value = {}) {
|
|
41
|
+
return {
|
|
42
|
+
maxAttempts: value?.maxAttempts ?? DEFAULT_RETRY_POLICY.maxAttempts,
|
|
43
|
+
baseDelayMs: value?.baseDelayMs ?? DEFAULT_RETRY_POLICY.baseDelayMs,
|
|
44
|
+
maxDelayMs: value?.maxDelayMs ?? DEFAULT_RETRY_POLICY.maxDelayMs,
|
|
45
|
+
sleep:
|
|
46
|
+
value?.sleep ??
|
|
47
|
+
((delayMs) =>
|
|
48
|
+
new Promise((resolve) => {
|
|
49
|
+
globalThis.setTimeout(resolve, delayMs);
|
|
50
|
+
})),
|
|
51
|
+
now: value?.now ?? (() => Date.now()),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function retryAfterMs(value, now) {
|
|
56
|
+
if (!value) return null;
|
|
57
|
+
const seconds = Number(value);
|
|
58
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000;
|
|
59
|
+
const date = Date.parse(value);
|
|
60
|
+
if (Number.isNaN(date)) return null;
|
|
61
|
+
return Math.max(0, date - now());
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function networkFailure(err) {
|
|
65
|
+
if (err instanceof TypeError) return true;
|
|
66
|
+
return RETRYABLE_NETWORK_CODES.has(err?.code) || RETRYABLE_NETWORK_CODES.has(err?.cause?.code);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function timeoutError(err, timeout) {
|
|
70
|
+
if (err?.name !== 'TimeoutError' && err?.name !== 'AbortError') return null;
|
|
71
|
+
return new Error(
|
|
72
|
+
`Request timed out after ${Math.round(timeout / 1000)}s — the model took too long to respond. ` +
|
|
73
|
+
`Raise "timeoutMs" in your config if this keeps happening.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function fetchWithRetry(
|
|
78
|
+
apiUrl,
|
|
79
|
+
init,
|
|
80
|
+
timeout,
|
|
81
|
+
configuredPolicy,
|
|
82
|
+
consume,
|
|
83
|
+
beforeAttempt = null,
|
|
84
|
+
) {
|
|
85
|
+
const policy = retryPolicy(configuredPolicy);
|
|
86
|
+
let attempt = 0;
|
|
87
|
+
|
|
88
|
+
while (attempt < policy.maxAttempts) {
|
|
89
|
+
attempt += 1;
|
|
90
|
+
beforeAttempt?.();
|
|
91
|
+
let response;
|
|
92
|
+
try {
|
|
93
|
+
response = await fetch(apiUrl, {
|
|
94
|
+
...init,
|
|
95
|
+
signal: init.signal
|
|
96
|
+
? AbortSignal.any([init.signal, AbortSignal.timeout(timeout)])
|
|
97
|
+
: AbortSignal.timeout(timeout),
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
const wrappedTimeout = timeoutError(err, timeout);
|
|
101
|
+
if (wrappedTimeout) throw wrappedTimeout;
|
|
102
|
+
if (!networkFailure(err) || attempt >= policy.maxAttempts) throw err;
|
|
103
|
+
const delay = Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs);
|
|
104
|
+
await policy.sleep(delay);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (response.ok) {
|
|
109
|
+
try {
|
|
110
|
+
return { value: await consume(response), attempts: attempt };
|
|
111
|
+
} catch (err) {
|
|
112
|
+
const wrappedTimeout = timeoutError(err, timeout);
|
|
113
|
+
if (wrappedTimeout) throw wrappedTimeout;
|
|
114
|
+
// Once the provider has accepted a generation request, replaying it is
|
|
115
|
+
// unsafe: the first request may already have completed and been billed
|
|
116
|
+
// even though its response body was interrupted locally.
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt < policy.maxAttempts) {
|
|
121
|
+
const requestedDelay = retryAfterMs(response.headers.get('retry-after'), policy.now);
|
|
122
|
+
if (requestedDelay !== null && requestedDelay > policy.maxDelayMs) {
|
|
123
|
+
await response.body?.cancel().catch(() => {});
|
|
124
|
+
throw new Error(
|
|
125
|
+
`HTTP ${response.status}: provider requested a retry after ${Math.ceil(
|
|
126
|
+
requestedDelay / 1000,
|
|
127
|
+
)}s, exceeding the configured retry.maxDelayMs limit.`,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
const delay =
|
|
131
|
+
requestedDelay ?? Math.min(policy.baseDelayMs * 2 ** (attempt - 1), policy.maxDelayMs);
|
|
132
|
+
await response.body?.cancel().catch(() => {});
|
|
133
|
+
await policy.sleep(delay);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const errText = await response.text();
|
|
138
|
+
throw new Error(`HTTP ${response.status}: ${errText.slice(0, 400)}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
throw new Error('Provider request exhausted its retry budget.');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function piContext(messages, model) {
|
|
145
|
+
return {
|
|
146
|
+
systemPrompt:
|
|
147
|
+
messages
|
|
148
|
+
.filter((m) => m.role === 'system')
|
|
149
|
+
.map((m) => m.content)
|
|
150
|
+
.join('\n\n') || undefined,
|
|
151
|
+
messages: messages
|
|
152
|
+
.filter((m) => m.role !== 'system')
|
|
153
|
+
.map((message) => {
|
|
154
|
+
if (message.role === 'user') return { ...message, timestamp: Date.now() };
|
|
155
|
+
if (message.role !== 'assistant')
|
|
156
|
+
throw new Error(`Unsupported generation message role: ${message.role}`);
|
|
157
|
+
return {
|
|
158
|
+
role: 'assistant',
|
|
159
|
+
content: [{ type: 'text', text: message.content }],
|
|
160
|
+
api: model.api,
|
|
161
|
+
provider: model.provider,
|
|
162
|
+
model: model.id,
|
|
163
|
+
stopReason: 'stop',
|
|
164
|
+
usage: {
|
|
165
|
+
input: 0,
|
|
166
|
+
output: 0,
|
|
167
|
+
cacheRead: 0,
|
|
168
|
+
cacheWrite: 0,
|
|
169
|
+
totalTokens: 0,
|
|
170
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
171
|
+
},
|
|
172
|
+
timestamp: Date.now(),
|
|
173
|
+
};
|
|
174
|
+
}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function nativeOllamaPayload(payload, apiUrl) {
|
|
179
|
+
const { max_tokens, temperature, stream_options: _streamOptions, options, ...rest } = payload;
|
|
180
|
+
const body = {
|
|
181
|
+
...rest,
|
|
182
|
+
stream: false,
|
|
183
|
+
options: { temperature, num_predict: max_tokens, ...options },
|
|
184
|
+
};
|
|
185
|
+
if (/\/api\/generate\/?$/i.test(new URL(apiUrl).pathname)) {
|
|
186
|
+
// /generate accepts a prompt, not the messages array used by /chat.
|
|
187
|
+
body.system = body.messages
|
|
188
|
+
.filter((m) => m.role === 'system')
|
|
189
|
+
.map((m) => m.content)
|
|
190
|
+
.join('\n\n');
|
|
191
|
+
body.prompt = body.messages
|
|
192
|
+
.filter((m) => m.role !== 'system')
|
|
193
|
+
.map((m) => `${m.role}: ${m.content}`)
|
|
194
|
+
.join('\n\n');
|
|
195
|
+
delete body.messages;
|
|
196
|
+
}
|
|
197
|
+
return body;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function transport(config, adapter, state) {
|
|
201
|
+
return async (_sdkUrl, init) => {
|
|
202
|
+
try {
|
|
203
|
+
const headers = new globalThis.Headers(init.headers);
|
|
204
|
+
// Never let SDK defaults resolve a different credential or follow a redirect
|
|
205
|
+
// carrying repository content to an endpoint the user did not configure.
|
|
206
|
+
if (config.apiKey) headers.set('Authorization', `Bearer ${config.apiKey}`);
|
|
207
|
+
else headers.delete('Authorization');
|
|
208
|
+
let payload = JSON.parse(init.body);
|
|
209
|
+
if (adapter.nativeOllama) payload = nativeOllamaPayload(payload, config.apiUrl);
|
|
210
|
+
else if (config.extraBody?.stream === false) {
|
|
211
|
+
payload.stream = false;
|
|
212
|
+
delete payload.stream_options;
|
|
213
|
+
}
|
|
214
|
+
const result = await fetchWithRetry(
|
|
215
|
+
config.apiUrl,
|
|
216
|
+
{
|
|
217
|
+
...init,
|
|
218
|
+
headers,
|
|
219
|
+
body: JSON.stringify(payload),
|
|
220
|
+
redirect: 'error',
|
|
221
|
+
},
|
|
222
|
+
config.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
223
|
+
config.analysisBudget
|
|
224
|
+
? {
|
|
225
|
+
...config.retry,
|
|
226
|
+
sleep: async (ms) => {
|
|
227
|
+
if (ms >= config.analysisBudget.remainingMs())
|
|
228
|
+
throw new Error('Analysis timed out during retry backoff.');
|
|
229
|
+
await new Promise((resolve) => {
|
|
230
|
+
setTimeout(resolve, ms);
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
}
|
|
234
|
+
: config.retry,
|
|
235
|
+
async (response) => {
|
|
236
|
+
if (
|
|
237
|
+
(response.headers.get('content-type') || '').toLowerCase().includes('text/event-stream')
|
|
238
|
+
)
|
|
239
|
+
return normalizeEventStream(response);
|
|
240
|
+
let data;
|
|
241
|
+
try {
|
|
242
|
+
data = await response.json();
|
|
243
|
+
} catch (err) {
|
|
244
|
+
if (err instanceof SyntaxError)
|
|
245
|
+
throw fail(ERROR_CATEGORIES.RESPONSE_FORMAT, 'Provider returned invalid JSON.', {
|
|
246
|
+
cause: err,
|
|
247
|
+
});
|
|
248
|
+
throw err;
|
|
249
|
+
}
|
|
250
|
+
const event = completionEvent(data);
|
|
251
|
+
state.raw = data;
|
|
252
|
+
return new Response(`data: ${JSON.stringify(event)}\n\ndata: [DONE]\n\n`, {
|
|
253
|
+
headers: { 'Content-Type': 'text/event-stream' },
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
config.analysisBudget
|
|
257
|
+
? () => {
|
|
258
|
+
state.ticket = config.analysisBudget.reserve(
|
|
259
|
+
config.analysisInputTokens,
|
|
260
|
+
config.analysisOutputTokens,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
: null,
|
|
264
|
+
);
|
|
265
|
+
state.attempts = result.attempts;
|
|
266
|
+
return result.value;
|
|
267
|
+
} catch (err) {
|
|
268
|
+
state.error = err;
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function requestGeneration(config, request) {
|
|
275
|
+
secureEndpoint(config.apiUrl);
|
|
276
|
+
if (config.analysisBudget) {
|
|
277
|
+
config = {
|
|
278
|
+
...config,
|
|
279
|
+
analysisInputTokens: estimateTokens(JSON.stringify(request.messages)),
|
|
280
|
+
analysisOutputTokens: request.maxTokens,
|
|
281
|
+
timeoutMs: Math.max(
|
|
282
|
+
1,
|
|
283
|
+
Math.min(config.timeoutMs || DEFAULT_TIMEOUT_MS, config.analysisBudget.remainingMs()),
|
|
284
|
+
),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const adapter = getProviderAdapter(config);
|
|
288
|
+
const options = adapter.options({
|
|
289
|
+
...request,
|
|
290
|
+
extraBody: config.extraBody,
|
|
291
|
+
reasoning: request.reasoning ?? config.reasoning,
|
|
292
|
+
});
|
|
293
|
+
const state = { attempts: 0, raw: null, error: null };
|
|
294
|
+
const startedAt = performance.now();
|
|
295
|
+
const controller = new AbortController();
|
|
296
|
+
const events = streamPi(adapter.model, piContext(request.messages, adapter.model), {
|
|
297
|
+
...options,
|
|
298
|
+
// Pi's transport requires a key even for a keyless server. The placeholder
|
|
299
|
+
// never leaves the process: transport installs only the resolved config key.
|
|
300
|
+
apiKey: config.apiKey || 'aicommit-keyless',
|
|
301
|
+
headers: adapter.headers,
|
|
302
|
+
env: {},
|
|
303
|
+
maxRetries: 0,
|
|
304
|
+
timeoutMs: config.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
305
|
+
signal: config.analysisBudget
|
|
306
|
+
? AbortSignal.any([controller.signal, config.analysisBudget.signal])
|
|
307
|
+
: controller.signal,
|
|
308
|
+
fetch: transport(config, adapter, state),
|
|
309
|
+
});
|
|
310
|
+
let result;
|
|
311
|
+
try {
|
|
312
|
+
for await (const event of events) {
|
|
313
|
+
if (event.type === 'thinking_delta') request.stream?.onReasoningDelta?.(event.delta);
|
|
314
|
+
if (event.type === 'error') {
|
|
315
|
+
if (state.error) throw state.error;
|
|
316
|
+
const message = event.error.errorMessage || 'Provider request failed.';
|
|
317
|
+
if (/without finish_reason/.test(message))
|
|
318
|
+
throw fail(
|
|
319
|
+
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
320
|
+
'Streaming response ended before the provider sent a finish_reason. The partial response was discarded; retry the request.',
|
|
321
|
+
);
|
|
322
|
+
if (/timed out|timeout/i.test(message))
|
|
323
|
+
throw fail(
|
|
324
|
+
ERROR_CATEGORIES.NETWORK,
|
|
325
|
+
`Request timed out after ${Math.round((config.timeoutMs || DEFAULT_TIMEOUT_MS) / 1000)}s — the model took too long to respond. Raise "timeoutMs" in your config if this keeps happening.`,
|
|
326
|
+
);
|
|
327
|
+
if (/socket|network|fetch failed|terminated|econn/i.test(message))
|
|
328
|
+
throw fail(ERROR_CATEGORIES.NETWORK, message);
|
|
329
|
+
if (/JSON|Unexpected token/i.test(message))
|
|
330
|
+
throw fail(
|
|
331
|
+
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
332
|
+
`Provider returned invalid JSON: ${message}`,
|
|
333
|
+
);
|
|
334
|
+
throw fail(ERROR_CATEGORIES.PROVIDER, `Provider request failed: ${message}`);
|
|
335
|
+
}
|
|
336
|
+
if (event.type === 'done') result = event.message;
|
|
337
|
+
}
|
|
338
|
+
} finally {
|
|
339
|
+
controller.abort();
|
|
340
|
+
}
|
|
341
|
+
if (!result)
|
|
342
|
+
throw fail(
|
|
343
|
+
ERROR_CATEGORIES.RESPONSE_FORMAT,
|
|
344
|
+
'Provider returned an invalid response: no completed generation.',
|
|
345
|
+
);
|
|
346
|
+
const content = result.content
|
|
347
|
+
.filter((block) => block.type === 'text')
|
|
348
|
+
.map((block) => block.text)
|
|
349
|
+
.join('');
|
|
350
|
+
const reasoning =
|
|
351
|
+
result.content
|
|
352
|
+
.filter((block) => block.type === 'thinking')
|
|
353
|
+
.map((block) => block.thinking)
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.join('\n') || null;
|
|
356
|
+
const usage = state.raw
|
|
357
|
+
? normalizeUsage(state.raw.usage || state.raw)
|
|
358
|
+
: result.usage.totalTokens ||
|
|
359
|
+
result.usage.input ||
|
|
360
|
+
result.usage.output ||
|
|
361
|
+
result.usage.cacheRead ||
|
|
362
|
+
result.usage.cacheWrite
|
|
363
|
+
? {
|
|
364
|
+
inputTokens: result.usage.input + result.usage.cacheRead + result.usage.cacheWrite,
|
|
365
|
+
outputTokens: result.usage.output,
|
|
366
|
+
totalTokens: result.usage.totalTokens,
|
|
367
|
+
}
|
|
368
|
+
: null;
|
|
369
|
+
const finishReason =
|
|
370
|
+
state.raw?.choices?.[0]?.finish_reason ??
|
|
371
|
+
state.raw?.stop_reason ??
|
|
372
|
+
state.raw?.done_reason ??
|
|
373
|
+
result.rawStopReason ??
|
|
374
|
+
result.stopReason;
|
|
375
|
+
config.analysisBudget?.settle(state.ticket, usage);
|
|
376
|
+
return {
|
|
377
|
+
provider: adapter.id,
|
|
378
|
+
model: result.responseModel || result.model,
|
|
379
|
+
content,
|
|
380
|
+
reasoning,
|
|
381
|
+
usage,
|
|
382
|
+
finishReason,
|
|
383
|
+
// Preserve callAPI's Chat Completions-shaped compatibility return. Pi's full
|
|
384
|
+
// normalized message is also available for future protocol-specific callers.
|
|
385
|
+
raw: state.raw || {
|
|
386
|
+
model: result.responseModel || result.model,
|
|
387
|
+
choices: [
|
|
388
|
+
{ message: { content, reasoning_content: reasoning }, finish_reason: finishReason },
|
|
389
|
+
],
|
|
390
|
+
usage: usage
|
|
391
|
+
? {
|
|
392
|
+
prompt_tokens: usage.inputTokens,
|
|
393
|
+
completion_tokens: usage.outputTokens,
|
|
394
|
+
total_tokens: usage.totalTokens,
|
|
395
|
+
}
|
|
396
|
+
: null,
|
|
397
|
+
},
|
|
398
|
+
piMessage: result,
|
|
399
|
+
capabilities: adapter.capabilities,
|
|
400
|
+
attempts: state.attempts,
|
|
401
|
+
latencyMs: performance.now() - startedAt,
|
|
402
|
+
};
|
|
403
|
+
}
|