From d9e2969af1387fe1d2c16134360b485ff9e9bc23 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Wed, 19 Aug 2026 21:24:50 +0000 Subject: [PATCH] fix(agentic-server): speak ollama's dialect, and name an upstream refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ollama's chat api takes `content` as a string; every harness sends it as parts, so a pi turn through the gateway died on `json: cannot unmarshal array into Go struct field ChatRequest.messages.content of type string`. The gateway is where the dialects meet, so it flattens text parts and moves image parts to ollama's own `images` field, failing loudly on a part it cannot express. That failure also reached the run as a bare `LLM provider error: 400` with the reason unread in `error.upstream` — harnesses surface `error.message` and nothing else, so the message now names the provider and its reason. --- .../agentic-server/__tests__/gateway.test.ts | 46 +++++++++++++++ agentic/agentic-server/src/router.ts | 43 +++++++++++++- agentic/agentic-server/src/transforms.ts | 59 ++++++++++++++++++- 3 files changed, 144 insertions(+), 4 deletions(-) diff --git a/agentic/agentic-server/__tests__/gateway.test.ts b/agentic/agentic-server/__tests__/gateway.test.ts index 5b71d6afea..1292cc9a89 100644 --- a/agentic/agentic-server/__tests__/gateway.test.ts +++ b/agentic/agentic-server/__tests__/gateway.test.ts @@ -36,6 +36,12 @@ beforeAll(async () => { llmApp.post('/api/chat', (req: any, res: any) => { llmRequests.push({ path: req.path, method: req.method, body: req.body }); + if (req.body?.model === 'refuses') { + res.status(400).json({ + error: 'json: cannot unmarshal array into Go struct field ChatRequest.messages.content of type string' + }); + return; + } res.json({ message: { role: 'assistant', content: 'Mock Ollama response' }, prompt_eval_count: 12, @@ -136,6 +142,46 @@ describe('POST /v1/chat/completions', () => { }); }); + it('flattens content parts into the single string ollama accepts', async () => { + // pi and every other harness send `content` as parts; ollama's chat api takes + // only a string, so the gateway is where the dialects meet. + const res = await fetch(`http://localhost:${agenticPort}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Database-Id': 'db-parts-1' }, + body: JSON.stringify({ + model: 'llama3', + messages: [ + { role: 'system', content: [{ type: 'text', text: 'Be brief.' }] }, + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }, { type: 'text', text: 'world' }] + } + ] + }) + }); + + expect(res.status).toBe(200); + expect(llmRequests[0].body.messages).toEqual([ + { role: 'system', content: 'Be brief.' }, + { role: 'user', content: 'Hello\nworld' } + ]); + }); + + it('names the upstream reason in the error message the harness reads', async () => { + const res = await fetch(`http://localhost:${agenticPort}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Database-Id': 'db-upstream-1' }, + body: JSON.stringify({ model: 'refuses', messages: [{ role: 'user', content: 'hi' }] }) + }); + + expect(res.status).toBe(400); + const data = (await res.json()) as any; + expect(data.error.message).toBe( + 'ollama provider error 400: json: cannot unmarshal array into Go struct field ChatRequest.messages.content of type string' + ); + expect(data.error.upstream).toContain('cannot unmarshal'); + }); + it('rejects the request (400) when no X-Database-Id header is present', async () => { const res = await fetch(`http://localhost:${agenticPort}/v1/chat/completions`, { method: 'POST', diff --git a/agentic/agentic-server/src/router.ts b/agentic/agentic-server/src/router.ts index fb13c9a948..9eeb059b77 100644 --- a/agentic/agentic-server/src/router.ts +++ b/agentic/agentic-server/src/router.ts @@ -21,10 +21,47 @@ import { transformEmbedRequest, transformEmbedResponse } from './transforms'; -import type { AgenticServerOptions } from './types'; +import type { AgenticServerOptions, ResolvedProvider } from './types'; const log = new Logger('agentic-server'); +/** How long an upstream reason may be before it stops being a message. */ +const UPSTREAM_REASON_LIMIT = 300; + +/** + * Name the upstream reason in the message itself. + * + * Harnesses surface `error.message` and nothing else, so a bare + * `LLM provider error: 400` reached a run as an unattributable failure while the + * reason — a rejected request shape, a missing key, an unknown model — sat + * unread in `error.upstream`. + */ +function upstreamErrorMessage( + provider: ResolvedProvider, + status: number, + body: string +): string { + const parsed = ((): string => { + try { + const error = (JSON.parse(body) as { error?: unknown }).error; + if (typeof error === 'string') return error; + const message = (error as { message?: unknown } | undefined)?.message; + return typeof message === 'string' ? message : body; + } catch { + // Not every provider answers an error with JSON; the raw body is the reason. + return body; + } + })().trim().replace(/\s+/g, ' '); + + const reason = parsed.length > UPSTREAM_REASON_LIMIT + ? `${parsed.slice(0, UPSTREAM_REASON_LIMIT)}…` + : parsed; + + return reason + ? `${provider.type} provider error ${status}: ${reason}` + : `${provider.type} provider error ${status}`; +} + export const createRouter = (options: AgenticServerOptions): Router => { const router = Router(); // Metering is backend-agnostic: the caller injects an InferenceSink. When @@ -110,7 +147,7 @@ export const createRouter = (options: AgenticServerOptions): Router => { } res.status(upstream.status).json({ - error: { message: `LLM provider error: ${upstream.status}`, upstream: text } + error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text } }); return; } @@ -253,7 +290,7 @@ export const createRouter = (options: AgenticServerOptions): Router => { } res.status(upstream.status).json({ - error: { message: `LLM provider error: ${upstream.status}`, upstream: text } + error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text } }); return; } diff --git a/agentic/agentic-server/src/transforms.ts b/agentic/agentic-server/src/transforms.ts index 66f1300539..b2b2136da1 100644 --- a/agentic/agentic-server/src/transforms.ts +++ b/agentic/agentic-server/src/transforms.ts @@ -1,5 +1,62 @@ import type { ResolvedProvider, UsageResult } from './types'; +interface ContentPart { + type: string; + text?: string; + image_url?: { url?: string }; +} + +interface ChatMessage { + role: string; + content?: string | ContentPart[]; + images?: string[]; +} + +/** + * Flatten OpenAI content parts into the single string Ollama's chat api takes. + * + * Harnesses send `content` as an array of parts — pi always does — and Ollama + * answers that with `json: cannot unmarshal array into Go struct field + * ChatRequest.messages.content of type string`, which reaches the harness as a + * bare `400` and reads as a broken model rather than a dialect mismatch. Image + * parts move to Ollama's own `images` field; a part this cannot express fails + * loudly rather than being dropped into a prompt the model never sees. + */ +function flattenContentParts(messages: unknown): unknown { + if (!Array.isArray(messages)) return messages; + + return (messages as ChatMessage[]).map((message) => { + if (!Array.isArray(message.content)) return message; + + const text: string[] = []; + const images: string[] = []; + for (const part of message.content) { + if (part.type === 'text' || part.type === 'input_text') { + text.push(part.text ?? ''); + continue; + } + if (part.type === 'image_url') { + const url = part.image_url?.url; + if (!url) throw new Error('ollama: an image_url content part carries no url'); + // Ollama takes base64 bytes, never a fetchable url. + const base64 = /^data:[^;]*;base64,(.*)$/.exec(url)?.[1]; + if (!base64) { + throw new Error('ollama: an image content part must be a base64 data url'); + } + images.push(base64); + continue; + } + throw new Error(`ollama: unsupported content part type '${part.type}'`); + } + + return { + ...message, + content: text.join('\n'), + ...(images.length ? { images: [...(message.images ?? []), ...images] } : {}) + }; + }); +} + export function transformChatRequest( provider: ResolvedProvider, body: Record @@ -15,7 +72,7 @@ export function transformChatRequest( if (provider.type === 'ollama') { return { model: model || 'llama3', - messages: body.messages, + messages: flattenContentParts(body.messages), stream: false, ...(body.temperature !== undefined && { options: { temperature: body.temperature }