@hifullmoon/aicommit 2.3.0 → 2.4.1

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/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
- function normalizeResponse(provider, data) {
158
- if (!data || typeof data !== 'object' || Array.isArray(data)) {
159
- throw new Error('Provider returned an invalid response: expected a JSON object.');
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
- if (!isOpenAIReasoningModel(modelId)) return;
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
- if (provider === 'openrouter') {
223
- payload.reasoning = { effort: enabled ? effort : 'none' };
224
- return;
225
- }
100
+ export function isOpenAIReasoningModel(modelId = '') {
101
+ return catalogModel('openai', modelId)?.reasoning ?? /^(?:o\d|gpt-5)/i.test(modelId);
102
+ }
226
103
 
227
- if (provider === 'minimax') {
228
- payload.reasoning_split = true;
229
- if (enabled) {
230
- delete payload.thinking;
231
- delete payload.enable_thinking;
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
- if (provider === 'ollama' && nativeOllama) {
240
- payload.think = enabled;
241
- return;
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
- const customBody = enabled ? reasoning?.enabledBody : reasoning?.disabledBody;
245
- if (customBody !== undefined) mergeRequestExtras(payload, customBody);
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 reasoningForFollowUp(provider, modelId, reasoning) {
249
- if (reasoning?.mode !== 'on') return reasoning;
250
- if (['deepseek', 'openrouter', 'minimax', 'ollama'].includes(provider)) {
251
- return { ...reasoning, mode: 'off' };
252
- }
253
- if (provider === 'openai') {
254
- const supported = openAIReasoningEfforts(modelId);
255
- if (supported?.includes('none')) return { ...reasoning, mode: 'off' };
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
- if (reasoning.disabledBody !== undefined) return { ...reasoning, mode: 'off' };
258
- return reasoning;
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(url?.pathname || '');
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
- : provider === 'openai' && !openAIReasoning
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
- buildRequest({ messages, temperature, maxTokens, extraBody = {}, reasoning, streaming }) {
290
- const payload = { model: modelId, messages };
291
- if (nativeOllama) {
292
- // Ollama's native stream is newline-delimited JSON rather than SSE.
293
- // Use its complete JSON response until the request layer exposes an
294
- // NDJSON consumer; reasoning still reaches the callback once parsed.
295
- payload.stream = false;
296
- payload.options = { temperature, num_predict: maxTokens };
297
- } else if (openAIReasoning) {
298
- payload.max_completion_tokens = maxTokens;
299
- } else {
300
- payload.temperature = temperature;
301
- payload.max_tokens = maxTokens;
302
- }
303
-
304
- mergeRequestExtras(payload, extraBody);
305
- applyReasoning(payload, provider, modelId, reasoning, nativeOllama);
306
- if (streaming && !nativeOllama) {
307
- payload.stream = true;
308
- if (provider === 'openai') {
309
- const current = payload.stream_options;
310
- payload.stream_options = {
311
- ...(current && typeof current === 'object' && !Array.isArray(current) ? current : {}),
312
- include_usage: true,
313
- };
314
- }
315
- }
316
- return payload;
317
- },
318
- normalizeResponse(data) {
319
- return normalizeResponse(provider, data);
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
- return reasoningForFollowUp(provider, modelId, reasoning);
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
  }
package/src/runtime.js ADDED
@@ -0,0 +1,6 @@
1
+ export const MIN_NODE_VERSION = '22.19.0';
2
+
3
+ export function nodeSupported(version = process.versions.node) {
4
+ const [major, minor] = version.split('.').map(Number);
5
+ return major > 22 || (major === 22 && minor >= 19);
6
+ }