Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions src/selfhost/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) };
}
Expand Down
28 changes: 7 additions & 21 deletions test/unit/selfhost-ai.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading