fix(ai): classify AI provider error status in logs and UI

Provider auth failures were logged with the provider's opaque message only
(e.g. OpenRouter returns "401: User not found." for a bad/missing API key),
which reads like a missing wiki user rather than a credentials problem.

describeProviderError now prepends a clear, human-readable English label for
a small set of well-known HTTP statuses while keeping the original detail
(status + provider message + truncated response-body snippet):
  - 401/403 -> authentication failed (invalid or missing API key)
  - 402     -> insufficient credits or quota
  - 429     -> rate limit exceeded
Other statuses and status-less errors are formatted exactly as before. The
label is a static string and never contains the API key. Benefits every
caller (embedding processor, indexer, AI "Test endpoint" UI) at once.

Tests: switch the plain status+message case to a non-classified status (500);
add 401/403/402/429 cases; keep 502/503 as regression guards for the
unchanged path.
This commit is contained in:
claude_code
2026-06-21 19:55:45 +03:00
parent 4f8015b342
commit 7171dfbdf0
2 changed files with 79 additions and 5 deletions
@@ -10,6 +10,12 @@
* None of these fields contain the API key (it is sent as an Authorization
* header and never echoed in the response body), so this is safe to log/return.
*
* A small set of well-known HTTP statuses (auth / billing / rate limit) are
* classified and a clear, human-readable English label is prepended, so the
* log/UI states the real cause instead of only the provider's opaque message
* (e.g. a 401 "User not found." is really a bad/missing API key). The label is
* a static string and never contains the API key.
*
* `fallback` is used when the error carries no usable message (e.g. a bare
* object); defaults to 'Unknown error'.
*/
@@ -31,9 +37,29 @@ export function describeProviderError(
? `${e.statusCode}: ${e.message ?? ''}`.trim()
: (e.message ?? fallback);
const body = (e.responseBody ?? e.text ?? '').trim();
if (!body) return base;
// Collapse whitespace so a multi-line HTML body stays on one log line.
const oneLine = body.replace(/\s+/g, ' ');
const snippet = oneLine.length > 300 ? `${oneLine.slice(0, 300)}` : oneLine;
return `${base} | response body: ${snippet}`;
const detail = body ? `${base} | response body: ${snippet}` : base;
// Classify well-known HTTP statuses so the log/UI states the real problem
// (auth / billing / rate limit) instead of only the provider's opaque message.
const label = classifyStatus(e.statusCode);
return label ? `${label}${detail}` : detail;
}
// Map a small set of well-known provider HTTP statuses to a clear,
// human-readable cause. Returns null for anything else so the existing
// "<status>: <message> | response body: …" output is preserved unchanged.
function classifyStatus(statusCode?: number): string | null {
switch (statusCode) {
case 401:
case 403:
return 'AI provider authentication failed (invalid or missing API key)';
case 402:
return 'AI provider rejected the request: insufficient credits or quota';
case 429:
return 'AI provider rate limit exceeded';
default:
return null;
}
}