Skip to content
Merged
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
46 changes: 46 additions & 0 deletions agentic/agentic-server/__tests__/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
43 changes: 40 additions & 3 deletions agentic/agentic-server/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
59 changes: 58 additions & 1 deletion agentic/agentic-server/src/transforms.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
Expand All @@ -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 }
Expand Down
Loading