diff --git a/src/selfhost/ai.ts b/src/selfhost/ai.ts index 2b5c1efb5..07f014512 100644 --- a/src/selfhost/ai.ts +++ b/src/selfhost/ai.ts @@ -302,15 +302,9 @@ export function createOpenAiCompatibleAi(opts: { body: JSON.stringify({ model: opts.embedModel ?? "bge-m3", input: options.text }), signal: AbortSignal.timeout(120_000), }); - // #4996: the error previously carried only the status code, with no detail from the response body -- - // diagnosing a real production ai_embed_http_400 required SSHing into the box and reasoning about - // likely causes from first principles, since the actual rejection reason (e.g. Ollama's own "input - // length exceeds..." message) was thrown away. Bounded read, best-effort (a body-read failure must - // never mask the original status in the thrown error). - if (!res.ok) { - const detail = await res.text().then((t) => t.slice(0, 300)).catch(() => ""); - throw new Error(detail ? `ai_embed_http_${res.status}: ${detail}` : `ai_embed_http_${res.status}`); - } + // Keep the thrown value status-only like the chat providers below: embedding error bodies are + // provider-controlled and may echo private input, and RAG fail-safe logs this error for operators. + if (!res.ok) throw new Error(`ai_embed_http_${res.status}`); const json = (await res.json()) as { data?: Array<{ embedding: number[] }> }; return { data: (json.data ?? []).map((d) => d.embedding) }; } diff --git a/test/unit/selfhost-ai.test.ts b/test/unit/selfhost-ai.test.ts index c4beee260..c0382fc45 100644 --- a/test/unit/selfhost-ai.test.ts +++ b/test/unit/selfhost-ai.test.ts @@ -188,30 +188,16 @@ describe("createOpenAiCompatibleAi (#979)", () => { expect(out).toEqual({ data: [[0.1, 0.2], [0.3, 0.4]] }); }); - it("throws on a non-OK embeddings response, including the response body detail (#4996: previously thrown away)", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response("bad request: input exceeds max length", { status: 400 }))); - await expect(createOpenAiCompatibleAi({ baseUrl: "http://x/v1" }).run("m", { text: ["a"] })).rejects.toThrow( - "ai_embed_http_400: bad request: input exceeds max length", - ); - }); - - it("falls back to the bare status code when the error response body is empty", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response("", { status: 502 }))); - await expect(createOpenAiCompatibleAi({ baseUrl: "http://x/v1" }).run("m", { text: ["a"] })).rejects.toThrow(/^ai_embed_http_502$/); + it("throws only a status code on a non-OK embeddings response so provider diagnostics cannot leak", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("bad request: private repo snippet", { status: 400 }))); + await expect(createOpenAiCompatibleAi({ baseUrl: "http://x/v1" }).run("m", { text: ["a"] })).rejects.toThrow(/^ai_embed_http_400$/); }); - it("still throws the bare status code (never masked) when reading the error response body itself fails", async () => { - vi.stubGlobal( - "fetch", - vi.fn(async () => ({ ok: false, status: 503, text: () => Promise.reject(new Error("stream error")) }) as unknown as Response), - ); + it("does not read provider-controlled embeddings error bodies", async () => { + const text = vi.fn(async () => "bad request: private repo snippet"); + vi.stubGlobal("fetch", vi.fn(async () => ({ ok: false, status: 503, text }) as unknown as Response)); await expect(createOpenAiCompatibleAi({ baseUrl: "http://x/v1" }).run("m", { text: ["a"] })).rejects.toThrow(/^ai_embed_http_503$/); - }); - - it("bounds the captured error detail to 300 chars", async () => { - vi.stubGlobal("fetch", vi.fn(async () => new Response("x".repeat(1000), { status: 400 }))); - const error = await createOpenAiCompatibleAi({ baseUrl: "http://x/v1" }).run("m", { text: ["a"] }).catch((e: Error) => e.message); - expect(error).toBe(`ai_embed_http_400: ${"x".repeat(300)}`); + expect(text).not.toHaveBeenCalled(); }); it("empty text array returns { data: [] } without a fetch", async () => {