feat(ai): surface provider error bodies + probe embeddings in test connection

A misconfigured embeddings endpoint failed the RAG indexer with an opaque
"Invalid JSON response" and was not caught by "Test connection" (which only
probed the chat model), so it only surfaced silently during background
indexing.

- add describeProviderError(): formats AI SDK errors as
  "<statusCode>: <message> | response body: <truncated one-line snippet>"
  (statusCode/message/responseBody never carry the API key)
- use it in the bulk-reindex catch and the embedding processor's formatter so
  the real cause (e.g. an HTML 404 from a wrong base URL) is visible in logs
- testConnection now probes chat AND embeddings independently: skips a probe
  when that capability is unconfigured, returns ok:false with a Chat:/Embeddings:
  prefix on real failure, "not configured" when neither is set
This commit is contained in:
vvzvlad
2026-06-18 02:35:01 +03:00
parent 52e19fe678
commit b46aed53e3
4 changed files with 81 additions and 23 deletions

View File

@@ -10,6 +10,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { AiService } from '../../../integrations/ai/ai.service'; import { AiService } from '../../../integrations/ai/ai.service';
import { AiEmbeddingNotConfiguredException } from '../../../integrations/ai/ai-embedding-not-configured.exception'; import { AiEmbeddingNotConfiguredException } from '../../../integrations/ai/ai-embedding-not-configured.exception';
import { describeProviderError } from '../../../integrations/ai/ai-error.util';
import { jsonToText } from '../../../collaboration/collaboration.util'; import { jsonToText } from '../../../collaboration/collaboration.util';
// NOTE: the `page_embeddings.embedding` column is now dimension-agnostic // NOTE: the `page_embeddings.embedding` column is now dimension-agnostic
@@ -188,9 +189,9 @@ export class EmbeddingIndexerService {
// Per-page isolation: one failure must not abort the whole batch. // Per-page isolation: one failure must not abort the whole batch.
failed++; failed++;
this.logger.error( this.logger.error(
`reindexWorkspace: failed to reindex page ${pageId}: ${ `reindexWorkspace: failed to reindex page ${pageId}: ${describeProviderError(
err instanceof Error ? err.message : 'Unknown error' err,
}`, )}`,
); );
} }
} }

View File

