@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/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 { abortSplit, applySplitPlan, resumeSplit, splitFlow } from './split.js';
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
- let diff = getStagedDiff(projectRoot, config.diffContextLines);
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(toStage ? ['add', '--', ...toStage] : ['add', '-A'], projectRoot);
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
- diff = getStagedDiff(projectRoot, config.diffContextLines);
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
- diff = getStagedDiff(projectRoot, config.diffContextLines);
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 = getDiffStats(diff);
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
- // Prepare the diff the model sees (computed once — it doesn't change across
531
- // regenerations): lock-file and stripFiles contents are stubbed (they carry
532
- // no commit intent) and oversized diffs are condensed to a --stat summary
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
- const { diff: modelDiff, truncated } = condenseDiff(
537
- strippedDiff,
538
- config.maxDiffChars,
539
- getDiffStat(projectRoot),
540
- config.maxFileDiffChars,
541
- );
542
- if (truncated) {
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
- generateCommitMessage(config, modelDiff, regenerateCount, message, stream),
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
+ }