agentic-server 0.24.0 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,10 @@ app.listen(3001, () => {
46
46
  | GET | `/v1/providers` | List configured providers |
47
47
  | GET | `/healthz` | Health check |
48
48
 
49
+ ## Streaming
50
+
51
+ `POST /v1/chat/completions` with `stream: true` is relayed as a `text/event-stream`: the gateway forwards the provider's frames verbatim, so a client receives tokens as they are produced. It adds `stream_options: { include_usage: true }` unless the caller set `stream_options` itself, and meters the usage the final frame carries. Streaming is available for OpenAI-compatible providers; a provider whose stream would need translation (`ollama`, `anthropic`) is rejected `501` rather than answered with a non-streaming body.
52
+
49
53
  ## Identity & tenancy
50
54
 
51
55
  Requests carry tenant identity via headers: `X-Database-Id` (**required** — requests without it are rejected `400`), `X-Entity-Id`, and `X-Actor-Id`. Routing to a specific provider can be forced with `X-LLM-Provider`.
package/esm/router.js CHANGED
@@ -6,13 +6,46 @@
6
6
  * - Model-based routing: "anthropic/claude-3.5-sonnet" → anthropic provider
7
7
  * - Header-based routing: X-LLM-Provider: ollama → ollama provider
8
8
  * - Fire-and-forget inference metering via an injected InferenceSink
9
+ * - Streaming chat completions relayed as server-sent events
9
10
  * - /v1/usage reporting endpoint for external usage submission
10
11
  */
11
12
  import { Logger } from '@pgpmjs/logger';
12
13
  import { Router } from 'express';
13
14
  import { buildProviderHeaders, resolveProvider, resolveUpstreamUrl } from './providers';
15
+ import { relayEventStream, withUsageStreamOptions } from './streaming';
14
16
  import { transformChatRequest, transformChatResponse, transformEmbedRequest, transformEmbedResponse } from './transforms';
15
17
  const log = new Logger('agentic-server');
18
+ /** How long an upstream reason may be before it stops being a message. */
19
+ const UPSTREAM_REASON_LIMIT = 300;
20
+ /**
21
+ * Name the upstream reason in the message itself.
22
+ *
23
+ * Harnesses surface `error.message` and nothing else, so a bare
24
+ * `LLM provider error: 400` reached a run as an unattributable failure while the
25
+ * reason — a rejected request shape, a missing key, an unknown model — sat
26
+ * unread in `error.upstream`.
27
+ */
28
+ function upstreamErrorMessage(provider, status, body) {
29
+ const parsed = (() => {
30
+ try {
31
+ const error = JSON.parse(body).error;
32
+ if (typeof error === 'string')
33
+ return error;
34
+ const message = error?.message;
35
+ return typeof message === 'string' ? message : body;
36
+ }
37
+ catch {
38
+ // Not every provider answers an error with JSON; the raw body is the reason.
39
+ return body;
40
+ }
41
+ })().trim().replace(/\s+/g, ' ');
42
+ const reason = parsed.length > UPSTREAM_REASON_LIMIT
43
+ ? `${parsed.slice(0, UPSTREAM_REASON_LIMIT)}…`
44
+ : parsed;
45
+ return reason
46
+ ? `${provider.type} provider error ${status}: ${reason}`
47
+ : `${provider.type} provider error ${status}`;
48
+ }
16
49
  export const createRouter = (options) => {
17
50
  const router = Router();
18
51
  // Metering is backend-agnostic: the caller injects an InferenceSink. When
@@ -36,14 +69,39 @@ export const createRouter = (options) => {
36
69
  provider: provider.type,
37
70
  model: req.body?.model
38
71
  });
72
+ // Only the OpenAI-compatible wire carries stream frames the gateway can
73
+ // relay; translating Ollama's or Anthropic's stream formats is separate
74
+ // work, and answering a stream request with a whole JSON body would break
75
+ // the client silently.
76
+ const streaming = req.body?.stream === true;
77
+ if (streaming && provider.type !== 'openai') {
78
+ res.status(501).json({
79
+ error: {
80
+ message: `streaming is not supported for provider type '${provider.type}'`
81
+ }
82
+ });
83
+ return;
84
+ }
39
85
  try {
40
86
  const upstreamUrl = resolveUpstreamUrl(provider, '/v1/chat/completions');
41
- const body = transformChatRequest(provider, req.body || {});
87
+ const transformed = transformChatRequest(provider, req.body || {});
88
+ const body = streaming ? withUsageStreamOptions(transformed) : transformed;
42
89
  const headers = buildProviderHeaders(provider);
90
+ const abort = new AbortController();
91
+ // A client that hangs up mid-stream stops the upstream read; the request
92
+ // stream's own 'close' fires as soon as the body is consumed, so the
93
+ // response is what reports the disconnect.
94
+ if (streaming) {
95
+ res.on('close', () => {
96
+ if (!res.writableEnded)
97
+ abort.abort();
98
+ });
99
+ }
43
100
  const upstream = await fetch(upstreamUrl, {
44
101
  method: 'POST',
45
102
  headers,
46
- body: JSON.stringify(body)
103
+ body: JSON.stringify(body),
104
+ ...(streaming ? { signal: abort.signal } : {})
47
105
  });
48
106
  const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
49
107
  if (!upstream.ok) {
@@ -63,8 +121,36 @@ export const createRouter = (options) => {
63
121
  });
64
122
  }
65
123
  res.status(upstream.status).json({
66
- error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
124
+ error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text }
125
+ });
126
+ return;
127
+ }
128
+ if (streaming) {
129
+ const streamUsage = await relayEventStream(upstream, res);
130
+ const streamLatencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
131
+ log.info('inference complete', {
132
+ databaseId,
133
+ provider: provider.type,
134
+ model: req.body?.model,
135
+ promptTokens: streamUsage?.prompt_tokens,
136
+ completionTokens: streamUsage?.completion_tokens,
137
+ streamed: true
67
138
  });
139
+ if (sink) {
140
+ sink.logInference({
141
+ databaseId, entityId, actorId,
142
+ model: String(req.body?.model || body.model || ''),
143
+ provider: provider.type,
144
+ service: 'chat',
145
+ operation: 'chat/completions',
146
+ inputTokens: streamUsage?.prompt_tokens || 0,
147
+ outputTokens: streamUsage?.completion_tokens || 0,
148
+ totalTokens: streamUsage?.total_tokens || 0,
149
+ latencyMs: streamLatencyMs,
150
+ status: 'ok',
151
+ ...(streamUsage ? { rawUsage: streamUsage } : {})
152
+ });
153
+ }
68
154
  return;
69
155
  }
