agentic-server 0.24.0 → 0.25.0

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,11 +6,13 @@
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');
16
18
  export const createRouter = (options) => {
@@ -36,14 +38,39 @@ export const createRouter = (options) => {
36
38
  provider: provider.type,
37
39
  model: req.body?.model
38
40
  });
41
+ // Only the OpenAI-compatible wire carries stream frames the gateway can
42
+ // relay; translating Ollama's or Anthropic's stream formats is separate
43
+ // work, and answering a stream request with a whole JSON body would break
44
+ // the client silently.
45
+ const streaming = req.body?.stream === true;
46
+ if (streaming && provider.type !== 'openai') {
47
+ res.status(501).json({
48
+ error: {
49
+ message: `streaming is not supported for provider type '${provider.type}'`
50
+ }
51
+ });
52
+ return;
53
+ }
39
54
  try {
40
55
  const upstreamUrl = resolveUpstreamUrl(provider, '/v1/chat/completions');
41
- const body = transformChatRequest(provider, req.body || {});
56
+ const transformed = transformChatRequest(provider, req.body || {});
57
+ const body = streaming ? withUsageStreamOptions(transformed) : transformed;
42
58
  const headers = buildProviderHeaders(provider);
59
+ const abort = new AbortController();
60
+ // A client that hangs up mid-stream stops the upstream read; the request
61
+ // stream's own 'close' fires as soon as the body is consumed, so the
62
+ // response is what reports the disconnect.
63
+ if (streaming) {
64
+ res.on('close', () => {
65
+ if (!res.writableEnded)
66
+ abort.abort();
67
+ });
68
+ }
43
69
  const upstream = await fetch(upstreamUrl, {
44
70
  method: 'POST',
45
71
  headers,
46
- body: JSON.stringify(body)
72
+ body: JSON.stringify(body),
73
+ ...(streaming ? { signal: abort.signal } : {})
47
74
  });
48
75
  const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
49
76
  if (!upstream.ok) {
@@ -67,6 +94,34 @@ export const createRouter = (options) => {
67
94
  });
68
95
  return;
69
96
  }
97
+ if (streaming) {
98
+ const streamUsage = await relayEventStream(upstream, res);
99
+ const streamLatencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
100
+ log.info('inference complete', {
101
+ databaseId,
102
+ provider: provider.type,
103
+ model: req.body?.model,
104
+ promptTokens: streamUsage?.prompt_tokens,
105
+ completionTokens: streamUsage?.completion_tokens,
106
+ streamed: true
107
+ });
108
+ if (sink) {
109
+ sink.logInference({
110
+ databaseId, entityId, actorId,
111
+ model: String(req.body?.model || body.model || ''),
112
+ provider: provider.type,
113
+ service: 'chat',
114
+ operation: 'chat/completions',
115
+ inputTokens: streamUsage?.prompt_tokens || 0,
116
+ outputTokens: streamUsage?.completion_tokens || 0,
117
+ totalTokens: streamUsage?.total_tokens || 0,
118
+ latencyMs: streamLatencyMs,
119
+ status: 'ok',
120
+ ...(streamUsage ? { rawUsage: streamUsage } : {})
121
+ });
122
+ }
123
+ return;
124
+ }
70
125
  const data = await upstream.json();
71
126
  const { body: responseBody, usage } = transformChatResponse(data, provider);
72
127
  log.info('inference complete', {
@@ -109,6 +164,12 @@ export const createRouter = (options) => {
109
164
  errorType: err.message
110
165
  });
111
166
  }
167
+ // A stream that failed mid-relay has already sent its status and frames;
168
+ // the client sees the truncated stream rather than a JSON error.
169
+ if (res.headersSent) {
170
+ res.end();
171
+ return;
172
+ }
112
173
  res.status(502).json({
113
174
  error: { message: 'Failed to reach LLM provider', details: err.message }
114
175
  });
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentic-server",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
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": "e35db12526094b515596ced2dd27a1ec8edd99d5"
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,6 +15,7 @@ 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');
19
21
  const createRouter = (options) => {
@@ -39,14 +41,39 @@ const createRouter = (options) => {
39
41
  provider: provider.type,
40
42
  model: req.body?.model
41
43
  });
