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 }; } }