@hifullmoon/aicommit 2.3.0 → 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 +14 -1
- package/README.md +31 -5
- package/README.zh-CN.md +31 -5
- package/bin/aicommit.js +6 -1
- package/docs/distribution.md +1 -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 +10 -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/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 +114 -25
- 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 +193 -42
package/src/providers.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { clampThinkingLevel, getSupportedThinkingLevels } from '@earendil-works/pi-ai';
|
|
2
|
+
import { OPENAI_MODELS } from '@earendil-works/pi-ai/providers/openai.models';
|
|
3
|
+
import { DEEPSEEK_MODELS } from '@earendil-works/pi-ai/providers/deepseek.models';
|
|
4
|
+
import { OPENROUTER_MODELS } from '@earendil-works/pi-ai/providers/openrouter.models';
|
|
1
5
|
export const PROVIDER_TYPES = Object.freeze([
|
|
2
6
|
'openai',
|
|
3
7
|
'openrouter',
|
|
@@ -48,78 +52,6 @@ export function detectProviderType(apiUrl, explicitType = '') {
|
|
|
48
52
|
return 'custom';
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
export function isOpenAIReasoningModel(modelId) {
|
|
52
|
-
const id = (modelId || '').split('/').pop();
|
|
53
|
-
return /^(?:o\d|gpt-5)/i.test(id);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function openAIReasoningEfforts(modelId) {
|
|
57
|
-
const id = (modelId || '').split('/').pop().toLowerCase();
|
|
58
|
-
if (/^gpt-5\.6(?:-|$)/.test(id)) return ['none', 'low', 'medium', 'high', 'xhigh', 'max'];
|
|
59
|
-
if (/^gpt-5\.(?:2|3|4|5)(?:-|$)/.test(id)) {
|
|
60
|
-
return ['none', 'low', 'medium', 'high', 'xhigh'];
|
|
61
|
-
}
|
|
62
|
-
if (/^gpt-5\.1(?:-|$)/.test(id)) return ['none', 'low', 'medium', 'high'];
|
|
63
|
-
if (/^gpt-5(?:-|$)/.test(id) || /^o\d(?:-|$)/.test(id)) return ['low', 'medium', 'high'];
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Setup uses this to avoid offering an effort that a recognized official
|
|
68
|
-
// OpenAI model will reject. Other adapters either accept the common effort
|
|
69
|
-
// vocabulary, normalize it, or are model-dependent, so they retain the full
|
|
70
|
-
// list and let the user/provider make the final choice.
|
|
71
|
-
export function reasoningEffortsForModel(providerType, modelId) {
|
|
72
|
-
if ((providerType || '').toLowerCase() === 'openai') {
|
|
73
|
-
const supported = openAIReasoningEfforts(modelId);
|
|
74
|
-
if (supported) return supported.filter((effort) => effort !== 'none');
|
|
75
|
-
}
|
|
76
|
-
return [...REASONING_EFFORTS];
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function canDisableReasoningForModel(providerType, modelId) {
|
|
80
|
-
if ((providerType || '').toLowerCase() !== 'openai') return true;
|
|
81
|
-
const supported = openAIReasoningEfforts(modelId);
|
|
82
|
-
return !supported || supported.includes('none');
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function openAIReasoningEffort(modelId, enabled, effort) {
|
|
86
|
-
const requested = enabled ? effort : 'none';
|
|
87
|
-
const supported = openAIReasoningEfforts(modelId);
|
|
88
|
-
if (!supported || supported.includes(requested)) return requested;
|
|
89
|
-
|
|
90
|
-
const action = enabled ? `reasoning effort "${requested}"` : 'disabling reasoning';
|
|
91
|
-
throw new Error(
|
|
92
|
-
`OpenAI model "${modelId}" does not support ${action}. ` +
|
|
93
|
-
`Supported reasoning efforts: ${supported.join(', ')}.`,
|
|
94
|
-
);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function mergeRequestExtras(payload, extras) {
|
|
98
|
-
if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return;
|
|
99
|
-
const { model: _model, messages: _messages, ...safe } = extras;
|
|
100
|
-
Object.assign(payload, safe);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function reasoningText(value) {
|
|
104
|
-
if (typeof value === 'string') return value;
|
|
105
|
-
if (Array.isArray(value)) return value.map(reasoningText).filter(Boolean).join('\n');
|
|
106
|
-
if (value && typeof value === 'object') {
|
|
107
|
-
return reasoningText(value.text ?? value.summary ?? value.content);
|
|
108
|
-
}
|
|
109
|
-
return '';
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function messageText(value) {
|
|
113
|
-
if (typeof value === 'string') return value;
|
|
114
|
-
if (Array.isArray(value)) {
|
|
115
|
-
return value
|
|
116
|
-
.map((part) => part?.text ?? part?.content ?? '')
|
|
117
|
-
.filter(Boolean)
|
|
118
|
-
.join('');
|
|
119
|
-
}
|
|
120
|
-
return value?.text ?? value?.content ?? '';
|
|
121
|
-
}
|
|
122
|
-
|
|
123
55
|
function firstNumber(...values) {
|
|
124
56
|
return values.find((value) => typeof value === 'number' && Number.isFinite(value));
|
|
125
57
|
}
|
|
@@ -154,172 +86,168 @@ export function normalizeUsage(usage) {
|
|
|
154
86
|
return Object.keys(normalized).length ? normalized : null;
|
|
155
87
|
}
|
|
156
88
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const choice = data?.choices?.[0];
|
|
163
|
-
const message = choice?.message ?? data.message;
|
|
164
|
-
const content =
|
|
165
|
-
messageText(message?.content) ||
|
|
166
|
-
messageText(data?.content?.[0]?.text) ||
|
|
167
|
-
messageText(data.response);
|
|
168
|
-
const reasoning =
|
|
169
|
-
reasoningText(message?.reasoning_content) ||
|
|
170
|
-
reasoningText(message?.reasoning) ||
|
|
171
|
-
reasoningText(message?.reasoning_details) ||
|
|
172
|
-
reasoningText(message?.thinking) ||
|
|
173
|
-
reasoningText(data.thinking) ||
|
|
174
|
-
null;
|
|
175
|
-
const finishReason =
|
|
176
|
-
choice?.finish_reason ?? data.stop_reason ?? data.done_reason ?? (data.done ? 'stop' : null);
|
|
177
|
-
const usageSource =
|
|
178
|
-
data.usage ||
|
|
179
|
-
(data.prompt_eval_count !== undefined || data.eval_count !== undefined
|
|
180
|
-
? {
|
|
181
|
-
prompt_eval_count: data.prompt_eval_count,
|
|
182
|
-
eval_count: data.eval_count,
|
|
183
|
-
}
|
|
184
|
-
: null);
|
|
185
|
-
|
|
186
|
-
return {
|
|
187
|
-
provider,
|
|
188
|
-
model: data.model || null,
|
|
189
|
-
content,
|
|
190
|
-
reasoning,
|
|
191
|
-
usage: normalizeUsage(usageSource),
|
|
192
|
-
finishReason,
|
|
193
|
-
raw: data,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function applyReasoning(payload, provider, modelId, reasoning, nativeOllama) {
|
|
198
|
-
const mode = reasoning?.mode || 'auto';
|
|
199
|
-
if (mode === 'auto') return;
|
|
200
|
-
|
|
201
|
-
const enabled = mode === 'on';
|
|
202
|
-
const effort = reasoning?.effort || DEFAULT_REASONING_EFFORT;
|
|
203
|
-
|
|
89
|
+
// Read Pi's bundled catalog locally. Credentials and network model discovery remain
|
|
90
|
+
// outside this layer; a configured model ID need not be present in the catalog.
|
|
91
|
+
function catalogModel(provider, modelId) {
|
|
204
92
|
if (provider === 'openai') {
|
|
205
|
-
|
|
206
|
-
payload.reasoning_effort = openAIReasoningEffort(modelId, enabled, effort);
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (provider === 'deepseek') {
|
|
211
|
-
delete payload.enable_thinking;
|
|
212
|
-
payload.thinking = { type: enabled ? 'enabled' : 'disabled' };
|
|
213
|
-
if (enabled) {
|
|
214
|
-
payload.reasoning_effort = effort === 'low' || effort === 'max' ? effort : 'high';
|
|
215
|
-
delete payload.temperature;
|
|
216
|
-
} else {
|
|
217
|
-
delete payload.reasoning_effort;
|
|
218
|
-
}
|
|
219
|
-
return;
|
|
93
|
+
return OPENAI_MODELS[modelId] || OPENAI_MODELS[modelId.replace(/-codex$/, '')];
|
|
220
94
|
}
|
|
95
|
+
if (provider === 'deepseek') return DEEPSEEK_MODELS[modelId];
|
|
96
|
+
if (provider === 'openrouter') return OPENROUTER_MODELS[modelId];
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
221
99
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
}
|
|
100
|
+
export function isOpenAIReasoningModel(modelId = '') {
|
|
101
|
+
return catalogModel('openai', modelId)?.reasoning ?? /^(?:o\d|gpt-5)/i.test(modelId);
|
|
102
|
+
}
|
|
226
103
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
} else {
|
|
233
|
-
delete payload.enable_thinking;
|
|
234
|
-
payload.thinking = { type: 'disabled' };
|
|
235
|
-
}
|
|
236
|
-
return;
|
|
104
|
+
export function reasoningEffortsForModel(providerType, modelId) {
|
|
105
|
+
const known = catalogModel((providerType || '').toLowerCase(), modelId);
|
|
106
|
+
if (known?.reasoning) {
|
|
107
|
+
const supported = getSupportedThinkingLevels(known);
|
|
108
|
+
return REASONING_EFFORTS.filter((effort) => supported.includes(effort));
|
|
237
109
|
}
|
|
110
|
+
return [...REASONING_EFFORTS];
|
|
111
|
+
}
|
|
238
112
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
113
|
+
export function canDisableReasoningForModel(providerType, modelId) {
|
|
114
|
+
const known = catalogModel((providerType || '').toLowerCase(), modelId);
|
|
115
|
+
return !known || getSupportedThinkingLevels(known).includes('off');
|
|
116
|
+
}
|
|
243
117
|
|
|
244
|
-
|
|
245
|
-
if (
|
|
118
|
+
function mergeRequestExtras(payload, extras) {
|
|
119
|
+
if (!extras || typeof extras !== 'object' || Array.isArray(extras)) return;
|
|
120
|
+
const { model: _model, messages: _messages, stream: _stream, ...safe } = extras;
|
|
121
|
+
Object.assign(payload, safe);
|
|
246
122
|
}
|
|
247
123
|
|
|
248
|
-
function
|
|
249
|
-
if (reasoning?.mode
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
124
|
+
function resolveEffort(provider, model, reasoning) {
|
|
125
|
+
if ((reasoning?.mode || 'auto') === 'auto' || !model.reasoning) return undefined;
|
|
126
|
+
const requested = reasoning.mode === 'on' ? reasoning.effort || DEFAULT_REASONING_EFFORT : 'off';
|
|
127
|
+
const known = catalogModel(provider, model.id);
|
|
128
|
+
if (
|
|
129
|
+
known &&
|
|
130
|
+
['openai', 'openrouter'].includes(provider) &&
|
|
131
|
+
!getSupportedThinkingLevels(known).includes(requested)
|
|
132
|
+
) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`${provider === 'openai' ? 'OpenAI' : 'OpenRouter'} model "${model.id}" does not support ${requested === 'off' ? 'disabling reasoning' : `reasoning effort "${requested}"`}. ` +
|
|
135
|
+
`Supported reasoning efforts: ${getSupportedThinkingLevels(known).join(', ')}.`,
|
|
136
|
+
);
|
|
256
137
|
}
|
|
257
|
-
|
|
258
|
-
return
|
|
138
|
+
const level = known && provider === 'deepseek' ? clampThinkingLevel(known, requested) : requested;
|
|
139
|
+
return level === 'off' ? undefined : level;
|
|
259
140
|
}
|
|
260
141
|
|
|
142
|
+
// The application selects a Pi model and applies only configuration compatibility
|
|
143
|
+
// overrides. Pi owns message conversion, model effort mapping and wire protocols.
|
|
261
144
|
export function getProviderAdapter({ apiUrl, providerType = '', modelId = '' }) {
|
|
262
145
|
const provider = detectProviderType(apiUrl, providerType);
|
|
263
|
-
const url = endpoint(apiUrl);
|
|
264
146
|
const nativeOllama =
|
|
265
|
-
provider === 'ollama' && /\/api\/(?:chat|generate)\/?$/i.test(
|
|
147
|
+
provider === 'ollama' && /\/api\/(?:chat|generate)\/?$/i.test(endpoint(apiUrl)?.pathname || '');
|
|
148
|
+
const known = catalogModel(provider, modelId);
|
|
266
149
|
const openAIReasoning = provider === 'openai' && isOpenAIReasoningModel(modelId);
|
|
267
|
-
|
|
150
|
+
const tokenField = openAIReasoning ? 'max_completion_tokens' : 'max_tokens';
|
|
151
|
+
const model = {
|
|
152
|
+
...known,
|
|
153
|
+
id: modelId,
|
|
154
|
+
name: known?.name || modelId,
|
|
155
|
+
api: 'openai-completions',
|
|
156
|
+
provider,
|
|
157
|
+
// The transport pins the full configured URL, including nonstandard proxy paths.
|
|
158
|
+
baseUrl: endpoint(apiUrl)?.origin || '',
|
|
159
|
+
reasoning:
|
|
160
|
+
known?.reasoning ?? (openAIReasoning || ['deepseek', 'openrouter'].includes(provider)),
|
|
161
|
+
input: ['text'],
|
|
162
|
+
cost: known?.cost || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
163
|
+
contextWindow: known?.contextWindow || 128000,
|
|
164
|
+
maxTokens: known?.maxTokens || 16384,
|
|
165
|
+
compat: {
|
|
166
|
+
...(known?.api === 'openai-completions' ? known.compat : {}),
|
|
167
|
+
supportsStore: false,
|
|
168
|
+
supportsDeveloperRole: false,
|
|
169
|
+
supportsReasoningEffort: ['openai', 'deepseek', 'openrouter'].includes(provider),
|
|
170
|
+
supportsUsageInStreaming: provider === 'openai',
|
|
171
|
+
supportsFinishReason: true,
|
|
172
|
+
maxTokensField: tokenField,
|
|
173
|
+
thinkingFormat:
|
|
174
|
+
provider === 'deepseek' ? 'deepseek' : provider === 'openrouter' ? 'openrouter' : 'openai',
|
|
175
|
+
},
|
|
176
|
+
};
|
|
268
177
|
const capabilities = Object.freeze({
|
|
269
178
|
streaming: !nativeOllama,
|
|
270
179
|
reasoning:
|
|
271
180
|
provider === 'custom'
|
|
272
181
|
? 'configurable'
|
|
273
|
-
:
|
|
182
|
+
: ['openai', 'openrouter', 'deepseek'].includes(provider) && !model.reasoning
|
|
274
183
|
? 'model-dependent'
|
|
275
184
|
: 'native',
|
|
276
|
-
tokenBudget: nativeOllama
|
|
277
|
-
? 'options.num_predict'
|
|
278
|
-
: openAIReasoning
|
|
279
|
-
? 'max_completion_tokens'
|
|
280
|
-
: 'max_tokens',
|
|
185
|
+
tokenBudget: nativeOllama ? 'options.num_predict' : tokenField,
|
|
281
186
|
usage: true,
|
|
282
187
|
finishReason: true,
|
|
283
188
|
});
|
|
284
|
-
|
|
285
189
|
return Object.freeze({
|
|
286
190
|
id: provider,
|
|
191
|
+
model,
|
|
192
|
+
nativeOllama,
|
|
287
193
|
capabilities,
|
|
288
194
|
headers: provider === 'openrouter' ? { 'X-Title': 'aicommit' } : {},
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
payload
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
195
|
+
options({ temperature, maxTokens, extraBody, reasoning }) {
|
|
196
|
+
const mode = reasoning?.mode || 'auto';
|
|
197
|
+
const reasoningEffort = resolveEffort(provider, model, reasoning);
|
|
198
|
+
return {
|
|
199
|
+
maxTokens,
|
|
200
|
+
temperature:
|
|
201
|
+
openAIReasoning || (provider === 'deepseek' && mode === 'on') ? undefined : temperature,
|
|
202
|
+
reasoningEffort,
|
|
203
|
+
cacheRetention: 'none',
|
|
204
|
+
onPayload(payload) {
|
|
205
|
+
// Pi's absent effort means "off"; AICommit's auto means server defaults.
|
|
206
|
+
const reasoningKeys = ['thinking', 'reasoning', 'reasoning_effort'];
|
|
207
|
+
const mapped = Object.fromEntries(
|
|
208
|
+
reasoningKeys
|
|
209
|
+
.filter((key) => payload[key] !== undefined)
|
|
210
|
+
.map((key) => [key, payload[key]]),
|
|
211
|
+
);
|
|
212
|
+
if (mode === 'auto') for (const key of reasoningKeys) delete payload[key];
|
|
213
|
+
mergeRequestExtras(payload, extraBody);
|
|
214
|
+
if (mode !== 'auto') {
|
|
215
|
+
if (model.reasoning) {
|
|
216
|
+
for (const key of reasoningKeys) delete payload[key];
|
|
217
|
+
Object.assign(payload, mapped);
|
|
218
|
+
if (provider === 'openai' && mode === 'off')
|
|
219
|
+
payload.reasoning_effort = model.thinkingLevelMap?.off || 'none';
|
|
220
|
+
}
|
|
221
|
+
if (provider === 'deepseek') {
|
|
222
|
+
delete payload.enable_thinking;
|
|
223
|
+
if (mode === 'on') delete payload.temperature;
|
|
224
|
+
} else if (provider === 'minimax') {
|
|
225
|
+
payload.reasoning_split = true;
|
|
226
|
+
delete payload.enable_thinking;
|
|
227
|
+
if (mode === 'off') payload.thinking = { type: 'disabled' };
|
|
228
|
+
else delete payload.thinking;
|
|
229
|
+
} else if (nativeOllama) {
|
|
230
|
+
payload.think = mode === 'on';
|
|
231
|
+
} else if (provider === 'custom' || provider === 'ollama') {
|
|
232
|
+
mergeRequestExtras(
|
|
233
|
+
payload,
|
|
234
|
+
mode === 'on' ? reasoning?.enabledBody : reasoning?.disabledBody,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
if (provider === 'openai') {
|
|
239
|
+
payload.stream_options = { ...payload.stream_options, include_usage: true };
|
|
240
|
+
}
|
|
241
|
+
payload.stream = true;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
320
244
|
},
|
|
321
245
|
reasoningForFollowUp(reasoning) {
|
|
322
|
-
|
|
246
|
+
if (reasoning?.mode !== 'on') return reasoning;
|
|
247
|
+
if (provider !== 'custom' && canDisableReasoningForModel(provider, modelId))
|
|
248
|
+
return { ...reasoning, mode: 'off' };
|
|
249
|
+
if (reasoning.disabledBody !== undefined) return { ...reasoning, mode: 'off' };
|
|
250
|
+
return reasoning;
|
|
323
251
|
},
|
|
324
252
|
});
|
|
325
253
|
}
|