agentic-server 0.25.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/esm/router.js CHANGED
@@ -15,6 +15,37 @@ import { buildProviderHeaders, resolveProvider, resolveUpstreamUrl } from './pro
15
15
  import { relayEventStream, withUsageStreamOptions } from './streaming';
16
16
  import { transformChatRequest, transformChatResponse, transformEmbedRequest, transformEmbedResponse } from './transforms';
17
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
+ }
18
49
  export const createRouter = (options) => {
19
50
  const router = Router();
20
51
  // Metering is backend-agnostic: the caller injects an InferenceSink. When
@@ -90,7 +121,7 @@ export const createRouter = (options) => {
90
121
  });
91
122
  }
92
123
  res.status(upstream.status).json({
93
- error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
124
+ error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text }
94
125
  });
95
126
  return;
96
127
  }
@@ -215,7 +246,7 @@ export const createRouter = (options) => {
215
246
  });
216
247
  }
217
248
  res.status(upstream.status).json({
218
- error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
249
+ error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text }
219
250
  });
220
251
  return;
221
252
  }
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.25.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": "e35db12526094b515596ced2dd27a1ec8edd99d5"
54
+ "gitHead": "f9611d3a1ba8bdb50a39b5bca7c9a7115df96e34"
55
55
  }
package/router.js CHANGED
@@ -18,6 +18,37 @@ const providers_1 = require("./providers");
18
18
  const streaming_1 = require("./streaming");
19
19
  const transforms_1 = require("./transforms");
20
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
+ }
21
52
  const createRouter = (options) => {
22
53
  const router = (0, express_1.Router)();
23
54
  // Metering is backend-agnostic: the caller injects an InferenceSink. When
@@ -93,7 +124,7 @@ const createRouter = (options) => {
93
124
  });
94
125
  }
95
126
  res.status(upstream.status).json({
96
- error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
127
+ error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text }
97
128
  });
98
129
  return;
99
130
  }
@@ -218,7 +249,7 @@ const createRouter = (options) => {
218
249
  });
219
250
  }
220
251
  res.status(upstream.status).json({
221
- error: { message: `LLM provider error: ${upstream.status}`, upstream: text }
252
+ error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text }
222
253
  });
223
254
  return;
224
255
  }
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 }