mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
fix: surface Ollama's real error message instead of bare HTTP statusText
Ollama returns errors as {"error":"<string>"}, but ollama-ai-provider-v2's
error parser expects OpenAI-style {"error":{"message":...}}. The schema
mismatch makes the AI SDK fall back to the HTTP statusText, so users only
ever saw "Bad Request"/"Not Found" while Ollama's actual reason (model not
found, no tool support, ...) was swallowed -- which is why reports like
#696 were impossible to debug.
makeOllamaThinkFetch now rewraps failed responses' plain-string error
bodies into the nested shape the provider parses, preserving status,
statusText and headers (content-length dropped so the runtime recomputes
it). Successful responses, already-nested errors and non-JSON bodies pass
through untouched. The wrapper's two fetch call sites were merged into one
so both the /api/chat path and the passthrough get the same error handling
without a duplicate-request hazard.
Refs #696
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d2263759ef
commit
6fc5d5ec11
2 changed files with 86 additions and 4 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeOllamaThinkFetch, resolveThinkValue } from "./local.js";
|
||||
import { makeOllamaThinkFetch, resolveThinkValue, rewrapOllamaErrorBody } from "./local.js";
|
||||
|
||||
describe("resolveThinkValue", () => {
|
||||
it("passes effort levels straight through for gpt-oss variants", () => {
|
||||
|
|
@ -22,6 +22,27 @@ describe("resolveThinkValue", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("rewrapOllamaErrorBody", () => {
|
||||
it("rewraps Ollama's plain-string error shape", () => {
|
||||
expect(rewrapOllamaErrorBody('{"error":"model \'x\' not found"}'))
|
||||
.toBe('{"error":{"message":"model \'x\' not found"}}');
|
||||
});
|
||||
|
||||
it("leaves already-nested errors untouched", () => {
|
||||
expect(rewrapOllamaErrorBody('{"error":{"message":"x"}}')).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves non-JSON bodies untouched", () => {
|
||||
expect(rewrapOllamaErrorBody("<html>bad gateway</html>")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves JSON without a string error field untouched", () => {
|
||||
expect(rewrapOllamaErrorBody('{"message":"x"}')).toBeUndefined();
|
||||
expect(rewrapOllamaErrorBody('{"error":42}')).toBeUndefined();
|
||||
expect(rewrapOllamaErrorBody("null")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeOllamaThinkFetch", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
|
|
@ -78,4 +99,32 @@ describe("makeOllamaThinkFetch", () => {
|
|||
// Non-chat request passed through with no body rewrite.
|
||||
expect(calls.some((c) => c.url.endsWith("/api/tags"))).toBe(true);
|
||||
});
|
||||
|
||||
it("rewraps plain-string error bodies on failed responses, keeping the status", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/show")) {
|
||||
return new Response(JSON.stringify({ capabilities: ["completion"] }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ error: "boom" }), { status: 400, statusText: "Bad Request" });
|
||||
}));
|
||||
const wrapped = makeOllamaThinkFetch("low");
|
||||
const res = await wrapped("http://localhost:11434/api/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "llama3.2:3b", think: false, messages: [] }),
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
expect(await res.json()).toEqual({ error: { message: "boom" } });
|
||||
});
|
||||
|
||||
it("returns successful responses untouched", async () => {
|
||||
stubFetch(["completion"]);
|
||||
const wrapped = makeOllamaThinkFetch("low");
|
||||
const res = await wrapped("http://localhost:11434/api/chat", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ model: "llama3.2:3b", messages: [] }),
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -84,12 +84,43 @@ export function resolveThinkValue(
|
|||
}
|
||||
}
|
||||
|
||||
// Ollama replies with {"error":"<string>"}; ollama-ai-provider-v2 expects
|
||||
// OpenAI-style {"error":{"message":...}} and falls back to the bare HTTP
|
||||
// statusText when parsing fails -- the real reason gets swallowed (#696).
|
||||
// Rewrap so the provider surfaces the actual message. Returns undefined
|
||||
// when the body isn't in Ollama's plain-string error shape.
|
||||
export function rewrapOllamaErrorBody(text: string): string | undefined {
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown;
|
||||
if (parsed && typeof parsed === "object" && typeof (parsed as { error?: unknown }).error === "string") {
|
||||
return JSON.stringify({ error: { message: (parsed as { error: string }).error } });
|
||||
}
|
||||
} catch {
|
||||
// not JSON -- leave untouched
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function rewrapErrorResponse(res: Response): Promise<Response> {
|
||||
const text = await res.text();
|
||||
const headers = new Headers(res.headers);
|
||||
// The body may have grown; let the runtime recompute the length.
|
||||
headers.delete("content-length");
|
||||
return new Response(rewrapOllamaErrorBody(text) ?? text, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// The ollama-ai-provider-v2 request builder always writes `think: false`
|
||||
// (its providerOptions schema is boolean-only), so effort can only be set by
|
||||
// rewriting the wire request. This wraps fetch for createOllama: /api/chat
|
||||
// bodies get `think` set per resolveThinkValue; every other request passes
|
||||
// through untouched. Thinking capability is probed once per model via
|
||||
// /api/show and cached for the process lifetime.
|
||||
// /api/show and cached for the process lifetime. Failed responses get their
|
||||
// error body rewrapped (see rewrapOllamaErrorBody) so the provider surfaces
|
||||
// Ollama's actual error message instead of the HTTP statusText.
|
||||
export function makeOllamaThinkFetch(
|
||||
effort: ReasoningEffort,
|
||||
): typeof fetch {
|
||||
|
|
@ -117,6 +148,7 @@ export function makeOllamaThinkFetch(
|
|||
};
|
||||
|
||||
return async (input, init) => {
|
||||
let finalInit = init;
|
||||
try {
|
||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
||||
const isChat = /\/api\/chat(\?.*)?$/.test(url);
|
||||
|
|
@ -130,11 +162,12 @@ export function makeOllamaThinkFetch(
|
|||
} else {
|
||||
parsed.think = think;
|
||||
}
|
||||
return fetch(input, { ...init, body: JSON.stringify(parsed) });
|
||||
finalInit = { ...init, body: JSON.stringify(parsed) };
|
||||
}
|
||||
} catch {
|
||||
// Malformed body or URL — send the original request unchanged.
|
||||
}
|
||||
return fetch(input, init);
|
||||
const res = await fetch(input, finalInit);
|
||||
return res.ok ? res : rewrapErrorResponse(res);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue