@dongowu/git-ai-cli 1.0.19 → 1.0.21

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.
Files changed (63) hide show
  1. package/README.md +171 -94
  2. package/README_EN.md +179 -84
  3. package/dist/cli.js +2 -135
  4. package/dist/cli.js.map +1 -1
  5. package/dist/cli_main.d.ts +2 -0
  6. package/dist/cli_main.d.ts.map +1 -0
  7. package/dist/cli_main.js +255 -0
  8. package/dist/cli_main.js.map +1 -0
  9. package/dist/commands/branch.d.ts +11 -0
  10. package/dist/commands/branch.d.ts.map +1 -0
  11. package/dist/commands/branch.js +279 -0
  12. package/dist/commands/branch.js.map +1 -0
  13. package/dist/commands/commit.d.ts.map +1 -1
  14. package/dist/commands/commit.js +52 -9
  15. package/dist/commands/commit.js.map +1 -1
  16. package/dist/commands/config_manage.d.ts +14 -0
  17. package/dist/commands/config_manage.d.ts.map +1 -0
  18. package/dist/commands/config_manage.js +394 -0
  19. package/dist/commands/config_manage.js.map +1 -0
  20. package/dist/commands/hook.d.ts.map +1 -1
  21. package/dist/commands/hook.js +124 -6
  22. package/dist/commands/hook.js.map +1 -1
  23. package/dist/commands/msg.d.ts +1 -0
  24. package/dist/commands/msg.d.ts.map +1 -1
  25. package/dist/commands/msg.js +40 -6
  26. package/dist/commands/msg.js.map +1 -1
  27. package/dist/commands/pr.d.ts +8 -0
  28. package/dist/commands/pr.d.ts.map +1 -0
  29. package/dist/commands/pr.js +96 -0
  30. package/dist/commands/pr.js.map +1 -0
  31. package/dist/commands/release.d.ts +8 -0
  32. package/dist/commands/release.d.ts.map +1 -0
  33. package/dist/commands/release.js +95 -0
  34. package/dist/commands/release.js.map +1 -0
  35. package/dist/commands/report.d.ts +7 -2
  36. package/dist/commands/report.d.ts.map +1 -1
  37. package/dist/commands/report.js +99 -15
  38. package/dist/commands/report.js.map +1 -1
  39. package/dist/types.d.ts +26 -0
  40. package/dist/types.d.ts.map +1 -1
  41. package/dist/types.js.map +1 -1
  42. package/dist/utils/agent_lite.d.ts +5 -0
  43. package/dist/utils/agent_lite.d.ts.map +1 -0
  44. package/dist/utils/agent_lite.js +263 -0
  45. package/dist/utils/agent_lite.js.map +1 -0
  46. package/dist/utils/ai.d.ts +22 -0
  47. package/dist/utils/ai.d.ts.map +1 -1
  48. package/dist/utils/ai.js +852 -37
  49. package/dist/utils/ai.js.map +1 -1
  50. package/dist/utils/config.d.ts +5 -0
  51. package/dist/utils/config.d.ts.map +1 -1
  52. package/dist/utils/config.js +157 -5
  53. package/dist/utils/config.js.map +1 -1
  54. package/dist/utils/git.d.ts +17 -0
  55. package/dist/utils/git.d.ts.map +1 -1
  56. package/dist/utils/git.js +275 -49
  57. package/dist/utils/git.js.map +1 -1
  58. package/dist/utils/update.d.ts +3 -1
  59. package/dist/utils/update.d.ts.map +1 -1
  60. package/dist/utils/update.js +53 -2
  61. package/dist/utils/update.js.map +1 -1
  62. package/package.json +1 -1
  63. package/release_notes.md +3 -2
package/dist/utils/ai.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import OpenAI from 'openai';
2
2
  import { runAgentLoop } from './agent.js';
3
+ import { runAgentLite } from './agent_lite.js';
3
4
  import { getFileStats } from './git.js';
4
5
  import chalk from 'chalk';