44
+ // Only the OpenAI-compatible wire carries stream frames the gateway can
45
+ // relay; translating Ollama's or Anthropic's stream formats is separate
46
+ // work, and answering a stream request with a whole JSON body would break
47
+ // the client silently.
48
+ const streaming = req.body?.stream === true;
49
+ if (streaming && provider.type !== 'openai') {
50
+ res.status(501).json({
51
+ error: {
52
+ message: `streaming is not supported for provider type '${provider.type}'`
53
+ }
54
+ });
55
+ return;
56
+ }
42
57
  try {
43
58
  const upstreamUrl = (0, providers_1.resolveUpstreamUrl)(provider, '/v1/chat/completions');
44
- const body = (0, transforms_1.transformChatRequest)(provider, req.body || {});
59
+ const transformed = (0, transforms_1.transformChatRequest)(provider, req.body || {});
60
+ const body = streaming ? (0, streaming_1.withUsageStreamOptions)(transformed) : transformed;
45
61
  const headers = (0, providers_1.buildProviderHeaders)(provider);
62
+ const abort = new AbortController();
63
+ // A client that hangs up mid-stream stops the upstream read; the request
64
+ // stream's own 'close' fires as soon as the body is consumed, so the
65
+ // response is what reports the disconnect.
66
+ if (streaming) {
67
+ res.on('close', () => {
68
+ if (!res.writableEnded)
69
+ abort.abort();
70
+ });
71
+ }
46
72
  const upstream = await fetch(upstreamUrl, {
47
73
  method: 'POST',
48
74
  headers,
49
- body: JSON.stringify(body)
75
+ body: JSON.stringify(body),
76
+ ...(streaming ? { signal: abort.signal } : {})
50
77
  });
51
78
  const latencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
52
79
  if (!upstream.ok) {
@@ -70,6 +97,34 @@ const createRouter = (options) => {
70
97
  });
71
98
  return;
72
99
  }
100
+ if (streaming) {
101
+ const streamUsage = await (0, streaming_1.relayEventStream)(upstream, res);
102
+ const streamLatencyMs = Number(process.hrtime.bigint() - startTime) / 1e6;
103
+ log.info('inference complete', {
104
+ databaseId,
105
+ provider: provider.type,
106
+ model: req.body?.model,
107
+ promptTokens: streamUsage?.prompt_tokens,
108
+ completionTokens: streamUsage?.completion_tokens,
109
+ streamed: true
110
+ });
111
+ if (sink) {
112
+ sink.logInference({
113
+ databaseId, entityId, actorId,
114
+ model: String(req.body?.model || body.model || ''),
115
+ provider: provider.type,
116
+ service: 'chat',
117
+ operation: 'chat/completions',
118
+ inputTokens: streamUsage?.prompt_tokens || 0,
119
+ outputTokens: streamUsage?.completion_tokens || 0,
120
+ totalTokens: streamUsage?.total_tokens || 0,
121
+ latencyMs: streamLatencyMs,
122
+ status: 'ok',
123
+ ...(streamUsage ? { rawUsage: streamUsage } : {})
124
+ });
125
+ }
126
+ return;
127
+ }
73
128
  const data = await upstream.json();
74
129
  const { body: responseBody, usage } = (0, transforms_1.transformChatResponse)(data, provider);
75
130
  log.info('inference complete', {
@@ -112,6 +167,12 @@ const createRouter = (options) => {
112
167
  errorType: err.message
113
168
  });
114
169
  }
170
+ // A stream that failed mid-relay has already sent its status and frames;
171
+ // the client sees the truncated stream rather than a JSON error.
172
+ if (res.headersSent) {
173
+ res.end();
174
+ return;
175
+ }
115
176
  res.status(502).json({
116
177
  error: { message: 'Failed to reach LLM provider', details: err.message }
117
178
  });
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
+ }