|
|
@@ -323,6 +323,7 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
|
|
|
private readonly now: () => number;
|
|
|
|
|
|
private dimensions: number | undefined = undefined;
|
|
|
+ private lastError: string | undefined = undefined;
|
|
|
readonly breaker: CircuitBreaker;
|
|
|
|
|
|
constructor(config: OpenAIProviderConfig) {
|
|
|
@@ -360,6 +361,21 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
|
|
|
return this.dimensions;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Most recent per-chunk failure message (HTTP status + body preview, malformed
|
|
|
+ * JSON, timeout, abort reason). Returns `undefined` after a successful call
|
|
|
+ * or before the first call. See `EmbeddingProvider.getLastError`.
|
|
|
+ */
|
|
|
+ getLastError(): string | undefined {
|
|
|
+ return this.lastError;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Endpoint URL configured at construction time — used by callers when
|
|
|
+ * building error messages for failed first-chunk probes. */
|
|
|
+ getEndpoint(): string {
|
|
|
+ return this.endpoint;
|
|
|
+ }
|
|
|
+
|
|
|
async healthcheck(signal?: AbortSignal): Promise<ProviderHealth> {
|
|
|
// Try GET /health first (worker exposes it). Fall back to probe embed.
|
|
|
try {
|
|
|
@@ -437,6 +453,7 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
|
|
|
const chunks = chunkArray(texts, this.batchSize);
|
|
|
const results: (ProviderEmbedding | null)[] = new Array(texts.length).fill(null);
|
|
|
let cursor = 0;
|
|
|
+ let anySucceeded = false;
|
|
|
|
|
|
for (const chunk of chunks) {
|
|
|
const start = cursor;
|
|
|
@@ -445,6 +462,7 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
|
|
|
// Abort early if signal already fired
|
|
|
if (options.signal?.aborted) {
|
|
|
// Leave remaining slots as null (caller treats as errors)
|
|
|
+ this.lastError = `aborted by caller${options.signal.reason ? `: ${String(options.signal.reason)}` : ""}`;
|
|
|
return results;
|
|
|
}
|
|
|
|
|
|
@@ -462,6 +480,7 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
|
|
|
embedding,
|
|
|
model: this.modelId,
|
|
|
};
|
|
|
+ anySucceeded = true;
|
|
|
// Record dimensions on first success
|
|
|
if (this.dimensions === undefined) {
|
|
|
this.dimensions = embedding.length;
|
|
|
@@ -473,6 +492,10 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
|
|
|
this.breaker.recordFailure();
|
|
|
// CircuitOpenError must propagate so the caller can fall back
|
|
|
if (err instanceof CircuitOpenError) throw err;
|
|
|
+ // Capture the underlying error so callers (e.g. the store dimension
|
|
|
+ // probe) can surface it instead of "Failed to get embedding
|
|
|
+ // dimensions from first chunk" with no context.
|
|
|
+ this.lastError = this.formatErrorContext(err);
|
|
|
// Other errors mark the chunk as null and continue with next chunk.
|
|
|
// (The store layer already handles per-text nulls as errors.)
|
|
|
if (process.env.QMD_EMBED_DEBUG) {
|
|
|
@@ -483,6 +506,11 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // Clear lastError on a fully-successful sweep (every input got an embedding).
|
|
|
+ if (anySucceeded && results.every((r) => r !== null)) {
|
|
|
+ this.lastError = undefined;
|
|
|
+ }
|
|
|
+
|
|
|
return results;
|
|
|
}
|
|
|
|
|
|
@@ -494,6 +522,25 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
|
|
|
|
|
|
// ────────────────────── Internals ──────────────────────
|
|
|
|
|
|
+ /**
|
|
|
+ * Format a request-failure context string for `lastError`. Includes endpoint
|
|
|
+ * + HTTP status + body preview when the error was an `HttpError`, otherwise
|
|
|
+ * falls back to the message of the underlying error (or the value itself
|
|
|
+ * when not an Error). Kept short — body preview is already capped at 1024
|
|
|
+ * chars by `HttpError`, but we trim further here for the dimension-probe
|
|
|
+ * thrown error which surfaces directly to users.
|
|
|
+ */
|
|
|
+ private formatErrorContext(err: unknown): string {
|
|
|
+ if (err instanceof HttpError) {
|
|
|
+ const preview = err.bodyPreview.replace(/\s+/g, " ").trim().slice(0, 240);
|
|
|
+ return `endpoint=${this.endpoint}/v1/embeddings status=${err.status}${preview ? ` body="${preview}"` : ""}`;
|
|
|
+ }
|
|
|
+ if (err instanceof Error) {
|
|
|
+ return `endpoint=${this.endpoint}/v1/embeddings error="${err.message}"`;
|
|
|
+ }
|
|
|
+ return `endpoint=${this.endpoint}/v1/embeddings error="${String(err)}"`;
|
|
|
+ }
|
|
|
+
|
|
|
private buildHeaders(): Record<string, string> {
|
|
|
const headers: Record<string, string> = {
|
|
|
"Content-Type": "application/json",
|