From b46aed53e3947a912795109618d688b24a26642a Mon Sep 17 00:00:00 2001 From: vvzvlad Date: Thu, 18 Jun 2026 02:35:01 +0300 Subject: [PATCH] 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 ": | response body: " (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 --- .../embedding/embedding-indexer.service.ts | 7 ++- .../ai-chat/embedding/embedding.processor.ts | 3 +- .../src/integrations/ai/ai-error.util.ts | 33 ++++++++++ apps/server/src/integrations/ai/ai.service.ts | 61 +++++++++++++------ 4 files changed, 81 insertions(+), 23 deletions(-) create mode 100644 apps/server/src/integrations/ai/ai-error.util.ts diff --git a/apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts b/apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts index a6967687..1bc4ee06 100644 --- a/apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts +++ b/apps/server/src/core/ai-chat/embedding/embedding-indexer.service.ts @@ -10,6 +10,7 @@ import { InjectKysely } from 'nestjs-kysely'; import { executeTx } from '@docmost/db/utils'; import { AiService } from '../../../integrations/ai/ai.service'; import { AiEmbeddingNotConfiguredException } from '../../../integrations/ai/ai-embedding-not-configured.exception'; +import { describeProviderError } from '../../../integrations/ai/ai-error.util'; import { jsonToText } from '../../../collaboration/collaboration.util'; // 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. failed++; this.logger.error( - `reindexWorkspace: failed to reindex page ${pageId}: ${ - err instanceof Error ? err.message : 'Unknown error' - }`, + `reindexWorkspace: failed to reindex page ${pageId}: ${describeProviderError( + err, + )}`, ); } } diff --git a/apps/server/src/core/ai-chat/embedding/embedding.processor.ts b/apps/server/src/core/ai-chat/embedding/embedding.processor.ts index 701f0084..a75d82af 100644 --- a/apps/server/src/core/ai-chat/embedding/embedding.processor.ts +++ b/apps/server/src/core/ai-chat/embedding/embedding.processor.ts @@ -7,6 +7,7 @@ import { IWorkspaceEmbeddingsJob, } from '../../../integrations/queue/constants/queue.interface'; 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]). @@ -106,7 +107,7 @@ export class EmbeddingProcessor extends WorkerHost implements OnModuleDestroy { } private errMessage(err: unknown): string { - return err instanceof Error ? err.message : 'Unknown error'; + return describeProviderError(err); } @OnWorkerEvent('failed') diff --git a/apps/server/src/integrations/ai/ai-error.util.ts b/apps/server/src/integrations/ai/ai-error.util.ts new file mode 100644 index 00000000..68fa328b --- /dev/null +++ b/apps/server/src/integrations/ai/ai-error.util.ts @@ -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}`; +} diff --git a/apps/server/src/integrations/ai/ai.service.ts b/apps/server/src/integrations/ai/ai.service.ts index 268079d2..8d063920 100644 --- a/apps/server/src/integrations/ai/ai.service.ts +++ b/apps/server/src/integrations/ai/ai.service.ts @@ -11,6 +11,7 @@ import { createOllama } from 'ai-sdk-ollama'; import { AiSettingsService } from './ai-settings.service'; import { AiNotConfiguredException } from './ai-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 @@ -118,37 +119,59 @@ export class AiService { } /** - * Cheap connectivity check. Builds the model and asks for a one-word reply. - * On AiNotConfiguredException returns a generic "not configured" message; for - * any other failure surfaces the provider's own cause (e.g. AI SDK - * `AI_APICallError` -> `${statusCode}: ${message}`) so a 402 / wrong model / - * missing key is diagnosable, and logs the full error. The decrypted key is - * never logged or returned — AI SDK error messages/4xx bodies do not contain - * it, and the resolved config (which holds the key) is never dumped (§6.4/§8.3). + * Cheap connectivity check for the "Test connection" button. Probes the + * configured chat model (a one-word generation) AND the configured embeddings + * model (embedding a tiny string) independently: + * - a probe is skipped when that capability is not configured (its + * NotConfigured exception), so a chat-only or embeddings-only workspace + * still tests fine; + * - 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( workspaceId: string, ): Promise<{ ok: true } | { ok: false; error: string }> { + let probed = false; + + // Chat probe — only when a chat model is configured. try { const model = await this.getChatModel(workspaceId); // maxOutputTokens keeps the probe cheap and avoids providers (e.g. // OpenRouter) reserving/charging for the model's full max-token budget, // which would 402 on a key with limited credit. await generateText({ model, prompt: 'ping', maxOutputTokens: 16 }); - return { ok: true }; + probed = true; } catch (err) { - if (err instanceof AiNotConfiguredException) { - return { ok: false, error: 'AI provider not configured' }; + if (!(err instanceof AiNotConfiguredException)) { + 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 - // 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 }; + // Chat not configured: skip — embeddings may still be configured. } + + // 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 }; } }