70
156
  const data = await upstream.json();
@@ -109,6 +195,12 @@ export const createRouter = (options) => {
109
195
  errorType: err.message
110
196
  });
111
197
  }
198
+ // A stream that failed mid-relay has already sent its status and frames;
199
+ // the client sees the truncated stream rather than a JSON error.
200
+ if (res.headersSent) {
201
+ res.end();
202
+ return;
203
+ }
112
204
  res.status(502).json({
113
205
  error: { message: 'Failed to reach LLM provider', details: err.message }
114
206
  });
@@ -154,7 +246,7 @@ export const createRouter = (options) => {
154
246
  });
155
247
  }
156
248
  res.status(upstream.status).json({
157
- error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
249
+ error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text }
158
250
  });
159
251
  return;
160
252
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Server-sent-event passthrough for streaming chat completions.
3
+ *
4
+ * The gateway relays an OpenAI-compatible `text/event-stream` body verbatim so
5
+ * clients receive tokens as the provider emits them, while scanning the frames
6
+ * for the terminal usage object that metering needs.
7
+ */
8
+ /** Accumulates SSE frames, exposing the last usage object the stream carried. */
9
+ export class UsageScanner {
10
+ buffer = '';
11
+ usage;
12
+ /** Feed one decoded chunk of the event stream. */
13
+ push(chunk) {
14
+ this.buffer += chunk;
15
+ const frames = this.buffer.split('\n');
16
+ this.buffer = frames.pop() ?? '';
17
+ for (const frame of frames) {
18
+ const line = frame.trim();
19
+ if (!line.startsWith('data:'))
20
+ continue;
21
+ const payload = line.slice('data:'.length).trim();
22
+ if (payload === '' || payload === '[DONE]')
23
+ continue;
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(payload);
27
+ }
28
+ catch {
29
+ // A provider frame the gateway cannot parse carries no usage it could
30
+ // report; relaying is the router's job and happens regardless.
31
+ continue;
32
+ }
33
+ const usage = parsed.usage;
34
+ if (!usage)
35
+ continue;
36
+ this.usage = {
37
+ prompt_tokens: usage.prompt_tokens ?? 0,
38
+ completion_tokens: usage.completion_tokens ?? 0,
39
+ total_tokens: usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0)
40
+ };
41
+ }
42
+ }
43
+ /** The last usage object seen, or undefined when the provider sent none. */
44
+ result() {
45
+ return this.usage;
46
+ }
47
+ }
48
+ /**
49
+ * Relay an upstream event-stream response to the client byte for byte and
50
+ * answer the usage its final frame carried, or undefined when it carried none.
51
+ *
52
+ * @param upstream - the provider response whose body is being relayed.
53
+ * @param res - the client response the frames are written to.
54
+ * @returns the usage the stream reported, if any.
55
+ */
56
+ export async function relayEventStream(upstream, res) {
57
+ res.setHeader('Content-Type', upstream.headers.get('content-type') ?? 'text/event-stream');
58
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
59
+ res.setHeader('Connection', 'keep-alive');
60
+ res.flushHeaders?.();
61
+ const scanner = new UsageScanner();
62
+ if (upstream.body) {
63
+ const decoder = new TextDecoder();
64
+ const reader = upstream.body.getReader();
65
+ for (;;) {
66
+ const { done, value } = await reader.read();
67
+ if (done)
68
+ break;
69
+ const text = decoder.decode(value, { stream: true });
70
+ scanner.push(text);
71
+ res.write(text);
72
+ }
73
+ }
74
+ res.end();
75
+ return scanner.result();
76
+ }
77
+ /**
78
+ * Ask an OpenAI-compatible provider to include usage in its final frame.
79
+ * Callers that already set `stream_options` keep their own choice.
80
+ */
81
+ export function withUsageStreamOptions(body) {
82
+ if (body.stream_options !== undefined)
83
+ return body;
84
+ return { ...body, stream_options: { include_usage: true } };
85
+ }
package/esm/transforms.js CHANGED
@@ -1,3 +1,47 @@
1
+ /**
2
+ * Flatten OpenAI content parts into the single string Ollama's chat api takes.
3
+ *
4
+ * Harnesses send `content` as an array of parts — pi always does — and Ollama
5
+ * answers that with `json: cannot unmarshal array into Go struct field
6
+ * ChatRequest.messages.content of type string`, which reaches the harness as a
7
+ * bare `400` and reads as a broken model rather than a dialect mismatch. Image
8
+ * parts move to Ollama's own `images` field; a part this cannot express fails
9
+ * loudly rather than being dropped into a prompt the model never sees.
10
+ */
11
+ function flattenContentParts(messages) {
12
+ if (!Array.isArray(messages))
13
+ return messages;
14
+ return messages.map((message) => {
15
+ if (!Array.isArray(message.content))
16
+ return message;
17
+ const text = [];
18
+ const images = [];
19
+ for (const part of message.content) {
20
+ if (part.type === 'text' || part.type === 'input_text') {
21
+ text.push(part.text ?? '');
22
+ continue;
23
+ }
24
+ if (part.type === 'image_url') {
25
+ const url = part.image_url?.url;
26
+ if (!url)
27
+ throw new Error('ollama: an image_url content part carries no url');
28
+ // Ollama takes base64 bytes, never a fetchable url.
29
+ const base64 = /^data:[^;]*;base64,(.*)$/.exec(url)?.[1];
30
+ if (!base64) {
31
+ throw new Error('ollama: an image content part must be a base64 data url');
32
+ }
33
+ images.push(base64);
34
+ continue;
35
+ }
36
+ throw new Error(`ollama: unsupported content part type '${part.type}'`);
37
+ }
38
+ return {
39
+ ...message,
40
+ content: text.join('\n'),
41
+ ...(images.length ? { images: [...(message.images ?? []), ...images] } : {})
42
+ };
43
+ });
44
+ }
1
45
  export function transformChatRequest(provider, body) {
2
46
  let model = body.model || provider.defaultModel;
3
47
  if (typeof model === 'string' && model.includes('/')) {
@@ -9,7 +53,7 @@ export function transformChatRequest(provider, body) {
9
53
  if (provider.type === 'ollama') {
10
54
  return {
11
55
  model: model || 'llama3',
12
- messages: body.messages,
56
+ messages: flattenContentParts(body.messages),
13
57
  stream: false,
14
58
  ...(body.temperature !== undefined && {
15
59
  options: { temperature: body.temperature }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-server",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "Standalone OpenAI-compatible LLM gateway — multi-provider proxy with pluggable inference metering",
5
5
  "main": "index.js",
6
6
  "module": "esm/index.js",
@@ -51,5 +51,5 @@
51
51
  "metering",
52
52
  "constructive"
53
53
  ],
54
- "gitHead": "b5b6b9f05276b7e9cbd3d1f4acd31dd315c353ea"
54
+ "gitHead": "f9611d3a1ba8bdb50a39b5bca7c9a7115df96e34"
55
55
  }
package/router.d.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  * - Model-based routing: "anthropic/claude-3.5-sonnet" → anthropic provider
7
7
  * - Header-based routing: X-LLM-Provider: ollama → ollama provider
8
8
  * - Fire-and-forget inference metering via an injected InferenceSink
9
+ * - Streaming chat completions relayed as server-sent events
9
10
  * - /v1/usage reporting endpoint for external usage submission
10
11
  */
11
12
  import { Router } from 'express';
package/router.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * - Model-based routing: "anthropic/claude-3.5-sonnet" → anthropic provider
8
8
  * - Header-based routing: X-LLM-Provider: ollama → ollama provider
9
9
  * - Fire-and-forget inference metering via an injected InferenceSink
10
+ * - Streaming chat completions relayed as server-sent events
10
11
  * - /v1/usage reporting endpoint for external usage submission
11
12
  */
12
13
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -14,8 +15,40 @@ exports.createRouter = void 0;
14
15
  const logger_1 = require("@pgpmjs/logger");
15
16
  const express_1 = require("express");
16
17
  const providers_1 = require("./providers");
18
+ const streaming_1 = require("./streaming");
17
19
  const transforms_1 = require("./transforms");
18
20
  const log = new logger_1.Logger('agentic-server');
21
+ /** How long an upstream reason may be before it stops being a message. */
22
+ const UPSTREAM_REASON_LIMIT = 300;
23
+ /**
24
+ * Name the upstream reason in the message itself.
25
+ *
26
+ * Harnesses surface `error.message` and nothing else, so a bare
27
+ * `LLM provider error: 400` reached a run as an unattributable failure while the
28
+ * reason — a rejected request shape, a missing key, an unknown model — sat
29
+ * unread in `error.upstream`.
30
+ */
31
+ function upstreamErrorMessage(provider, status, body) {
32
+ const parsed = (() => {
33
+ try {
34
+ const error = JSON.parse(body).error;
35
+ if (typeof error === 'string')
36
+ return error;
37
+ const message = error?.message;
38
+ return typeof message === 'string' ? message : body;
39
+ }
40
+ catch {
41
+ // Not every provider answers an error with JSON; the raw body is the reason.
42
+ return body;
43
+ }
44
+ })().trim().replace(/\s+/g, ' ');
45
+ const reason = parsed.length > UPSTREAM_REASON_LIMIT
46
+ ? `${parsed.slice(0, UPSTREAM_REASON_LIMIT)}…`
47
+ : parsed;
48
+ return reason
49
+ ? `${provider.type} provider error ${status}: ${reason}`
50
+ : `${provider.type} provider error ${status}`;
51
+ }
19
52
  const createRouter = (options) => {
20
53
  const router = (0, express_1.Router)();
21
54
  // Metering is backend-agnostic: the caller injects an InferenceSink. When
@@ -39,14 +72,39 @@ const createRouter = (options) => {
39
72
  provider: provider.type,
40
73
  model: req.body?.model
41
74
  });
75
+ // Only the OpenAI-compatible wire carries stream frames the gateway can
76
+ // relay; translating Ollama's or Anthropic's stream formats is separate
77
+ // work, and answering a stream request with a whole JSON body would break
78
+ // the client silently.
79
+ const streaming = req.body?.stream === true;
80
+ if (streaming && provider.type !== 'openai') {
81
+ res.status(501).json({
82
+ error: {
83
+ message: `streaming is not supported for provider type '${provider.type}'`
84
+ }
85
+ });
86
+ return;
87
+ }
42
88
  try {
43
89
  const upstreamUrl = (0, providers_1.resolveUpstreamUrl)(provider, '/v1/chat/completions');
44
- const body = (0, transforms_1.transformChatRequest)(provider, req.body || {});
90
+ const transformed = (0, transforms_1.transformChatRequest)(provider, req.body || {});
91
+ const body = streaming ? (0, streaming_1.withUsageStreamOptions)(transformed) : transformed;
45
92
  const headers = (0, providers_1.buildProviderHeaders)(provider);
93
+ const abort = new AbortController();
94
+ // A client that hangs up mid-stream stops the upstream read; the request
95
+ // stream's own 'close' fires as soon as the body is consumed, so the
96
+ // response is what reports the disconnect.
97
+ if (streaming) {
98
+ res.on('close', () => {
99
+ if (!res.writableEnded)
100
+ abort.abort();
101
+ });
102
+ }
46
103
  const upstream = await fetch(upstreamUrl, {
47
104
  method: 'POST',
48
105
  headers,
49
- body: JSON.stringify(body)
106
+ body: JSON.stringify(body),
107
+ ...(streaming ? { signal: abort.signal } : {})
50
108
  });
51
109
  const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
52
110
  if (!upstream.ok) {
@@ -66,8 +124,36 @@ const createRouter = (options) => {
66
124
  });
67
125
  }
68
126
  res.status(upstream.status).json({
69
- error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
127
+ error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text }
128
+ });
129
+ return;
130
+ }
131
+ if (streaming) {
132
+ const streamUsage = await (0, streaming_1.relayEventStream)(upstream, res);
133
+ const streamLatencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
134
+ log.info('inference complete', {
135
+ databaseId,
136
+ provider: provider.type,
137
+ model: req.body?.model,
138
+ promptTokens: streamUsage?.prompt_tokens,
139
+ completionTokens: streamUsage?.completion_tokens,
140
+ streamed: true
70
141
  });
142
+ if (sink) {
143
+ sink.logInference({
144
+ databaseId, entityId, actorId,
145
+ model: String(req.body?.model || body.model || ''),
146
+ provider: provider.type,
147
+ service: 'chat',
148
+ operation: 'chat/completions',
149
+ inputTokens: streamUsage?.prompt_tokens || 0,
150
+ outputTokens: streamUsage?.completion_tokens || 0,
151
+ totalTokens: streamUsage?.total_tokens || 0,
152
+ latencyMs: streamLatencyMs,
153
+ status: 'ok',
154
+ ...(streamUsage ? { rawUsage: streamUsage } : {})
155
+ });
156
+ }
71
157
  return;
72
158
  }
73
159
  const data = await upstream.json();
@@ -112,6 +198,12 @@ const createRouter = (options) => {
112
198
  errorType: err.message
113
199
  });
114
200
  }
201
+ // A stream that failed mid-relay has already sent its status and frames;
202
+ // the client sees the truncated stream rather than a JSON error.
203
+ if (res.headersSent) {
204
+ res.end();
205
+ return;
206
+ }
115
207
  res.status(502).json({
116
208
  error: { message: 'Failed to reach LLM provider', details: err.message }
117
209
  });
@@ -157,7 +249,7 @@ const createRouter = (options) => {
157
249
  });
