diff --git a/apps/x/packages/core/src/models/local.test.ts b/apps/x/packages/core/src/models/local.test.ts index 7973edf8..80e397e8 100644 --- a/apps/x/packages/core/src/models/local.test.ts +++ b/apps/x/packages/core/src/models/local.test.ts @@ -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("bad gateway")).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({}); + }); }); diff --git a/apps/x/packages/core/src/models/local.ts b/apps/x/packages/core/src/models/local.ts index f1ebc3bb..fa136211 100644 --- a/apps/x/packages/core/src/models/local.ts +++ b/apps/x/packages/core/src/models/local.ts @@ -84,12 +84,43 @@ export function resolveThinkValue( } } +// Ollama replies with {"error":""}; 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 { + 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); }; }