@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
216 lines (215 loc) • 8.57 kB
JavaScript
/**
* Classification of AI provider failures into a small, actionable set.
*
* The value is deciding two things the caller cannot infer from a message
* string: whether retrying could plausibly succeed (`retryable`), and whether a
* different model/provider should be tried instead (`shouldFallback`). Rule-based
* changelog generation is always available as a floor, so a permanent failure
* should stop wasting time rather than retry a request that will never succeed.
*
* Deliberately narrower than a hosted-service taxonomy: this is a local CLI with
* no ledger, no tenants and no per-request budgets, so codes exist only where
* they change behaviour or the advice shown to the user.
*/
function statusOf(error) {
const candidate = error?.status ?? error?.statusCode ?? error?.response?.status ?? error?.cause?.status;
return typeof candidate === 'number' ? candidate : undefined;
}
function textOf(error) {
const parts = [
error?.message,
error?.cause?.message,
error?.error?.message,
typeof error === 'string' ? error : '',
].filter(Boolean);
return parts.join(' ').toLowerCase();
}
/**
* Order matters: the most specific and most actionable signals are checked
* first, and HTTP status is preferred over message matching where available
* because provider prose changes far more often than status codes.
*/
export function classifyAiError(error) {
const status = statusOf(error);
const text = textOf(error);
const name = error?.name ?? '';
if (name === 'AbortError' || text.includes('aborted') || text.includes('cancell')) {
return {
code: 'cancelled',
retryable: false,
shouldFallback: false,
message: 'The request was cancelled before it completed.',
suggestions: [],
};
}
if (name === 'TimeoutError' || text.includes('timed out') || text.includes('timeout')) {
return {
code: 'timed_out',
retryable: true,
shouldFallback: true,
message: 'The AI provider did not respond in time.',
suggestions: [
'Retry with a smaller commit range',
'Use --no-ai for rule-based generation',
'Check network connectivity to the provider',
],
};
}
if (status === 401 || text.includes('api key') || text.includes('unauthorized')) {
return {
code: 'auth_failed',
retryable: false,
shouldFallback: false,
message: 'The AI provider rejected the configured credentials.',
suggestions: [
'Verify the provider credential is set and current',
'Run `ai-changelog providers --validate` to test credentials',
],
};
}
if (status === 403 || text.includes('forbidden') || text.includes('permission')) {
return {
code: 'forbidden',
retryable: false,
shouldFallback: true,
message: 'The AI provider denied this request.',
suggestions: ['Confirm the credential has access to the requested model'],
};
}
if (status === 404 || text.includes('model not found') || text.includes('does not exist')) {
return {
code: 'model_missing',
retryable: false,
shouldFallback: true,
message: 'The configured model could not be found for this provider.',
suggestions: [
'Check AI_MODEL against the provider model list',
'Remove AI_MODEL to use the provider default',
],
};
}
if (status === 429 || text.includes('rate limit') || text.includes('too many requests')) {
return {
code: 'rate_limited',
retryable: true,
shouldFallback: true,
message: 'The AI provider is rate limiting requests.',
suggestions: ['Retry shortly', 'Increase RATE_LIMIT_DELAY', 'Reduce the commit range'],
};
}
if (text.includes('quota') || text.includes('insufficient_quota') || text.includes('billing')) {
return {
code: 'quota_exceeded',
retryable: false,
shouldFallback: true,
message: 'The AI provider account has exhausted its quota.',
suggestions: ['Check provider billing', 'Switch provider with --provider'],
};
}
if (text.includes('context length') ||
text.includes('context_length_exceeded') ||
text.includes('too many tokens') ||
text.includes('maximum context')) {
return {
code: 'context_limit_exceeded',
retryable: false,
shouldFallback: true,
message: 'The request exceeded the model context window.',
suggestions: [
'Use --analysis-mode standard to send smaller diffs',
'Narrow the commit range',
'Switch to a longer-context model',
],
};
}
if (text.includes('content filter') ||
text.includes('safety') ||
text.includes('content_policy')) {
return {
code: 'content_filtered',
retryable: false,
shouldFallback: true,
message: 'The provider safety system blocked this content.',
suggestions: ['Use --no-ai for the affected range'],
};
}
if (typeof status === 'number' && status >= 500) {
return {
code: 'provider_unavailable',
retryable: true,
shouldFallback: true,
message: 'The AI provider returned a server error.',
suggestions: ['Retry shortly', 'Switch provider with --provider'],
};
}
if (text.includes('econnrefused') ||
text.includes('enotfound') ||
text.includes('econnreset') ||
text.includes('fetch failed') ||
text.includes('network')) {
return {
code: 'network_error',
retryable: true,
shouldFallback: true,
message: 'The AI provider could not be reached.',
suggestions: [
'Check network connectivity',
'For local providers, confirm the server is running',
],
};
}
if (status === 400 || text.includes('invalid request') || text.includes('bad request')) {
return {
code: 'request_invalid',
retryable: false,
shouldFallback: true,
message: 'The AI provider rejected the request format.',
suggestions: ['Check model-specific parameter support'],
};
}
// Reasoning models can burn the whole output budget before emitting visible
// text, finishing "successfully" with an empty string. That must not read as
// a usable result.
if (text.includes('output limit') || text.includes('truncat')) {
return {
code: 'output_truncated',
retryable: true,
shouldFallback: true,
message: 'The model reached its output limit before producing any text.',
suggestions: [
'Increase the output budget for this model',
'Narrow the commit range so the prompt is smaller',
'Disable model thinking so the budget goes to visible output',
],
};
}
if (text.includes('no usable output') || text.includes('empty response')) {
return {
code: 'no_output',
retryable: true,
shouldFallback: true,
message: 'The model returned no usable output.',
suggestions: ['Retry, or use --no-ai for rule-based generation'],
};
}
if (text.includes('not configured') || text.includes('no provider')) {
return {
code: 'not_configured',
retryable: false,
shouldFallback: false,
message: 'No AI provider is configured.',
suggestions: ['Run `ai-changelog providers` to configure one', 'Or use --no-ai'],
};
}
return {
code: 'unknown',
retryable: false,
shouldFallback: true,
message: error?.message || 'An unknown AI provider error occurred.',
suggestions: ['Retry, or use --no-ai for rule-based generation'],
};
}
/** True when re-issuing the identical request could plausibly succeed. */
export function isRetryableAiError(error) {
return classifyAiError(error).retryable;
}