158
250
  }
159
251
  res.status(upstream.status).json({
160
- error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
252
+ error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text }
161
253
  });
162
254
  return;
163
255
  }
package/streaming.d.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Server-sent-event passthrough for streaming chat completions.
3
+ *
4
+ * The gateway relays an OpenAI-compatible `text/event-stream` body verbatim so
5
+ * clients receive tokens as the provider emits them, while scanning the frames
6
+ * for the terminal usage object that metering needs.
7
+ */
8
+ import type { UsageResult } from './types';
9
+ /** Accumulates SSE frames, exposing the last usage object the stream carried. */
10
+ export declare class UsageScanner {
11
+ private buffer;
12
+ private usage;
13
+ /** Feed one decoded chunk of the event stream. */
14
+ push(chunk: string): void;
15
+ /** The last usage object seen, or undefined when the provider sent none. */
16
+ result(): UsageResult | undefined;
17
+ }
18
+ /**
19
+ * Relay an upstream event-stream response to the client byte for byte and
20
+ * answer the usage its final frame carried, or undefined when it carried none.
21
+ *
22
+ * @param upstream - the provider response whose body is being relayed.
23
+ * @param res - the client response the frames are written to.
24
+ * @returns the usage the stream reported, if any.
25
+ */
26
+ export declare function relayEventStream(upstream: {
27
+ body: ReadableStream<Uint8Array> | null;
28
+ headers: {
29
+ get(name: string): string | null;
30
+ };
31
+ }, res: {
32
+ setHeader(name: string, value: string): void;
33
+ flushHeaders?(): void;
34
+ write(chunk: string): void;
35
+ end(): void;
36
+ }): Promise<UsageResult | undefined>;
37
+ /**
38
+ * Ask an OpenAI-compatible provider to include usage in its final frame.
39
+ * Callers that already set `stream_options` keep their own choice.
40
+ */
41
+ export declare function withUsageStreamOptions(body: Record<string, unknown>): Record<string, unknown>;
package/streaming.js ADDED
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+ /**
3
+ * Server-sent-event passthrough for streaming chat completions.
4
+ *
5
+ * The gateway relays an OpenAI-compatible `text/event-stream` body verbatim so
6
+ * clients receive tokens as the provider emits them, while scanning the frames
7
+ * for the terminal usage object that metering needs.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.UsageScanner = void 0;
11
+ exports.relayEventStream = relayEventStream;
12
+ exports.withUsageStreamOptions = withUsageStreamOptions;
13
+ /** Accumulates SSE frames, exposing the last usage object the stream carried. */
14
+ class UsageScanner {
15
+ buffer = '';
16
+ usage;
17
+ /** Feed one decoded chunk of the event stream. */
18
+ push(chunk) {
19
+ this.buffer += chunk;
20
+ const frames = this.buffer.split('\n');
21
+ this.buffer = frames.pop() ?? '';
22
+ for (const frame of frames) {
23
+ const line = frame.trim();
24
+ if (!line.startsWith('data:'))
25
+ continue;
26
+ const payload = line.slice('data:'.length).trim();
27
+ if (payload === '' || payload === '[DONE]')
28
+ continue;
29
+ let parsed;
30
+ try {
31
+ parsed = JSON.parse(payload);
32
+ }
33
+ catch {
34
+ // A provider frame the gateway cannot parse carries no usage it could
35
+ // report; relaying is the router's job and happens regardless.
36
+ continue;
37
+ }
38
+ const usage = parsed.usage;
39
+ if (!usage)
40
+ continue;
41
+ this.usage = {
42
+ prompt_tokens: usage.prompt_tokens ?? 0,
43
+ completion_tokens: usage.completion_tokens ?? 0,
44
+ total_tokens: usage.total_tokens ?? (usage.prompt_tokens ?? 0) + (usage.completion_tokens ?? 0)
45
+ };
46
+ }
47
+ }
48
+ /** The last usage object seen, or undefined when the provider sent none. */
49
+ result() {
50
+ return this.usage;
51
+ }
52
+ }
53
+ exports.UsageScanner = UsageScanner;
54
+ /**
55
+ * Relay an upstream event-stream response to the client byte for byte and
56
+ * answer the usage its final frame carried, or undefined when it carried none.
57
+ *
58
+ * @param upstream - the provider response whose body is being relayed.
59
+ * @param res - the client response the frames are written to.
60
+ * @returns the usage the stream reported, if any.
61
+ */
62
+ async function relayEventStream(upstream, res) {
63
+ res.setHeader('Content-Type', upstream.headers.get('content-type') ?? 'text/event-stream');
64
+ res.setHeader('Cache-Control', 'no-cache, no-transform');
65
+ res.setHeader('Connection', 'keep-alive');
66
+ res.flushHeaders?.();
67
+ const scanner = new UsageScanner();
68
+ if (upstream.body) {
69
+ const decoder = new TextDecoder();
70
+ const reader = upstream.body.getReader();
71
+ for (;;) {
72
+ const { done, value } = await reader.read();
73
+ if (done)
74
+ break;
75
+ const text = decoder.decode(value, { stream: true });
76
+ scanner.push(text);
77
+ res.write(text);
78
+ }
79
+ }
80
+ res.end();
81
+ return scanner.result();
82
+ }
83
+ /**
84
+ * Ask an OpenAI-compatible provider to include usage in its final frame.
85
+ * Callers that already set `stream_options` keep their own choice.
86
+ */
87
+ function withUsageStreamOptions(body) {
88
+ if (body.stream_options !== undefined)
89
+ return body;
90
+ return { ...body, stream_options: { include_usage: true } };
91
+ }
package/transforms.js CHANGED
@@ -4,6 +4,50 @@ exports.transformChatRequest = transformChatRequest;
4
4
  exports.transformEmbedRequest = transformEmbedRequest;