5
6
  function getTimeoutMs() {
@@ -9,6 +10,45 @@ function getTimeoutMs() {
9
10
  return parsed;
10
11
  return 120_000; // 2 minutes
11
12
  }
13
+ function parseBooleanEnv(value) {
14
+ if (!value)
15
+ return undefined;
16
+ const normalized = value.trim().toLowerCase();
17
+ if (['1', 'true', 'yes', 'y', 'on'].includes(normalized))
18
+ return true;
19
+ if (['0', 'false', 'no', 'n', 'off'].includes(normalized))
20
+ return false;
21
+ return undefined;
22
+ }
23
+ function getMaxOutputTokens(numChoices) {
24
+ const raw = process.env.GIT_AI_MAX_OUTPUT_TOKENS || process.env.OCO_TOKENS_MAX_OUTPUT;
25
+ const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
26
+ const base = Number.isFinite(parsed) && parsed > 0 ? parsed : 500;
27
+ return base * Math.max(numChoices, 1);
28
+ }
29
+ function getModelCandidates(config) {
30
+ const primary = (config.model || '').trim();
31
+ const fallbacks = Array.isArray(config.fallbackModels) ? config.fallbackModels : [];
32
+ const cleaned = fallbacks.map((m) => String(m).trim()).filter(Boolean);
33
+ const unique = [primary, ...cleaned.filter((m) => m && m !== primary)];
34
+ return unique.filter(Boolean);
35
+ }
36
+ function redactSecrets(input) {
37
+ if (!input)
38
+ return input;
39
+ // Generic "api key: xxxx" patterns.
40
+ let out = input.replace(/(api[_ -]?key\s*[:=]\s*)([^\s,;]+)/gi, (_m, p1, p2) => {
41
+ const s = String(p2);
42
+ if (s.length <= 8)
43
+ return `${p1}********`;
44
+ return `${p1}${s.slice(0, 2)}****${s.slice(-2)}`;
45
+ });
46
+ // Common OpenAI-style keys: sk-...
47
+ out = out.replace(/\bsk-[A-Za-z0-9]{8,}\b/g, (m) => `${m.slice(0, 4)}****${m.slice(-2)}`);
48
+ // Avoid leaking long tokens in error messages.
49
+ out = out.replace(/\b[A-Za-z0-9_-]{24,}\b/g, (m) => `${m.slice(0, 3)}****${m.slice(-3)}`);
50
+ return out;
51
+ }
12
52
  function formatAgentFailureReason(error) {
13
53
  const err = error;
14
54
  const status = typeof err?.status === 'number' ? String(err.status) : '';
@@ -18,19 +58,30 @@ function formatAgentFailureReason(error) {
18
58
  const rawMsg = (typeof err?.error?.message === 'string' && err.error.message) ||
19
59
  (typeof err?.message === 'string' && err.message) ||
20
60
  '';
21
- const compactMsg = rawMsg.replace(/\s+/g, ' ').trim();
61
+ const compactMsg = redactSecrets(rawMsg).replace(/\s+/g, ' ').trim();
22
62
  const shortMsg = compactMsg.length > 180 ? compactMsg.slice(0, 180) + '...' : compactMsg;
23
63
  const parts = [status || name, code, type, shortMsg].filter(Boolean);
24
64
  return parts.join(' ');
25
65
  }
26
- function resolveAgentModel(config) {
66
+ function resolveAgentStrategy(_config) {
67
+ const raw = (process.env.GIT_AI_AGENT_STRATEGY || '').trim().toLowerCase();
68
+ if (raw === 'tools' || raw === 'tool' || raw === 'function' || raw === 'functions')
69
+ return 'tools';
70
+ if (raw === 'lite' || raw === 'local' || raw === 'fast')
71
+ return 'lite';
72
+ // Default: lite (fewer API calls, works for providers without tool calling).
73
+ // Users can opt-in to tool calling with GIT_AI_AGENT_STRATEGY=tools.
74
+ return 'lite';
75
+ }
76
+ function resolveAgentModel(config, strategy) {
27
77
  const envModel = process.env.GIT_AI_AGENT_MODEL;
28
78
  if (envModel && envModel.trim())
29
79
  return envModel.trim();
30
80
  const configured = config.agentModel;
31
81
  if (configured && configured.trim())
32
82
  return configured.trim();
33
- if (config.provider === 'deepseek') {
83
+ // DeepSeek reasoner models are often not tool-capable; switch to a tool-capable model only when needed.
84
+ if (strategy === 'tools' && config.provider === 'deepseek') {
34
85
  const base = (config.model || '').trim();
35
86
  if (base.toLowerCase().includes('reasoner')) {
36
87
  return 'deepseek-chat';
@@ -38,6 +89,545 @@ function resolveAgentModel(config) {
38
89
  }
39
90
  return config.model;
40
91
  }
92
+ function isAgentDisabled() {
93
+ return parseBooleanEnv(process.env.GIT_AI_DISABLE_AGENT) === true;
94
+ }
95
+ function isAutoAgentEnabled() {
96
+ const raw = parseBooleanEnv(process.env.GIT_AI_AUTO_AGENT);
97
+ if (raw === undefined)
98
+ return true;
99
+ return raw;
100
+ }
101
+ function stripCodeFences(text) {
102
+ return text.replace(/^```[a-z]*\n?/i, '').replace(/```$/i, '').trim();
103
+ }
104
+ function tryParseMessagesJson(content) {
105
+ try {
106
+ const parsed = JSON.parse(content);
107
+ if (Array.isArray(parsed)) {
108
+ const arr = parsed.filter((item) => typeof item === 'string');
109
+ return arr.map((s) => s.trim()).filter(Boolean);
110
+ }
111
+ if (parsed && typeof parsed === 'object') {
112
+ const anyObj = parsed;
113
+ if (typeof anyObj.message === 'string') {
114
+ const msg = anyObj.message.trim();
115
+ return msg ? [msg] : [];
116
+ }
117
+ if (Array.isArray(anyObj.messages)) {
118
+ const arr = anyObj.messages.filter((item) => typeof item === 'string');
119
+ return arr.map((s) => s.trim()).filter(Boolean);
120
+ }
121
+ }
122
+ }
123
+ catch {
124
+ // ignore
125
+ }
126
+ return null;
127
+ }
128
+ const DEFAULT_COMMIT_RULES = {
129
+ types: ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'build', 'ci'],
130
+ maxSubjectLength: 50,
131
+ requireScope: false,
132
+ issuePattern: /([A-Z]+-\d+|#\d+)/,
133
+ issuePlacement: 'footer',
134
+ issueFooterPrefix: 'Refs',
135
+ requireIssue: false,
136
+ };
137
+ const RULES_PRESETS = {
138
+ conventional: {
139
+ types: ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'build', 'ci'],
140
+ maxSubjectLength: 50,
141
+ requireScope: false,
142
+ },
143
+ angular: {
144
+ types: ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'],
145
+ maxSubjectLength: 50,
146
+ requireScope: true,
147
+ },
148
+ minimal: {
149
+ types: ['feat', 'fix', 'docs', 'refactor', 'perf', 'test', 'chore'],
150
+ maxSubjectLength: 50,
151
+ requireScope: false,
152
+ },
153
+ };
154
+ export function validateCommitRules(raw) {
155
+ const errors = [];
156
+ const warnings = [];
157
+ if (raw === undefined)
158
+ return { errors, warnings };
159
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
160
+ errors.push('rules must be an object');
161
+ return { errors, warnings };
162
+ }
163
+ const rules = raw;
164
+ if (rules.types !== undefined) {
165
+ if (!Array.isArray(rules.types)) {
166
+ errors.push('rules.types must be an array of strings');
167
+ }
168
+ else {
169
+ const cleaned = rules.types.filter((t) => typeof t === 'string' && t.trim());
170
+ if (cleaned.length === 0) {
171
+ errors.push('rules.types must not be empty');
172
+ }
173
+ if (cleaned.length !== rules.types.length) {
174
+ warnings.push('rules.types contains non-string entries');
175
+ }
176
+ }
177
+ }
178
+ if (rules.scopes !== undefined) {
179
+ if (!Array.isArray(rules.scopes)) {
180
+ errors.push('rules.scopes must be an array of strings');
181
+ }
182
+ else {
183
+ const cleaned = rules.scopes.filter((s) => typeof s === 'string' && s.trim());
184
+ if (cleaned.length !== rules.scopes.length) {
185
+ warnings.push('rules.scopes contains non-string entries');
186
+ }
187
+ }
188
+ }
189
+ if (rules.scopeMap !== undefined) {
190
+ if (!rules.scopeMap || typeof rules.scopeMap !== 'object' || Array.isArray(rules.scopeMap)) {
191
+ errors.push('rules.scopeMap must be an object map of path -> scope');
192
+ }
193
+ else {
194
+ const entries = Object.entries(rules.scopeMap);
195
+ const valid = entries.filter(([k, v]) => typeof k === 'string' && k.trim() && typeof v === 'string' && v.trim());
196
+ if (valid.length !== entries.length) {
197
+ warnings.push('rules.scopeMap contains invalid entries');
198
+ }
199
+ }
200
+ }
201
+ if (rules.maxSubjectLength !== undefined) {
202
+ if (typeof rules.maxSubjectLength !== 'number' || !Number.isFinite(rules.maxSubjectLength)) {
203
+ errors.push('rules.maxSubjectLength must be a number');
204
+ }
205
+ else if (rules.maxSubjectLength <= 10) {
206
+ warnings.push('rules.maxSubjectLength should be > 10');
207
+ }
208
+ }
209
+ if (rules.requireScope !== undefined && typeof rules.requireScope !== 'boolean') {
210
+ errors.push('rules.requireScope must be a boolean');
211
+ }
212
+ if (rules.issuePattern !== undefined) {
213
+ if (typeof rules.issuePattern !== 'string' || !rules.issuePattern.trim()) {
214
+ errors.push('rules.issuePattern must be a non-empty string');
215
+ }
216
+ else {
217
+ try {
218
+ // eslint-disable-next-line no-new
219
+ new RegExp(rules.issuePattern);
220
+ }
221
+ catch {
222
+ errors.push('rules.issuePattern must be a valid regex string');
223
+ }
224
+ }
225
+ }
226
+ if (rules.issuePlacement !== undefined) {
227
+ if (rules.issuePlacement !== 'scope' &&
228
+ rules.issuePlacement !== 'subject' &&
229
+ rules.issuePlacement !== 'footer') {
230
+ errors.push('rules.issuePlacement must be scope, subject, or footer');
231
+ }
232
+ }
233
+ if (rules.issueFooterPrefix !== undefined && typeof rules.issueFooterPrefix !== 'string') {
234
+ errors.push('rules.issueFooterPrefix must be a string');
235
+ }
236
+ if (rules.requireIssue !== undefined && typeof rules.requireIssue !== 'boolean') {
237
+ errors.push('rules.requireIssue must be a boolean');
238
+ }
239
+ return { errors, warnings };
240
+ }
241
+ function normalizeRules(config) {
242
+ const presetName = (config.rulesPreset || '').trim().toLowerCase();
243
+ const preset = presetName ? RULES_PRESETS[presetName] : undefined;
244
+ const custom = config.rules;
245
+ const resolvedTypes = Array.isArray(custom?.types) && custom.types.length > 0
246
+ ? custom.types.filter((t) => typeof t === 'string' && t.trim()).map((t) => t.trim())
247
+ : Array.isArray(preset?.types) && preset?.types?.length
248
+ ? preset.types
249
+ : DEFAULT_COMMIT_RULES.types;
250
+ const types = resolvedTypes.length ? resolvedTypes : DEFAULT_COMMIT_RULES.types;
251
+ const scopes = Array.isArray(custom?.scopes) && custom.scopes.length > 0
252
+ ? custom.scopes.filter((s) => typeof s === 'string' && s.trim()).map((s) => s.trim())
253
+ : Array.isArray(preset?.scopes) && preset?.scopes?.length
254
+ ? preset.scopes
255
+ : undefined;
256
+ const presetScopeMap = preset?.scopeMap && typeof preset.scopeMap === 'object'
257
+ ? Object.fromEntries(Object.entries(preset.scopeMap).filter(([k, v]) => typeof k === 'string' && typeof v === 'string' && k.trim() && v.trim()))
258
+ : undefined;
259
+ const customScopeMap = custom?.scopeMap && typeof custom.scopeMap === 'object'
260
+ ? Object.fromEntries(Object.entries(custom.scopeMap).filter(([k, v]) => typeof k === 'string' && typeof v === 'string' && k.trim() && v.trim()))
261
+ : undefined;
262
+ const scopeMap = presetScopeMap || customScopeMap ? { ...(presetScopeMap || {}), ...(customScopeMap || {}) } : undefined;
263
+ const maxSubjectLength = typeof custom?.maxSubjectLength === 'number' && custom.maxSubjectLength > 10
264
+ ? Math.floor(custom.maxSubjectLength)
265
+ : typeof preset?.maxSubjectLength === 'number' && preset.maxSubjectLength > 10
266
+ ? Math.floor(preset.maxSubjectLength)
267
+ : DEFAULT_COMMIT_RULES.maxSubjectLength;
268
+ const requireScope = typeof custom?.requireScope === 'boolean'
269
+ ? custom.requireScope
270
+ : typeof preset?.requireScope === 'boolean'
271
+ ? preset.requireScope
272
+ : DEFAULT_COMMIT_RULES.requireScope;
273
+ const issuePatternRaw = typeof custom?.issuePattern === 'string' && custom.issuePattern.trim()
274
+ ? custom.issuePattern.trim()
275
+ : typeof preset?.issuePattern === 'string' && preset.issuePattern.trim()
276
+ ? preset.issuePattern.trim()
277
+ : undefined;
278
+ let issuePattern = DEFAULT_COMMIT_RULES.issuePattern;
279
+ if (issuePatternRaw) {
280
+ try {
281
+ issuePattern = new RegExp(issuePatternRaw);
282
+ }
283
+ catch {
284
+ issuePattern = DEFAULT_COMMIT_RULES.issuePattern;
285
+ }
286
+ }
287
+ const issuePlacement = custom?.issuePlacement === 'scope' ||
288
+ custom?.issuePlacement === 'subject' ||
289
+ custom?.issuePlacement === 'footer'
290
+ ? custom.issuePlacement
291
+ : preset?.issuePlacement === 'scope' ||
292
+ preset?.issuePlacement === 'subject' ||
293
+ preset?.issuePlacement === 'footer'
294
+ ? preset.issuePlacement
295
+ : DEFAULT_COMMIT_RULES.issuePlacement;
296
+ const issueFooterPrefix = typeof custom?.issueFooterPrefix === 'string' && custom.issueFooterPrefix.trim()
297
+ ? custom.issueFooterPrefix.trim()
298
+ : typeof preset?.issueFooterPrefix === 'string' && preset.issueFooterPrefix.trim()
299
+ ? preset.issueFooterPrefix.trim()
300
+ : DEFAULT_COMMIT_RULES.issueFooterPrefix;
301
+ const requireIssue = typeof custom?.requireIssue === 'boolean'
302
+ ? custom.requireIssue
303
+ : typeof preset?.requireIssue === 'boolean'
304
+ ? preset.requireIssue
305
+ : DEFAULT_COMMIT_RULES.requireIssue;
306
+ return {
307
+ types,
308
+ scopes,
309
+ scopeMap,
310
+ maxSubjectLength,
311
+ requireScope,
312
+ issuePattern,
313
+ issuePlacement,
314
+ issueFooterPrefix,
315
+ requireIssue,
316
+ };
317
+ }
318
+ function normalizePath(path) {
319
+ return path.replace(/\\/g, '/');
320
+ }
321
+ function inferTypeFromBranch(branchName) {
322
+ const branch = branchName || '';
323
+ if (/^feature\//.test(branch))
324
+ return 'feat';
325
+ if (/^bugfix\//.test(branch))
326
+ return 'fix';
327
+ if (/^hotfix\//.test(branch))
328
+ return 'fix';
329
+ if (/^release\//.test(branch))
330
+ return 'chore';
331
+ if (/^docs\//.test(branch))
332
+ return 'docs';
333
+ return null;
334
+ }
335
+ function inferScopeFromBranch(branchName) {
336
+ if (!branchName)
337
+ return null;
338
+ const patterns = [
339
+ /^(?:feature|bugfix|hotfix|release|dev)\/([a-zA-Z0-9_-]+)/,
340
+ /^(?:feat|fix|chore|docs)\/([a-zA-Z0-9_-]+)/,
341
+ /^[A-Z]+-\d+/,
342
+ ];
343
+ for (const pattern of patterns) {
344
+ const match = branchName.match(pattern);
345
+ if (match) {
346
+ return match[1].toLowerCase().replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
347
+ }
348
+ }
349
+ return null;
350
+ }
351
+ function inferScopeFromPaths(stagedFiles, scopeMap) {
352
+ if (!stagedFiles || stagedFiles.length === 0)
353
+ return null;
354
+ if (scopeMap) {
355
+ const normalizedMap = Object.entries(scopeMap)
356
+ .map(([prefix, scope]) => [normalizePath(prefix), scope])
357
+ .sort((a, b) => b[0].length - a[0].length);
358
+ for (const file of stagedFiles) {
359
+ const normalized = normalizePath(file);
360
+ for (const [prefix, scope] of normalizedMap) {
361
+ if (!prefix)
362
+ continue;
363
+ const anchor = prefix.endsWith('/') ? prefix : `${prefix}/`;
364
+ if (normalized.startsWith(prefix) || normalized.includes(anchor)) {
365
+ return scope;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ // Monorepo auto-scope (packages/apps/services/libs)
371
+ for (const file of stagedFiles) {
372
+ const normalized = normalizePath(file);
373
+ const match = normalized.match(/^(?:packages|apps|services|libs)\/([^/]+)/);
374
+ if (match) {
375
+ return match[1];
376
+ }
377
+ }
378
+ return null;
379
+ }
380
+ function pickScope(inputScope, rules, branchName, stagedFiles) {
381
+ let scope = inputScope?.trim();
382
+ if (!scope) {
383
+ scope = inferScopeFromBranch(branchName) || inferScopeFromPaths(stagedFiles, rules.scopeMap) || undefined;
384
+ }
385
+ if (scope && rules.scopes && !rules.scopes.includes(scope)) {
386
+ scope = undefined;
387
+ }
388
+ if (!scope && rules.requireScope) {
389
+ scope = inferScopeFromBranch(branchName) || inferScopeFromPaths(stagedFiles, rules.scopeMap) || 'core';
390
+ }
391
+ return scope || null;
392
+ }
393
+ function normalizeType(inputType, rules, branchName) {
394
+ const trimmed = inputType?.trim();
395
+ if (trimmed && rules.types.includes(trimmed))
396
+ return trimmed;
397
+ const inferred = inferTypeFromBranch(branchName);
398
+ if (inferred && rules.types.includes(inferred))
399
+ return inferred;
400
+ return rules.types[0] || 'chore';
401
+ }
402
+ function cleanSubject(subject, rules, locale) {
403
+ let out = (subject || '').trim();
404
+ out = out.replace(/[。.]$/, '').trim();
405
+ if (!out) {
406
+ out = locale === 'zh' ? '更新代码' : 'update code';
407
+ }
408
+ if (out.length > rules.maxSubjectLength) {
409
+ out = out.slice(0, rules.maxSubjectLength).trim();
410
+ }
411
+ return out;
412
+ }
413
+ function findIssue(text, pattern) {
414
+ if (!text)
415
+ return null;
416
+ const match = text.match(pattern);
417
+ return match ? match[0] : null;
418
+ }
419
+ function resolveIssueId(message, branchName, rules) {
420
+ const fromMessage = findIssue(message, rules.issuePattern);
421
+ if (fromMessage)
422
+ return { issue: fromMessage, fromMessage: true };
423
+ const fromBranch = findIssue(branchName, rules.issuePattern);
424
+ if (fromBranch)
425
+ return { issue: fromBranch, fromMessage: false };
426
+ return { issue: null, fromMessage: false };
427
+ }
428
+ function normalizeIssueId(issue, placement) {
429
+ if (placement === 'scope') {
430
+ return issue.replace(/^#/, '');
431
+ }
432
+ return issue;
433
+ }
434
+ function formatCommitMessage(type, scope, subject, locale, body, risks, footer) {
435
+ const head = `${type}${scope ? `(${scope})` : ''}: ${subject}`;
436
+ const bodyParts = [];
437
+ if (body && body.trim())
438
+ bodyParts.push(body.trim());
439
+ if (risks && risks.trim()) {
440
+ bodyParts.push(`${locale === 'zh' ? '风险' : 'Risks'}: ${risks.trim()}`);
441
+ }
442
+ if (footer && footer.trim()) {
443
+ bodyParts.push(footer.trim());
444
+ }
445
+ if (bodyParts.length === 0)
446
+ return head;
447
+ return `${head}\n\n${bodyParts.join('\n\n')}`;
448
+ }
449
+ function enforceRulesOnTextMessage(message, rules, locale, branchName, stagedFiles) {
450
+ const lines = message.split('\n');
451
+ const head = lines[0]?.trim() || '';
452
+ const body = lines.slice(1).join('\n').trim() || undefined;
453
+ const match = head.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/);
454
+ const type = normalizeType(match?.[1], rules, branchName);
455
+ let scope = pickScope(match?.[2], rules, branchName, stagedFiles);
456
+ let subject = cleanSubject(match?.[3] || head, rules, locale);
457
+ const issueInfo = resolveIssueId(message, branchName, rules);
458
+ let footer;
459
+ if (issueInfo.issue && !issueInfo.fromMessage) {
460
+ const issue = normalizeIssueId(issueInfo.issue, rules.issuePlacement);
461
+ if (rules.issuePlacement === 'scope') {
462
+ if (!scope || scope === 'core') {
463
+ if (!rules.scopes || rules.scopes.includes(issue)) {
464
+ scope = issue;
465
+ }
466
+ else {
467
+ footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
468
+ }
469
+ }
470
+ else {
471
+ footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
472
+ }
473
+ }
474
+ else if (rules.issuePlacement === 'subject') {
475
+ subject = cleanSubject(`${issue} ${subject}`, rules, locale);
476
+ }
477
+ else {
478
+ footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
479
+ }
480
+ }
481
+ return formatCommitMessage(type, scope, subject, locale, body, undefined, footer);
482
+ }
483
+ export function validateCommitMessage(message, config, context) {
484
+ const errors = [];
485
+ const warnings = [];
486
+ const rules = normalizeRules(config);
487
+ const lines = message.split('\n');
488
+ const head = lines[0]?.trim() || '';
489
+ const match = head.match(/^(\w+)(?:\(([^)]+)\))?:\s*(.+)$/);
490
+ if (!match) {
491
+ errors.push('Commit message must match "<type>(<scope>): <subject>"');
492
+ return { errors, warnings };
493
+ }
494
+ const type = match[1];
495
+ const scope = match[2];
496
+ const subject = match[3]?.trim() || '';
497
+ if (!rules.types.includes(type)) {
498
+ errors.push(`type "${type}" is not allowed`);
499
+ }
500
+ if (rules.requireScope && (!scope || !scope.trim())) {
501
+ errors.push('scope is required');
502
+ }
503
+ if (scope && rules.scopes && !rules.scopes.includes(scope)) {
504
+ errors.push(`scope "${scope}" is not in allowed scopes`);
505
+ }
506
+ if (!subject) {
507
+ errors.push('subject must not be empty');
508
+ }
509
+ if (subject.length > rules.maxSubjectLength) {
510
+ errors.push(`subject exceeds max length ${rules.maxSubjectLength}`);
511
+ }
512
+ if (rules.requireIssue) {
513
+ const issue = findIssue(message, rules.issuePattern) ||
514
+ findIssue(context?.branchName, rules.issuePattern);
515
+ if (!issue) {
516
+ errors.push('issue id is required but not found');
517
+ }
518
+ }
519
+ return { errors, warnings };
520
+ }
521
+ function parseCommitJson(normalized) {
522
+ try {
523
+ const parsed = JSON.parse(normalized);
524
+ if (Array.isArray(parsed)) {
525
+ return parsed
526
+ .map((item) => (typeof item === 'object' && item ? item : null))
527
+ .filter(Boolean);
528
+ }
529
+ if (parsed && typeof parsed === 'object') {
530
+ return [parsed];
531
+ }
532
+ }
533
+ catch {
534
+ // ignore
535
+ }
536
+ return null;
537
+ }
538
+ function formatFromCommitJson(items, rules, locale, branchName, stagedFiles) {
539
+ return items.map((item) => {
540
+ if (item.message && typeof item.message === 'string') {
541
+ return enforceRulesOnTextMessage(item.message, rules, locale, branchName, stagedFiles);
542
+ }
543
+ const type = normalizeType(item.type, rules, branchName);
544
+ let scope = pickScope(item.scope, rules, branchName, stagedFiles);
545
+ let subject = cleanSubject(item.subject, rules, locale);
546
+ const raw = [item.type, item.scope, item.subject, item.body, item.risks]
547
+ .filter((v) => typeof v === 'string' && v.trim())
548
+ .join('\n');
549
+ const issueInfo = resolveIssueId(raw, branchName, rules);
550
+ let footer;
551
+ if (issueInfo.issue && !issueInfo.fromMessage) {
552
+ const issue = normalizeIssueId(issueInfo.issue, rules.issuePlacement);
553
+ if (rules.issuePlacement === 'scope') {
554
+ if (!scope || scope === 'core') {
555
+ if (!rules.scopes || rules.scopes.includes(issue)) {
556
+ scope = issue;
557
+ }
558
+ else {
559
+ footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
560
+ }
561
+ }
562
+ else {
563
+ footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
564
+ }
565
+ }
566
+ else if (rules.issuePlacement === 'subject') {
567
+ subject = cleanSubject(`${issue} ${subject}`, rules, locale);
568
+ }
569
+ else {
570
+ footer = `${rules.issueFooterPrefix}: ${issueInfo.issue}`;
571
+ }
572
+ }
573
+ return formatCommitMessage(type, scope, subject, locale, item.body, item.risks, footer);
574
+ });
575
+ }
576
+ function normalizeAIError(error) {
577
+ if (error instanceof Error) {
578
+ const safe = redactSecrets(error.message || '');
579
+ const e = new Error(safe);
580
+ e.cause = error;
581
+ return e;
582
+ }
583
+ return new Error(redactSecrets(String(error)));
584
+ }
585
+ function shouldFallbackFromAgent(error) {
586
+ const err = error;
587
+ const status = typeof err?.status === 'number' ? err.status : undefined;
588
+ const type = typeof err?.type === 'string' ? err.type : '';
589
+ // If auth/endpoint is wrong, basic mode will fail too: don't spam the user with a second failure.
590
+ if (status === 401 || status === 403 || type === 'authentication_error')
591
+ return false;
592
+ if (status === 404)
593
+ return false;
594
+ // Rate limits / transient errors: agent uses more calls; basic mode may succeed.
595
+ if (status === 429)
596
+ return true;
597
+ if (status && status >= 500)
598
+ return true;
599
+ const msg = (typeof err?.error?.message === 'string' && err.error.message) ||
600
+ (typeof err?.message === 'string' && err.message) ||
601
+ '';
602
+ const lowered = String(msg).toLowerCase();
603
+ // Tool calling compatibility issues: fall back to basic mode.
604
+ if (lowered.includes('tool') || lowered.includes('tool_choice') || lowered.includes('function'))
605
+ return true;
606
+ // Default: keep previous behavior (fallback), unless it's clearly an auth/endpoint issue.
607
+ return true;
608
+ }
609
+ function shouldFallbackModel(error) {
610
+ const err = error;
611
+ const status = typeof err?.status === 'number' ? err.status : undefined;
612
+ const type = typeof err?.type === 'string' ? err.type : '';
613
+ if (status === 401 || status === 403 || type === 'authentication_error')
614
+ return false;
615
+ if (status === 404)
616
+ return false;
617
+ if (status === 429)
618
+ return true;
619
+ if (status && status >= 500)
620
+ return true;
621
+ const msg = (typeof err?.error?.message === 'string' && err.error.message) ||
622
+ (typeof err?.message === 'string' && err.message) ||
623
+ '';
624
+ const lowered = String(msg).toLowerCase();
625
+ if (lowered.includes('timeout') || lowered.includes('timed out'))
626
+ return true;
627
+ if (lowered.includes('rate limit'))
628
+ return true;
629
+ return false;
630
+ }
41
631
  const DEFAULT_SYSTEM_PROMPT_EN = `You are an expert at writing Git commit messages following the Conventional Commits specification.
42
632
 
43
633
  Based on the git diff provided, generate a concise and descriptive commit message.
@@ -112,6 +702,8 @@ export async function generateCommitMessage(client, input, config, numChoices =
112
702
  let diff = input.diff;
113
703
  let ignoredFiles = input.ignoredFiles;
114
704
  let truncated = input.truncated;
705
+ const rules = normalizeRules(config);
706
+ const outputFormat = config.outputFormat || 'text';
115
707
  const ensureDiff = async () => {
116
708
  if (diff !== undefined)
117
709
  return;
@@ -127,23 +719,42 @@ export async function generateCommitMessage(client, input, config, numChoices =
127
719
  // Auto-enable Agent for critical branches in Git Flow
128
720
  // Critical: release/hotfix/master/main - always use Agent
129
721
  // Feature: feature/*/bugfix/*/dev/* - use Agent for impact analysis
130
- const isCriticalBranch = /^(release|hotfix|master|main)$/.test(input.branchName || '');
131
- const isFeatureBranch = /^(feature|bugfix|dev)\//.test(input.branchName || '');
132
- const shouldRunAgent = (input.truncated || input.forceAgent || isCriticalBranch || isFeatureBranch) && numChoices === 1;
722
+ const branch = input.branchName || '';
723
+ const isCriticalBranch = /^(release|hotfix)\//.test(branch) || /^(master|main)$/.test(branch);
724
+ const isFeatureBranch = /^(feature|bugfix|dev)\//.test(branch);
725
+ const autoAgentEnabled = isAutoAgentEnabled();
726
+ const agentDisabled = isAgentDisabled();
727
+ const shouldRunAgent = !agentDisabled &&
728
+ (input.forceAgent || (autoAgentEnabled && (input.truncated || isCriticalBranch || isFeatureBranch))) &&
729
+ numChoices === 1;
133
730
  // Trigger Agent Mode if diff is truncated OR forced by user OR critical branch
134
731
  if (shouldRunAgent) {
135
732
  try {
136
733
  const stats = await getFileStats();
137
734
  if (stats.length > 0) {
138
- const agentModel = resolveAgentModel(config);
139
- if (!input.quiet && agentModel !== config.model) {
140
- console.log(chalk.gray(`\n🧠 Agent model: ${agentModel} (base model: ${config.model})`));
735
+ const agentStrategy = resolveAgentStrategy(config);
736
+ const agentModel = resolveAgentModel(config, agentStrategy);
737
+ if (!input.quiet) {
738
+ const label = agentStrategy === 'tools' ? 'tools' : 'lite';
739
+ if (agentModel !== config.model) {
740
+ console.log(chalk.gray(`\n🧠 Agent (${label}) model: ${agentModel} (base model: ${config.model})`));
741
+ }
742
+ else {
743
+ console.log(chalk.gray(`\n🧠 Agent strategy: ${label}`));
744
+ }
141
745
  }
142
- const agentMessage = await runAgentLoop(client, config, stats, input.branchName, input.quiet, agentModel);
143
- return [agentMessage];
746
+ const agentMessage = agentStrategy === 'tools'
747
+ ? await runAgentLoop(client, config, stats, input.branchName, input.quiet, agentModel)
748
+ : await runAgentLite(client, config, stats, input.branchName, input.quiet, agentModel);
749
+ const enforced = enforceRulesOnTextMessage(agentMessage, rules, config.locale, input.branchName, input.stagedFiles);
750
+ const out = config.enableFooter ? `${enforced}\n\n🤖 Generated by git-ai 🚀` : enforced;
751
+ return [out];
144
752
  }
145
753
  }
146
754
  catch (error) {
755
+ if (!shouldFallbackFromAgent(error)) {
756
+ throw normalizeAIError(error);
757
+ }
147
758
  if (!input.quiet) {
148
759
  const reason = formatAgentFailureReason(error);
149
760
  const suffix = reason ? ` (${reason})` : '';
@@ -167,13 +778,38 @@ export async function generateCommitMessage(client, input, config, numChoices =
167
778
  }
168
779
  const isZh = config.locale === 'zh';
169
780
  const lines = [];
781
+ const rulesHeader = isZh ? 'Commit 规则:' : 'Commit rules:';
782
+ const rulesLines = [
783
+ `${rulesHeader}`,
784
+ `types: ${rules.types.join(', ')}`,
785
+ `maxSubjectLength: ${rules.maxSubjectLength}`,
786
+ `requireScope: ${rules.requireScope ? 'true' : 'false'}`,
787
+ `issuePlacement: ${rules.issuePlacement}`,
788
+ `requireIssue: ${rules.requireIssue ? 'true' : 'false'}`,
789
+ `issuePattern: ${rules.issuePattern.source}`,
790
+ `issueFooterPrefix: ${rules.issueFooterPrefix}`,
791
+ ];
792
+ if (rules.scopes && rules.scopes.length) {
793
+ rulesLines.push(`scopes: ${rules.scopes.join(', ')}`);
794
+ }
795
+ lines.push(rulesLines.join('\n'));
170
796
  if (numChoices > 1) {
171
- // Add instruction for multiple choices
172
- const multiInstruction = isZh
173
- ? `\n请生成 ${numChoices} 个不同的 commit message 选项,每个选项用 "---" 分隔。`
174
- : `\nPlease generate ${numChoices} distinct commit message options, separated by "---".`;
797
+ // Add instruction for multiple choices (JSON array for robustness)
798
+ const multiInstruction = outputFormat === 'json'
799
+ ? isZh
800
+ ? `\n请仅输出 JSON 数组,包含 ${numChoices} 个对象,每个对象字段为 type, scope, subject, body?, risks?;不要输出其他内容。`
801
+ : `\nRespond with a JSON array of ${numChoices} objects with fields type, scope, subject, body?, risks?. Output JSON only.`
802
+ : isZh
803
+ ? `\n请仅输出 JSON 数组,包含 ${numChoices} 条不同的 commit message(字符串数组),不要输出其他内容。`
804
+ : `\nRespond with a JSON array of ${numChoices} distinct commit message strings. Output JSON only.`;
175
805
  systemPrompt += multiInstruction;
176
806
  }
807
+ else if (outputFormat === 'json') {
808
+ const singleInstruction = isZh
809
+ ? `\n请仅输出一个 JSON 对象,字段为 type, scope, subject, body?, risks?;不要输出其他内容。`
810
+ : `\nRespond with a single JSON object with fields type, scope, subject, body?, risks?. Output JSON only.`;
811
+ systemPrompt += singleInstruction;
812
+ }
177
813
  if (input.recentCommits?.length) {
178
814
  const header = isZh
179
815
  ? '参考历史提交风格 (请模仿以下风格):'
@@ -212,23 +848,76 @@ export async function generateCommitMessage(client, input, config, numChoices =
212
848
  }
213
849
  const diffHeader = isZh ? 'Git Diff:' : 'Git diff:';
214
850
  lines.push(`${diffHeader}\n\n${diff || '(empty)'}`);
215
- const response = await client.chat.completions.create({
216
- model: config.model,
217
- messages: [
218
- { role: 'system', content: systemPrompt },
219
- { role: 'user', content: lines.join('\n\n') },
220
- ],
221
- temperature: 0.7,
222
- max_tokens: 500 * numChoices, // Increase token limit for multiple choices
223
- });
851
+ const modelCandidates = getModelCandidates(config);
852
+ let response = null;
853
+ let lastError = null;
854
+ for (const model of modelCandidates) {
855
+ try {
856
+ response = await client.chat.completions.create({
857
+ model,
858
+ messages: [
859
+ { role: 'system', content: systemPrompt },
860
+ { role: 'user', content: lines.join('\n\n') },
861
+ ],
862
+ temperature: 0.7,
863
+ max_tokens: getMaxOutputTokens(numChoices),
864
+ });
865
+ break;
866
+ }
867
+ catch (error) {
868
+ lastError = error;
869
+ if (!shouldFallbackModel(error) || model === modelCandidates[modelCandidates.length - 1]) {
870
+ throw normalizeAIError(error);
871
+ }
872
+ if (process.env.GIT_AI_DEBUG === '1') {
873
+ console.error(chalk.yellow(`⚠️ Model ${model} failed, trying fallback...`));
874
+ }
875
+ }
876
+ }
877
+ if (!response) {
878
+ throw normalizeAIError(lastError);
879
+ }
224
880
  const content = response.choices[0]?.message?.content?.trim();
225
881
  if (!content) {
226
882
  throw new Error('Failed to generate commit message: empty response');
227
883
  }
228
- const messages = content
229
- .split('---')
230
- .map((msg) => msg.trim())
231
- .filter(Boolean);
884
+ const normalized = stripCodeFences(content);
885
+ let messages = [];
886
+ if (outputFormat === 'json') {
887
+ const parsed = parseCommitJson(normalized);
888
+ if (parsed && parsed.length) {
889
+ messages = formatFromCommitJson(parsed, rules, config.locale, input.branchName, input.stagedFiles);
890
+ }
891
+ else {
892
+ const fallback = tryParseMessagesJson(normalized);
893
+ const rawMessages = fallback && fallback.length ? fallback : [normalized];
894
+ messages = rawMessages.map((msg) => enforceRulesOnTextMessage(msg, rules, config.locale, input.branchName, input.stagedFiles));
895
+ }
896
+ }
897
+ else {
898
+ if (numChoices > 1) {
899
+ const parsed = tryParseMessagesJson(normalized);
900
+ if (parsed && parsed.length) {
901
+ messages = parsed;
902
+ }
903
+ else {
904
+ messages = normalized
905
+ .split('---')
906
+ .map((msg) => msg.trim())
907
+ .filter(Boolean);
908
+ }
909
+ }
910
+ else {
911
+ const parsed = tryParseMessagesJson(normalized);
912
+ if (parsed && parsed.length === 1) {
913
+ messages = parsed;
914
+ }
915
+ else {
916
+ messages = [normalized];
917
+ }
918
+ }
919
+ messages = messages.map((msg) => enforceRulesOnTextMessage(msg, rules, config.locale, input.branchName, input.stagedFiles));
920
+ }
232
921
  if (config.enableFooter) {
233
922
  return messages.map((msg) => `${msg}\n\n🤖 Generated by git-ai 🚀`);
234
923
  }
@@ -269,20 +958,146 @@ Rules:
269
958
  4. **Filter Noise**: Ignore trivial or "wip" commits.
270
959
 
271
960
  Output structured markdown text only.`;
961
+ const PR_PROMPT_EN = `You are a senior engineer writing a pull request description.
962
+
963
+ Based on the commit list and diff summary, produce a concise PR description.
964
+
965
+ Rules:
966
+ 1) Use Markdown
967
+ 2) Include sections: Summary, Changes, Testing, Risks
968
+ 3) Be concise and value-focused
969
+ 4) If testing info is unknown, write "Not run"
970
+
971
+ Output Markdown only.`;
972
+ const PR_PROMPT_ZH = `你是资深工程师,负责撰写 PR 描述。
973
+
974
+ 根据提交记录和 diff 概要,生成简洁的 PR 描述。
975
+
976
+ 规则:
977
+ 1) 使用 Markdown
978
+ 2) 包含板块:Summary, Changes, Testing, Risks
979
+ 3) 聚焦价值与影响,避免啰嗦
980
+ 4) 如未知测试情况,写 "Not run"
981
+
982
+ 仅输出 Markdown。`;
983
+ const RELEASE_PROMPT_EN = `You are a release manager writing release notes.
984
+
985
+ Based on the commit list, generate structured release notes.
986
+
987
+ Rules:
988
+ 1) Use Markdown
989
+ 2) Group changes by type (Features, Fixes, Improvements, Docs/Chore)
990
+ 3) Be concise and user-facing when possible
991
+ 4) Exclude trivial/wip commits
992
+
993
+ Output Markdown only.`;
994
+ const RELEASE_PROMPT_ZH = `你是发布负责人,负责撰写 Release Notes。
995
+
996
+ 根据提交记录生成结构化的发布说明。
997
+
998
+ 规则:
999
+ 1) 使用 Markdown
1000
+ 2) 按类型分组(Features / Fixes / Improvements / Docs/Chore)
1001
+ 3) 尽量面向用户价值表达
1002
+ 4) 过滤无意义提交
1003
+
1004
+ 仅输出 Markdown。`;
1005
+ export async function generatePullRequestDescription(client, input, config) {
1006
+ const isZh = config.locale === 'zh';
1007
+ const systemPrompt = isZh ? PR_PROMPT_ZH : PR_PROMPT_EN;
1008
+ const diffBlock = input.diffStat ? `\nDiff Summary:\n${input.diffStat}` : '';
1009
+ const modelCandidates = getModelCandidates(config);
1010
+ let result = null;
1011
+ let lastError = null;
1012
+ for (const model of modelCandidates) {
1013
+ try {
1014
+ result = await client.chat.completions.create({
1015
+ model,
1016
+ messages: [
1017
+ { role: 'system', content: systemPrompt },
1018
+ {
1019
+ role: 'user',
1020
+ content: `Base: ${input.base}\nHead: ${input.head}\nCommits:\n${input.commits.join('\n')}${diffBlock}`,
1021
+ },
1022
+ ],
1023
+ temperature: 0.4,
1024
+ });
1025
+ break;
1026
+ }
1027
+ catch (error) {
1028
+ lastError = error;
1029
+ if (!shouldFallbackModel(error) || model === modelCandidates[modelCandidates.length - 1]) {
1030
+ throw normalizeAIError(error);
1031
+ }
1032
+ }
1033
+ }
1034
+ if (!result)
1035
+ throw normalizeAIError(lastError);
1036
+ return result.choices[0]?.message?.content?.trim() || '';
1037
+ }
1038
+ export async function generateReleaseNotes(client, input, config) {
1039
+ const isZh = config.locale === 'zh';
1040
+ const systemPrompt = isZh ? RELEASE_PROMPT_ZH : RELEASE_PROMPT_EN;
1041
+ const modelCandidates = getModelCandidates(config);
1042
+ let result = null;
1043
+ let lastError = null;
1044
+ for (const model of modelCandidates) {
1045
+ try {
1046
+ result = await client.chat.completions.create({
1047
+ model,
1048
+ messages: [
1049
+ { role: 'system', content: systemPrompt },
1050
+ {
1051
+ role: 'user',
1052
+ content: `Range: ${input.from} → ${input.to}\nCommits:\n${input.commits.join('\n')}`,
1053
+ },
1054
+ ],
1055
+ temperature: 0.4,
1056
+ });
1057
+ break;
1058
+ }
1059
+ catch (error) {
1060
+ lastError = error;
1061
+ if (!shouldFallbackModel(error) || model === modelCandidates[modelCandidates.length - 1]) {
1062
+ throw normalizeAIError(error);
1063
+ }
1064
+ }
1065
+ }
1066
+ if (!result)
1067
+ throw normalizeAIError(lastError);
1068
+ return result.choices[0]?.message?.content?.trim() || '';
1069
+ }
272
1070
  export async function generateWeeklyReport(client, commits, config) {
273
1071
  const isZh = config.locale === 'zh';
274
1072
  const systemPrompt = isZh ? REPORT_PROMPT_ZH : REPORT_PROMPT_EN;
275
1073
  if (commits.length === 0) {
276
1074
  return isZh ? '这段时间没有找到您的提交记录。' : 'No commits found for this period.';
277
1075
  }
278
- const response = await client.chat.completions.create({
279
- model: config.model,
280
- messages: [
281
- { role: 'system', content: systemPrompt },
282
- { role: 'user', content: `Commit History:\n${commits.join('\n')}` },
283
- ],
284
- temperature: 0.7,
285
- });
286
- return response.choices[0]?.message?.content?.trim() || '';
1076
+ const modelCandidates = getModelCandidates(config);
1077
+ let result = null;
1078
+ let lastError = null;
1079
+ for (const model of modelCandidates) {
1080
+ try {
1081
+ result = await client.chat.completions.create({
1082
+ model,
1083
+ messages: [
1084
+ { role: 'system', content: systemPrompt },
1085
+ { role: 'user', content: `Commit History:\n${commits.join('\n')}` },
1086
+ ],
1087
+ temperature: 0.7,
1088
+ });
1089
+ break;
1090
+ }
1091
+ catch (error) {
1092
+ lastError = error;
1093
+ if (!shouldFallbackModel(error) || model === modelCandidates[modelCandidates.length - 1]) {
1094
+ throw normalizeAIError(error);
1095
+ }
1096
+ }
1097
+ }
1098
+ if (!result) {
1099
+ throw normalizeAIError(lastError);
1100
+ }
1101
+ return result.choices[0]?.message?.content?.trim() || '';
287
1102
  }
288
1103
  //# sourceMappingURL=ai.js.map