@@ -7,6 +7,7 @@ import {
IWorkspaceEmbeddingsJob, IWorkspaceEmbeddingsJob,
} from '../../../integrations/queue/constants/queue.interface'; } from '../../../integrations/queue/constants/queue.interface';
import { EmbeddingIndexerService } from './embedding-indexer.service'; import { EmbeddingIndexerService } from './embedding-indexer.service';
import { describeProviderError } from '../../../integrations/ai/ai-error.util';
/** /**
* AI_QUEUE consumer for the vector-RAG indexer (§6.7 stage D / §14[M1]). * AI_QUEUE consumer for the vector-RAG indexer (§6.7 stage D / §14[M1]).
@@ -106,7 +107,7 @@ export class EmbeddingProcessor extends WorkerHost implements OnModuleDestroy {
} }
private errMessage(err: unknown): string { private errMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unknown error'; return describeProviderError(err);
} }
@OnWorkerEvent('failed') @OnWorkerEvent('failed')

View File

@@ -0,0 +1,33 @@
/**
* Format an AI SDK provider error for logging / surfacing to admins.
*
* AI SDK APICallError / JSONParseError carry `statusCode` and the raw
* `responseBody` (for an "Invalid JSON response" this is the offending
* non-JSON payload — typically an HTML error page from a misconfigured
* endpoint), which is exactly what is needed to diagnose the failure. A
* truncated, single-line snippet of the body is appended.
*
* 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.
*/
export function describeProviderError(err: unknown): string {
if (typeof err !== 'object' || err === null) {
return typeof err === 'string' ? err : 'Unknown error';
}
const e = err as {
statusCode?: number;
message?: string;
responseBody?: string;
text?: string;
};
const base =
typeof e.statusCode === 'number'
? `${e.statusCode}: ${e.message ?? ''}`.trim()
: (e.message ?? 'Unknown error');
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}`;
}

View File

@@ -11,6 +11,7 @@ import { createOllama } from 'ai-sdk-ollama';
import { AiSettingsService } from './ai-settings.service'; import { AiSettingsService } from './ai-settings.service';
import { AiNotConfiguredException } from './ai-not-configured.exception'; import { AiNotConfiguredException } from './ai-not-configured.exception';
import { AiEmbeddingNotConfiguredException } from './ai-embedding-not-configured.exception'; import { AiEmbeddingNotConfiguredException } from './ai-embedding-not-configured.exception';
import { describeProviderError } from './ai-error.util';
/** /**
* Builds AI SDK language models from per-workspace config and runs cheap * Builds AI SDK language models from per-workspace config and runs cheap
@@ -118,37 +119,59 @@ export class AiService {
} }
/** /**
* Cheap connectivity check. Builds the model and asks for a one-word reply. * Cheap connectivity check for the "Test connection" button. Probes the
* On AiNotConfiguredException returns a generic "not configured" message; for * configured chat model (a one-word generation) AND the configured embeddings
* any other failure surfaces the provider's own cause (e.g. AI SDK * model (embedding a tiny string) independently:
* `AI_APICallError` -> `${statusCode}: ${message}`) so a 402 / wrong model / * - a probe is skipped when that capability is not configured (its
* missing key is diagnosable, and logs the full error. The decrypted key is * NotConfigured exception), so a chat-only or embeddings-only workspace
* never logged or returned — AI SDK error messages/4xx bodies do not contain * still tests fine;
* it, and the resolved config (which holds the key) is never dumped (§6.4/§8.3). * - any real failure returns ok:false with the provider's own cause
* (statusCode + truncated response body via describeProviderError),
* prefixed Chat: / Embeddings: so the failing side is obvious;
* - if neither capability is configured, reports "not configured".
*
* Probing embeddings here catches a misconfigured embeddings endpoint (e.g.
* one returning non-JSON, which the background RAG indexer would otherwise hit
* as an opaque "Invalid JSON response") at config time instead of silently
* during indexing. The decrypted key is never logged or returned — AI SDK
* error fields do not carry it, and the resolved config is never dumped.
*/ */
async testConnection( async testConnection(
workspaceId: string, workspaceId: string,
): Promise<{ ok: true } | { ok: false; error: string }> { ): Promise<{ ok: true } | { ok: false; error: string }> {
let probed = false;
// Chat probe — only when a chat model is configured.
try { try {
const model = await this.getChatModel(workspaceId); const model = await this.getChatModel(workspaceId);
// maxOutputTokens keeps the probe cheap and avoids providers (e.g. // maxOutputTokens keeps the probe cheap and avoids providers (e.g.
// OpenRouter) reserving/charging for the model's full max-token budget, // OpenRouter) reserving/charging for the model's full max-token budget,
// which would 402 on a key with limited credit. // which would 402 on a key with limited credit.
await generateText({ model, prompt: 'ping', maxOutputTokens: 16 }); await generateText({ model, prompt: 'ping', maxOutputTokens: 16 });
return { ok: true }; probed = true;
} catch (err) { } catch (err) {
if (err instanceof AiNotConfiguredException) { if (!(err instanceof AiNotConfiguredException)) {
return { ok: false, error: 'AI provider not configured' }; this.logger.error('AI chat test connection failed', err as Error);
return { ok: false, error: `Chat: ${describeProviderError(err)}` };
} }
// Surface the real provider cause so failures are diagnosable, and log the // Chat not configured: skip — embeddings may still be configured.
// full error. AI SDK errors expose statusCode/message (and responseBody);
// none of these carry the key. Do NOT log/return the resolved config.
this.logger.error('AI test connection failed', err as Error);
const e = err as { statusCode?: number; message?: string };
const msg = e?.statusCode
? `${e.statusCode}: ${e.message}`
: (e?.message ?? 'Unknown error');
return { ok: false, error: msg };
} }
// Embedding probe — only when an embedding model is configured.
try {
await this.embedTexts(workspaceId, ['ping']);
probed = true;
} catch (err) {
if (!(err instanceof AiEmbeddingNotConfiguredException)) {
this.logger.error('AI embedding test connection failed', err as Error);
return { ok: false, error: `Embeddings: ${describeProviderError(err)}` };
}
// Embeddings not configured: skip.
}
if (!probed) {
return { ok: false, error: 'AI provider not configured' };
}
return { ok: true };
} }
} }