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 +4 -0
- package/esm/router.js +96 -4
- package/esm/streaming.js +85 -0
- package/esm/transforms.js +45 -1
- package/package.json +2 -2
- package/router.d.ts +1 -0
- package/router.js +96 -4
- package/streaming.d.ts +41 -0
- package/streaming.js +91 -0
- package/transforms.js +45 -1
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
|
|
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:
|
|
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:
|
|
249
|
+
error: { message: upstreamErrorMessage(provider, upstream.status, text), upstream: text }
|
|
158
250
|
});
|
|
159
251
|
return;
|
|
160
252
|
}
|
package/esm/streaming.js
ADDED
|
@@ -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.
|
|
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": "
|
|
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
|
|
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:
|
|
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:
|
|
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 }
|