5
5
  exports.transformChatResponse = transformChatResponse;
6
6
  exports.transformEmbedResponse = transformEmbedResponse;
7
+ /**
8
+ * Flatten OpenAI content parts into the single string Ollama's chat api takes.
9
+ *
10
+ * Harnesses send `content` as an array of parts — pi always does — and Ollama
11
+ * answers that with `json: cannot unmarshal array into Go struct field
12
+ * ChatRequest.messages.content of type string`, which reaches the harness as a
13
+ * bare `400` and reads as a broken model rather than a dialect mismatch. Image
14
+ * parts move to Ollama's own `images` field; a part this cannot express fails
15
+ * loudly rather than being dropped into a prompt the model never sees.
16
+ */
17
+ function flattenContentParts(messages) {
18
+ if (!Array.isArray(messages))
19
+ return messages;
20
+ return messages.map((message) => {
21
+ if (!Array.isArray(message.content))
22
+ return message;
23
+ const text = [];
24
+ const images = [];
25
+ for (const part of message.content) {
26
+ if (part.type === 'text' || part.type === 'input_text') {
27
+ text.push(part.text ?? '');
28
+ continue;
29
+ }
30
+ if (part.type === 'image_url') {
31
+ const url = part.image_url?.url;
32
+ if (!url)
33
+ throw new Error('ollama: an image_url content part carries no url');
34
+ // Ollama takes base64 bytes, never a fetchable url.
35
+ const base64 = /^data:[^;]*;base64,(.*)$/.exec(url)?.[1];
36
+ if (!base64) {
37
+ throw new Error('ollama: an image content part must be a base64 data url');
38
+ }
39
+ images.push(base64);
40
+ continue;
41
+ }
42
+ throw new Error(`ollama: unsupported content part type '${part.type}'`);
43
+ }
44
+ return {
45
+ ...message,
46
+ content: text.join('\n'),
47
+ ...(images.length ? { images: [...(message.images ?? []), ...images] } : {})
48
+ };
49
+ });
50
+ }
7
51
  function transformChatRequest(provider, body) {
8
52
  let model = body.model || provider.defaultModel;
9
53
  if (typeof model === 'string' && model.includes('/')) {
@@ -15,7 +59,7 @@ function transformChatRequest(provider, body) {
15
59
  if (provider.type === 'ollama') {
16
60
  return {
17
61
  model: model || 'llama3',
18
- messages: body.messages,
62
+ messages: flattenContentParts(body.messages),
19
63
  stream: false,
20
64
  ...(body.temperature !== undefined && {
21
65
  options: { temperature: body.temperature }