agentic-server 0.25.0 → 0.25.2
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 +33 -2
- package/esm/transforms.js +45 -1
- package/package.json +4 -4
- package/router.js +33 -2
- package/transforms.js +45 -1
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:
|
|
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:
|
|
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.
|
|
3
|
+
"version": "0.25.2",
|
|
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",
|
|
@@ -29,13 +29,13 @@
|
|
|
29
29
|
"test:watch": "jest --watch"
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@pgpmjs/logger": "^2.25.
|
|
32
|
+
"@pgpmjs/logger": "^2.25.1"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"@types/express": "^5.0.6",
|
|
36
36
|
"@types/node": "^22.19.11",
|
|
37
37
|
"express": "^5.2.1",
|
|
38
|
-
"makage": "^0.
|
|
38
|
+
"makage": "^0.8.0"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
41
|
"express": "^5.0.0"
|
|
@@ -51,5 +51,5 @@
|
|
|
51
51
|
"metering",
|
|
52
52
|
"constructive"
|
|
53
53
|
],
|
|
54
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "5e0bfead2856d1a062b495d343a43e543343f9b8"
|
|
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:
|
|
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:
|
|
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 }
|