@hifullmoon/aicommit 1.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 +118 -0
- package/CHANGELOG.md +131 -0
- package/LICENSE +21 -0
- package/README.md +406 -0
- package/README.zh-CN.md +408 -0
- package/SECURITY.md +33 -0
- package/bin/aicommit.js +47 -0
- package/docs/distribution.md +104 -0
- package/docs/examples/aicommit-policy.yml +24 -0
- package/docs/examples/commit-msg +4 -0
- package/docs/examples/extension/aicommit-extension.json +9 -0
- package/docs/examples/extension/index.mjs +32 -0
- package/docs/extensions.md +93 -0
- package/docs/privacy.md +58 -0
- package/docs/provider-compatibility.md +37 -0
- package/docs/provider-presets.md +100 -0
- package/docs/team-policy.md +86 -0
- package/docs/troubleshooting.md +37 -0
- package/package.json +100 -0
- package/presets/provider-presets.json +60 -0
- package/schemas/aicommit-extension.schema.json +27 -0
- package/schemas/aicommit-output.schema.json +86 -0
- package/schemas/aicommit-provider-presets.schema.json +56 -0
- package/schemas/aicommit-split-checkpoint.schema.json +92 -0
- package/schemas/aicommit-split-plan.schema.json +108 -0
- package/schemas/aicommit-team-policy.schema.json +59 -0
- package/src/api.js +740 -0
- package/src/cli.js +558 -0
- package/src/completion.js +136 -0
- package/src/config-command.js +59 -0
- package/src/config.js +524 -0
- package/src/context.js +537 -0
- package/src/credentials.js +123 -0
- package/src/doctor.js +131 -0
- package/src/errors.js +96 -0
- package/src/extension-runner.mjs +36 -0
- package/src/extensions.js +426 -0
- package/src/generation-ui.js +53 -0
- package/src/git.js +458 -0
- package/src/main.js +768 -0
- package/src/metrics.js +375 -0
- package/src/output.js +87 -0
- package/src/policy-command.js +172 -0
- package/src/policy.js +421 -0
- package/src/preset-command.js +91 -0
- package/src/provider-presets.js +361 -0
- package/src/providers.js +306 -0
- package/src/setup.js +268 -0
- package/src/split-checkpoint.js +252 -0
- package/src/split-hunks.js +263 -0
- package/src/split-plan.js +339 -0
- package/src/split.js +2285 -0
- package/src/team-policy.js +95 -0
- package/src/trust.js +31 -0
- package/src/ui.js +488 -0
- package/src/utils.js +165 -0
- package/templates/.aicommit.policy.json +22 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { lstat, mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import { isProviderType } from './providers.js';
|
|
8
|
+
import { fileExists } from './utils.js';
|
|
9
|
+
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const { version: CORE_VERSION } = require('../package.json');
|
|
12
|
+
|
|
13
|
+
export const PROVIDER_PRESET_KIND = 'aicommit-provider-presets';
|
|
14
|
+
export const PROVIDER_PRESET_SCHEMA_VERSION = 1;
|
|
15
|
+
export const PROVIDER_ADAPTER_CONTRACT_VERSION = 1;
|
|
16
|
+
export const PROVIDER_PRESET_FILENAME = 'provider-presets.json';
|
|
17
|
+
export const PROVIDER_PRESET_BACKUP_FILENAME = 'provider-presets.previous.json';
|
|
18
|
+
export const BUNDLED_PROVIDER_PRESET_PATH = resolve(
|
|
19
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
20
|
+
'../presets/provider-presets.json',
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const MAX_PRESET_BYTES = 256 * 1024;
|
|
24
|
+
const MAX_EXTRA_BODY_DEPTH = 8;
|
|
25
|
+
const MAX_EXTRA_BODY_NODES = 500;
|
|
26
|
+
const STABLE_SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
27
|
+
const PRERELEASE_IDENTIFIER = '(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)';
|
|
28
|
+
const CORE_SEMVER_RE = new RegExp(
|
|
29
|
+
`^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)` +
|
|
30
|
+
`(?:-(${PRERELEASE_IDENTIFIER}(?:\\.${PRERELEASE_IDENTIFIER})*))?` +
|
|
31
|
+
'(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$',
|
|
32
|
+
);
|
|
33
|
+
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
34
|
+
const FORBIDDEN_JSON_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
35
|
+
const CREDENTIAL_KEY_RE =
|
|
36
|
+
/^(?:authorization|proxy-authorization|api[-_]?key|access[-_]?token|bearer[-_]?token|token|secret(?:[-_]?key)?|client[-_]?secret|password)$/i;
|
|
37
|
+
|
|
38
|
+
function object(value) {
|
|
39
|
+
return value && typeof value === 'object' && !Array.isArray(value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assertExactKeys(value, expected, path) {
|
|
43
|
+
if (!object(value)) throw new Error(`${path} must be an object.`);
|
|
44
|
+
const allowed = new Set(expected);
|
|
45
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
46
|
+
const missing = expected.filter((key) => !Object.hasOwn(value, key));
|
|
47
|
+
if (unknown.length)
|
|
48
|
+
throw new Error(`${path} contains unknown properties: ${unknown.join(', ')}.`);
|
|
49
|
+
if (missing.length) throw new Error(`${path} is missing properties: ${missing.join(', ')}.`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function semverValue(value, path, { allowPrerelease = false } = {}) {
|
|
53
|
+
const match =
|
|
54
|
+
typeof value === 'string'
|
|
55
|
+
? value.match(allowPrerelease ? CORE_SEMVER_RE : STABLE_SEMVER_RE)
|
|
56
|
+
: null;
|
|
57
|
+
if (!match) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
allowPrerelease
|
|
60
|
+
? `${path} must be a semantic version.`
|
|
61
|
+
: `${path} must be a stable semantic version (x.y.z).`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
numbers: match.slice(1, 4).map(Number),
|
|
66
|
+
prerelease: match[4] ? match[4].split('.') : [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function compareVersions(left, right) {
|
|
71
|
+
for (let index = 0; index < 3; index++) {
|
|
72
|
+
if (left.numbers[index] !== right.numbers[index]) {
|
|
73
|
+
return left.numbers[index] - right.numbers[index];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!left.prerelease.length || !right.prerelease.length) {
|
|
77
|
+
if (!left.prerelease.length && !right.prerelease.length) return 0;
|
|
78
|
+
return left.prerelease.length ? -1 : 1;
|
|
79
|
+
}
|
|
80
|
+
const count = Math.max(left.prerelease.length, right.prerelease.length);
|
|
81
|
+
for (let index = 0; index < count; index++) {
|
|
82
|
+
const leftPart = left.prerelease[index];
|
|
83
|
+
const rightPart = right.prerelease[index];
|
|
84
|
+
if (leftPart === undefined) return -1;
|
|
85
|
+
if (rightPart === undefined) return 1;
|
|
86
|
+
if (leftPart === rightPart) continue;
|
|
87
|
+
const leftNumeric = /^\d+$/.test(leftPart);
|
|
88
|
+
const rightNumeric = /^\d+$/.test(rightPart);
|
|
89
|
+
if (leftNumeric && rightNumeric) {
|
|
90
|
+
const normalizedLeft = leftPart.replace(/^0+(?=\d)/, '');
|
|
91
|
+
const normalizedRight = rightPart.replace(/^0+(?=\d)/, '');
|
|
92
|
+
if (normalizedLeft.length !== normalizedRight.length) {
|
|
93
|
+
return normalizedLeft.length - normalizedRight.length;
|
|
94
|
+
}
|
|
95
|
+
if (normalizedLeft === normalizedRight) continue;
|
|
96
|
+
return normalizedLeft < normalizedRight ? -1 : 1;
|
|
97
|
+
}
|
|
98
|
+
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
|
|
99
|
+
return leftPart < rightPart ? -1 : 1;
|
|
100
|
+
}
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function assertExtraBody(value, path = 'provider.extraBody', depth = 0, state = { nodes: 0 }) {
|
|
105
|
+
state.nodes += 1;
|
|
106
|
+
if (state.nodes > MAX_EXTRA_BODY_NODES) throw new Error(`${path} is too complex.`);
|
|
107
|
+
if (depth > MAX_EXTRA_BODY_DEPTH) throw new Error(`${path} is nested too deeply.`);
|
|
108
|
+
if (value === null || ['string', 'boolean'].includes(typeof value)) return;
|
|
109
|
+
if (typeof value === 'number' && Number.isFinite(value)) return;
|
|
110
|
+
if (Array.isArray(value)) {
|
|
111
|
+
if (value.length > 100) throw new Error(`${path} has too many array items.`);
|
|
112
|
+
value.forEach((item, index) => assertExtraBody(item, `${path}[${index}]`, depth + 1, state));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!object(value)) throw new Error(`${path} must contain JSON-compatible values.`);
|
|
116
|
+
const entries = Object.entries(value);
|
|
117
|
+
if (entries.length > 100) throw new Error(`${path} has too many properties.`);
|
|
118
|
+
for (const [key, item] of entries) {
|
|
119
|
+
if (
|
|
120
|
+
FORBIDDEN_JSON_KEYS.has(key) ||
|
|
121
|
+
CREDENTIAL_KEY_RE.test(key) ||
|
|
122
|
+
(depth === 0 && (key === 'model' || key === 'messages'))
|
|
123
|
+
) {
|
|
124
|
+
throw new Error(`${path} contains forbidden property: ${key}.`);
|
|
125
|
+
}
|
|
126
|
+
assertExtraBody(item, `${path}.${key}`, depth + 1, state);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function securePresetUrl(value) {
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL(value);
|
|
133
|
+
const loopback =
|
|
134
|
+
url.hostname === 'localhost' ||
|
|
135
|
+
url.hostname === '127.0.0.1' ||
|
|
136
|
+
url.hostname.startsWith('127.') ||
|
|
137
|
+
url.hostname === '[::1]';
|
|
138
|
+
const secureTransport = url.protocol === 'https:' || (url.protocol === 'http:' && loopback);
|
|
139
|
+
return secureTransport && !url.username && !url.password && !url.search && !url.hash;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function validateProviderPresetManifest(value) {
|
|
146
|
+
assertExactKeys(
|
|
147
|
+
value,
|
|
148
|
+
['kind', 'schemaVersion', 'version', 'compatibility', 'providers'],
|
|
149
|
+
'Provider preset manifest',
|
|
150
|
+
);
|
|
151
|
+
if (value.kind !== PROVIDER_PRESET_KIND) {
|
|
152
|
+
throw new Error(`Provider preset kind must be "${PROVIDER_PRESET_KIND}".`);
|
|
153
|
+
}
|
|
154
|
+
if (value.schemaVersion !== PROVIDER_PRESET_SCHEMA_VERSION) {
|
|
155
|
+
throw new Error(`Provider preset schemaVersion must be ${PROVIDER_PRESET_SCHEMA_VERSION}.`);
|
|
156
|
+
}
|
|
157
|
+
semverValue(value.version, 'Provider preset version');
|
|
158
|
+
assertExactKeys(
|
|
159
|
+
value.compatibility,
|
|
160
|
+
['coreMinimum', 'coreMaximumExclusive', 'adapterContract'],
|
|
161
|
+
'Provider preset compatibility',
|
|
162
|
+
);
|
|
163
|
+
const minimum = semverValue(value.compatibility.coreMinimum, 'compatibility.coreMinimum');
|
|
164
|
+
const maximum = semverValue(
|
|
165
|
+
value.compatibility.coreMaximumExclusive,
|
|
166
|
+
'compatibility.coreMaximumExclusive',
|
|
167
|
+
);
|
|
168
|
+
if (compareVersions(minimum, maximum) >= 0) {
|
|
169
|
+
throw new Error('Provider preset compatibility range is empty.');
|
|
170
|
+
}
|
|
171
|
+
if (value.compatibility.adapterContract !== PROVIDER_ADAPTER_CONTRACT_VERSION) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Provider preset adapterContract must be ${PROVIDER_ADAPTER_CONTRACT_VERSION}.`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(value.providers) || !value.providers.length || value.providers.length > 100) {
|
|
177
|
+
throw new Error('Provider preset providers must contain 1-100 entries.');
|
|
178
|
+
}
|
|
179
|
+
const ids = new Set();
|
|
180
|
+
for (const [index, provider] of value.providers.entries()) {
|
|
181
|
+
const path = `Provider preset providers[${index}]`;
|
|
182
|
+
const required = ['id', 'label', 'adapter', 'apiUrl', 'modelId'];
|
|
183
|
+
assertExactKeys(
|
|
184
|
+
provider,
|
|
185
|
+
Object.hasOwn(provider || {}, 'extraBody') ? [...required, 'extraBody'] : required,
|
|
186
|
+
path,
|
|
187
|
+
);
|
|
188
|
+
if (
|
|
189
|
+
!ID_RE.test(provider.id) ||
|
|
190
|
+
provider.id.toLowerCase() === 'custom' ||
|
|
191
|
+
ids.has(provider.id)
|
|
192
|
+
) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`${path}.id must be unique and use letters, digits, dot, dash, or underscore.`,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
ids.add(provider.id);
|
|
198
|
+
if (
|
|
199
|
+
typeof provider.label !== 'string' ||
|
|
200
|
+
!provider.label.trim() ||
|
|
201
|
+
provider.label.length > 80 ||
|
|
202
|
+
/[\0-\x1f\x7f]/.test(provider.label)
|
|
203
|
+
) {
|
|
204
|
+
throw new Error(`${path}.label must be a non-empty string of at most 80 characters.`);
|
|
205
|
+
}
|
|
206
|
+
if (!isProviderType(provider.adapter) || provider.adapter !== provider.adapter.toLowerCase()) {
|
|
207
|
+
throw new Error(`${path}.adapter is not supported by adapter contract v1.`);
|
|
208
|
+
}
|
|
209
|
+
if (
|
|
210
|
+
!securePresetUrl(provider.apiUrl) ||
|
|
211
|
+
provider.apiUrl.length > 2048 ||
|
|
212
|
+
/[\0-\x1f\x7f]/.test(provider.apiUrl)
|
|
213
|
+
) {
|
|
214
|
+
throw new Error(
|
|
215
|
+
`${path}.apiUrl must be credential-free HTTPS, or HTTP only for localhost/loopback, without query or fragment.`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (
|
|
219
|
+
typeof provider.modelId !== 'string' ||
|
|
220
|
+
!provider.modelId.trim() ||
|
|
221
|
+
provider.modelId.length > 256 ||
|
|
222
|
+
/[\0-\x1f\x7f]/.test(provider.modelId)
|
|
223
|
+
) {
|
|
224
|
+
throw new Error(`${path}.modelId must be a non-empty string of at most 256 characters.`);
|
|
225
|
+
}
|
|
226
|
+
if (Object.hasOwn(provider, 'extraBody')) {
|
|
227
|
+
if (!object(provider.extraBody)) throw new Error(`${path}.extraBody must be an object.`);
|
|
228
|
+
assertExtraBody(provider.extraBody, `${path}.extraBody`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
return value;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function assertProviderPresetCompatibility(value, coreVersion = CORE_VERSION) {
|
|
235
|
+
validateProviderPresetManifest(value);
|
|
236
|
+
const core = semverValue(coreVersion, 'Core version', { allowPrerelease: true });
|
|
237
|
+
const minimum = semverValue(value.compatibility.coreMinimum, 'compatibility.coreMinimum');
|
|
238
|
+
const maximum = semverValue(
|
|
239
|
+
value.compatibility.coreMaximumExclusive,
|
|
240
|
+
'compatibility.coreMaximumExclusive',
|
|
241
|
+
);
|
|
242
|
+
if (compareVersions(core, minimum) < 0 || compareVersions(core, maximum) >= 0) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Provider preset v${value.version} requires aicommit >=${value.compatibility.coreMinimum} and <${value.compatibility.coreMaximumExclusive}; current core is ${coreVersion}.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function providerPresetPaths(home = homedir()) {
|
|
251
|
+
const directory = join(home, '.aicommit');
|
|
252
|
+
return {
|
|
253
|
+
bundled: BUNDLED_PROVIDER_PRESET_PATH,
|
|
254
|
+
user: join(directory, PROVIDER_PRESET_FILENAME),
|
|
255
|
+
backup: join(directory, PROVIDER_PRESET_BACKUP_FILENAME),
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function readManifest(path, coreVersion) {
|
|
260
|
+
const info = await lstat(path);
|
|
261
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
262
|
+
throw new Error(`Provider preset must be a regular non-symlinked file: ${path}`);
|
|
263
|
+
}
|
|
264
|
+
if (info.size > MAX_PRESET_BYTES) {
|
|
265
|
+
throw new Error(`Provider preset exceeds 256 KiB: ${path}`);
|
|
266
|
+
}
|
|
267
|
+
let value;
|
|
268
|
+
try {
|
|
269
|
+
value = JSON.parse(await readFile(path, 'utf8'));
|
|
270
|
+
} catch (err) {
|
|
271
|
+
throw new Error(`Failed to parse provider preset ${path}: ${err.message}`, { cause: err });
|
|
272
|
+
}
|
|
273
|
+
assertProviderPresetCompatibility(value, coreVersion);
|
|
274
|
+
return value;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export async function loadProviderPresetManifest({
|
|
278
|
+
path = null,
|
|
279
|
+
home = homedir(),
|
|
280
|
+
coreVersion,
|
|
281
|
+
} = {}) {
|
|
282
|
+
const paths = providerPresetPaths(home);
|
|
283
|
+
const selected = path
|
|
284
|
+
? resolve(path)
|
|
285
|
+
: (await fileExists(paths.user))
|
|
286
|
+
? paths.user
|
|
287
|
+
: paths.bundled;
|
|
288
|
+
return {
|
|
289
|
+
manifest: await readManifest(selected, coreVersion),
|
|
290
|
+
path: selected,
|
|
291
|
+
source: path ? 'file' : selected === paths.user ? 'user' : 'bundled',
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function assertWritableTarget(path) {
|
|
296
|
+
if (!(await fileExists(path))) return;
|
|
297
|
+
const info = await lstat(path);
|
|
298
|
+
if (!info.isFile() || info.isSymbolicLink()) {
|
|
299
|
+
throw new Error(`Refusing to replace non-regular provider preset path: ${path}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function writeTextAtomic(path, text) {
|
|
304
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
305
|
+
try {
|
|
306
|
+
await writeFile(temp, text, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
307
|
+
await rename(temp, path);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
await unlink(temp).catch(() => {});
|
|
310
|
+
throw err;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function writeAtomic(path, value) {
|
|
315
|
+
await writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function installProviderPresetManifest(
|
|
319
|
+
sourcePath,
|
|
320
|
+
{ home = homedir(), coreVersion } = {},
|
|
321
|
+
) {
|
|
322
|
+
if (!sourcePath) throw new Error('preset install requires --file=<manifest>.');
|
|
323
|
+
const source = await loadProviderPresetManifest({ path: sourcePath, home, coreVersion });
|
|
324
|
+
const paths = providerPresetPaths(home);
|
|
325
|
+
const directory = dirname(paths.user);
|
|
326
|
+
if (await fileExists(directory)) {
|
|
327
|
+
const info = await lstat(directory);
|
|
328
|
+
if (!info.isDirectory() || info.isSymbolicLink()) {
|
|
329
|
+
throw new Error(`Refusing to use non-regular provider preset directory: ${directory}`);
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
333
|
+
}
|
|
334
|
+
await assertWritableTarget(paths.user);
|
|
335
|
+
await assertWritableTarget(paths.backup);
|
|
336
|
+
let invalidBackupPath = null;
|
|
337
|
+
if (await fileExists(paths.user)) {
|
|
338
|
+
try {
|
|
339
|
+
const current = await readManifest(paths.user, coreVersion);
|
|
340
|
+
await writeAtomic(paths.backup, current);
|
|
341
|
+
} catch {
|
|
342
|
+
invalidBackupPath = join(dirname(paths.user), `provider-presets.invalid-${Date.now()}.json`);
|
|
343
|
+
await writeTextAtomic(invalidBackupPath, await readFile(paths.user, 'utf8'));
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
await writeAtomic(paths.user, source.manifest);
|
|
347
|
+
return {
|
|
348
|
+
manifest: source.manifest,
|
|
349
|
+
path: paths.user,
|
|
350
|
+
backupPath: paths.backup,
|
|
351
|
+
invalidBackupPath,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export async function rollbackProviderPresetManifest({ home = homedir(), coreVersion } = {}) {
|
|
356
|
+
const paths = providerPresetPaths(home);
|
|
357
|
+
if (!(await fileExists(paths.backup))) {
|
|
358
|
+
throw new Error(`No previous provider preset is available at ${paths.backup}.`);
|
|
359
|
+
}
|
|
360
|
+
return installProviderPresetManifest(paths.backup, { home, coreVersion });
|
|
361
|
+
}
|
package/src/providers.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
export const PROVIDER_TYPES = Object.freeze([
|
|
2
|
+
'openai',
|
|
3
|
+
'openrouter',
|
|
4
|
+
'deepseek',
|
|
5
|
+
'minimax',
|
|
6
|
+
'ollama',
|
|
7
|
+
'custom',
|
|
8
|
+
]);
|
|
9
|
+
const PROVIDER_TYPE_SET = new Set(PROVIDER_TYPES);
|
|
10
|
+
|
|
11
|
+
export function isProviderType(value) {
|
|
12
|
+
return typeof value === 'string' && PROVIDER_TYPE_SET.has(value.toLowerCase());
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function endpoint(apiUrl) {
|
|
16
|
+
try {
|
|
17
|
+
return new URL(apiUrl);
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function detectProviderType(apiUrl, explicitType = '') {
|
|
24
|
+
if (explicitType) {
|
|
25
|
+
const normalized = explicitType.toLowerCase();
|
|
26
|
+
if (/^extension:[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/.test(normalized)) return normalized;
|
|
27
|
+
if (!PROVIDER_TYPE_SET.has(normalized)) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Unknown providerType "${explicitType}". Use one of: ${PROVIDER_TYPES.join(', ')}.`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return normalized;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const url = endpoint(apiUrl);
|
|
36
|
+
const host = url?.hostname.toLowerCase() || '';
|
|
37
|
+
if (host === 'api.openai.com') return 'openai';
|
|
38
|
+
if (host === 'openrouter.ai' || host.endsWith('.openrouter.ai')) return 'openrouter';
|
|
39
|
+
if (host === 'api.deepseek.com' || host.endsWith('.deepseek.com')) return 'deepseek';
|
|
40
|
+
if (host.includes('minimax')) return 'minimax';
|
|
41
|
+
if (
|
|
42
|
+
/\/api\/(?:chat|generate)\/?$/i.test(url?.pathname || '') &&
|
|
43
|
+
(host === 'localhost' || host.startsWith('127.') || host === '[::1]')
|
|
44
|
+
) {
|
|
45
|
+
return 'ollama';
|
|
46
|
+
}
|
|
47
|
+
return 'custom';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isOpenAIReasoningModel(modelId) {
|
|
51
|
+
const id = (modelId || '').split('/').pop();
|
|
52
|
+
return /^(?:o\d|gpt-5)/i.test(id);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function openAIReasoningEfforts(modelId) {
|
|
56
|
+
const id = (modelId || '').split('/').pop().toLowerCase();
|
|
57
|
+
if (/^gpt-5\.6(?:-|$)/.test(id)) return ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
58
|
+
if (/^gpt-5\.(?:2|3|4|5)(?:-|$)/.test(id)) {
|
|
59
|
+
return ['none', 'low', 'medium', 'high', 'xhigh'];
|
|
60
|
+
}
|
|
61
|
+
if (/^gpt-5\.1(?:-|$)/.test(id)) return ['none', 'low', 'medium', 'high'];
|
|
62
|
+
if (/^gpt-5(?:-|$)/.test(id) || /^o\d(?:-|$)/.test(id)) return ['low', 'medium', 'high'];
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function openAIReasoningEffort(modelId, enabled, effort) {
|
|
67
|
+
const requested = enabled ? effort : 'none';
|
|
68
|
+
const supported = openAIReasoningEfforts(modelId);
|
|
69
|
+
if (!supported || supported.includes(requested)) return requested;
|
|
70
|
+
|
|
71
|
+
const action = enabled ? `reasoning effort "${requested}"` : 'disabling reasoning';
|
|
72
|
+
throw new Error(
|
|
73
|
+
`OpenAI model "${modelId}" does not support ${action}. ` +
|
|
74
|
+
`Supported reasoning efforts: ${supported.join(', ')}.`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function mergeRequestExtensions(payload, extensions) {
|
|
79
|
+
if (!extensions || typeof extensions !== 'object' || Array.isArray(extensions)) return;
|
|
80
|
+
const { model: _model, messages: _messages, ...safe } = extensions;
|
|
81
|
+
Object.assign(payload, safe);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function reasoningText(value) {
|
|
85
|
+
if (typeof value === 'string') return value;
|
|
86
|
+
if (Array.isArray(value)) return value.map(reasoningText).filter(Boolean).join('\n');
|
|
87
|
+
if (value && typeof value === 'object') {
|
|
88
|
+
return reasoningText(value.text ?? value.summary ?? value.content);
|
|
89
|
+
}
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function messageText(value) {
|
|
94
|
+
if (typeof value === 'string') return value;
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
return value
|
|
97
|
+
.map((part) => part?.text ?? part?.content ?? '')
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join('');
|
|
100
|
+
}
|
|
101
|
+
return value?.text ?? value?.content ?? '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function firstNumber(...values) {
|
|
105
|
+
return values.find((value) => typeof value === 'number' && Number.isFinite(value));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function normalizeUsage(usage) {
|
|
109
|
+
if (!usage || typeof usage !== 'object') return null;
|
|
110
|
+
|
|
111
|
+
const inputTokens = firstNumber(
|
|
112
|
+
usage.inputTokens,
|
|
113
|
+
usage.prompt_tokens,
|
|
114
|
+
usage.input_tokens,
|
|
115
|
+
usage.prompt_eval_count,
|
|
116
|
+
);
|
|
117
|
+
const outputTokens = firstNumber(
|
|
118
|
+
usage.outputTokens,
|
|
119
|
+
usage.completion_tokens,
|
|
120
|
+
usage.output_tokens,
|
|
121
|
+
usage.eval_count,
|
|
122
|
+
);
|
|
123
|
+
const totalTokens = firstNumber(
|
|
124
|
+
usage.totalTokens,
|
|
125
|
+
usage.total_tokens,
|
|
126
|
+
inputTokens !== undefined && outputTokens !== undefined
|
|
127
|
+
? inputTokens + outputTokens
|
|
128
|
+
: undefined,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const normalized = {};
|
|
132
|
+
if (inputTokens !== undefined) normalized.inputTokens = inputTokens;
|
|
133
|
+
if (outputTokens !== undefined) normalized.outputTokens = outputTokens;
|
|
134
|
+
if (totalTokens !== undefined) normalized.totalTokens = totalTokens;
|
|
135
|
+
return Object.keys(normalized).length ? normalized : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function normalizeResponse(provider, data) {
|
|
139
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
140
|
+
throw new Error('Provider returned an invalid response: expected a JSON object.');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const choice = data?.choices?.[0];
|
|
144
|
+
const message = choice?.message ?? data.message;
|
|
145
|
+
const content =
|
|
146
|
+
messageText(message?.content) ||
|
|
147
|
+
messageText(data?.content?.[0]?.text) ||
|
|
148
|
+
messageText(data.response);
|
|
149
|
+
const reasoning =
|
|
150
|
+
reasoningText(message?.reasoning_content) ||
|
|
151
|
+
reasoningText(message?.reasoning) ||
|
|
152
|
+
reasoningText(message?.reasoning_details) ||
|
|
153
|
+
reasoningText(message?.thinking) ||
|
|
154
|
+
reasoningText(data.thinking) ||
|
|
155
|
+
null;
|
|
156
|
+
const finishReason =
|
|
157
|
+
choice?.finish_reason ?? data.stop_reason ?? data.done_reason ?? (data.done ? 'stop' : null);
|
|
158
|
+
const usageSource =
|
|
159
|
+
data.usage ||
|
|
160
|
+
(data.prompt_eval_count !== undefined || data.eval_count !== undefined
|
|
161
|
+
? {
|
|
162
|
+
prompt_eval_count: data.prompt_eval_count,
|
|
163
|
+
eval_count: data.eval_count,
|
|
164
|
+
}
|
|
165
|
+
: null);
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
provider,
|
|
169
|
+
model: data.model || null,
|
|
170
|
+
content,
|
|
171
|
+
reasoning,
|
|
172
|
+
usage: normalizeUsage(usageSource),
|
|
173
|
+
finishReason,
|
|
174
|
+
raw: data,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function applyReasoning(payload, provider, modelId, reasoning, nativeOllama) {
|
|
179
|
+
const mode = reasoning?.mode || 'auto';
|
|
180
|
+
if (mode === 'auto') return;
|
|
181
|
+
|
|
182
|
+
const enabled = mode === 'on';
|
|
183
|
+
const effort = reasoning?.effort || 'medium';
|
|
184
|
+
|
|
185
|
+
if (provider === 'openai') {
|
|
186
|
+
if (!isOpenAIReasoningModel(modelId)) return;
|
|
187
|
+
payload.reasoning_effort = openAIReasoningEffort(modelId, enabled, effort);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (provider === 'deepseek') {
|
|
192
|
+
delete payload.enable_thinking;
|
|
193
|
+
payload.thinking = { type: enabled ? 'enabled' : 'disabled' };
|
|
194
|
+
if (enabled) {
|
|
195
|
+
payload.reasoning_effort = effort === 'low' || effort === 'max' ? effort : 'high';
|
|
196
|
+
delete payload.temperature;
|
|
197
|
+
} else {
|
|
198
|
+
delete payload.reasoning_effort;
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (provider === 'openrouter') {
|
|
204
|
+
payload.reasoning = { effort: enabled ? effort : 'none' };
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (provider === 'minimax') {
|
|
209
|
+
payload.reasoning_split = true;
|
|
210
|
+
if (enabled) {
|
|
211
|
+
delete payload.thinking;
|
|
212
|
+
delete payload.enable_thinking;
|
|
213
|
+
} else {
|
|
214
|
+
delete payload.enable_thinking;
|
|
215
|
+
payload.thinking = { type: 'disabled' };
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (provider === 'ollama' && nativeOllama) {
|
|
221
|
+
payload.think = enabled;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const customBody = enabled ? reasoning?.enabledBody : reasoning?.disabledBody;
|
|
226
|
+
if (customBody !== undefined) mergeRequestExtensions(payload, customBody);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function reasoningForFollowUp(provider, modelId, reasoning) {
|
|
230
|
+
if (reasoning?.mode !== 'on') return reasoning;
|
|
231
|
+
if (['deepseek', 'openrouter', 'minimax', 'ollama'].includes(provider)) {
|
|
232
|
+
return { ...reasoning, mode: 'off' };
|
|
233
|
+
}
|
|
234
|
+
if (provider === 'openai') {
|
|
235
|
+
const supported = openAIReasoningEfforts(modelId);
|
|
236
|
+
if (supported?.includes('none')) return { ...reasoning, mode: 'off' };
|
|
237
|
+
}
|
|
238
|
+
if (reasoning.disabledBody !== undefined) return { ...reasoning, mode: 'off' };
|
|
239
|
+
return reasoning;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function getProviderAdapter({ apiUrl, providerType = '', modelId = '' }) {
|
|
243
|
+
const provider = detectProviderType(apiUrl, providerType);
|
|
244
|
+
const url = endpoint(apiUrl);
|
|
245
|
+
const nativeOllama =
|
|
246
|
+
provider === 'ollama' && /\/api\/(?:chat|generate)\/?$/i.test(url?.pathname || '');
|
|
247
|
+
const openAIReasoning = provider === 'openai' && isOpenAIReasoningModel(modelId);
|
|
248
|
+
|
|
249
|
+
const capabilities = Object.freeze({
|
|
250
|
+
streaming: !nativeOllama,
|
|
251
|
+
reasoning:
|
|
252
|
+
provider === 'custom'
|
|
253
|
+
? 'configurable'
|
|
254
|
+
: provider === 'openai' && !openAIReasoning
|
|
255
|
+
? 'model-dependent'
|
|
256
|
+
: 'native',
|
|
257
|
+
tokenBudget: nativeOllama
|
|
258
|
+
? 'options.num_predict'
|
|
259
|
+
: openAIReasoning
|
|
260
|
+
? 'max_completion_tokens'
|
|
261
|
+
: 'max_tokens',
|
|
262
|
+
usage: true,
|
|
263
|
+
finishReason: true,
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
return Object.freeze({
|
|
267
|
+
id: provider,
|
|
268
|
+
capabilities,
|
|
269
|
+
headers: provider === 'openrouter' ? { 'X-Title': 'aicommit' } : {},
|
|
270
|
+
buildRequest({ messages, temperature, maxTokens, extraBody = {}, reasoning, streaming }) {
|
|
271
|
+
const payload = { model: modelId, messages };
|
|
272
|
+
if (nativeOllama) {
|
|
273
|
+
// Ollama's native stream is newline-delimited JSON rather than SSE.
|
|
274
|
+
// Use its complete JSON response until the request layer exposes an
|
|
275
|
+
// NDJSON consumer; reasoning still reaches the callback once parsed.
|
|
276
|
+
payload.stream = false;
|
|
277
|
+
payload.options = { temperature, num_predict: maxTokens };
|
|
278
|
+
} else if (openAIReasoning) {
|
|
279
|
+
payload.max_completion_tokens = maxTokens;
|
|
280
|
+
} else {
|
|
281
|
+
payload.temperature = temperature;
|
|
282
|
+
payload.max_tokens = maxTokens;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
mergeRequestExtensions(payload, extraBody);
|
|
286
|
+
applyReasoning(payload, provider, modelId, reasoning, nativeOllama);
|
|
287
|
+
if (streaming && !nativeOllama) {
|
|
288
|
+
payload.stream = true;
|
|
289
|
+
if (provider === 'openai') {
|
|
290
|
+
const current = payload.stream_options;
|
|
291
|
+
payload.stream_options = {
|
|
292
|
+
...(current && typeof current === 'object' && !Array.isArray(current) ? current : {}),
|
|
293
|
+
include_usage: true,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return payload;
|
|
298
|
+
},
|
|
299
|
+
normalizeResponse(data) {
|
|
300
|
+
return normalizeResponse(provider, data);
|
|
301
|
+
},
|
|
302
|
+
reasoningForFollowUp(reasoning) {
|
|
303
|
+
return reasoningForFollowUp(provider, modelId, reasoning);
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|