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 +4 -0
- package/esm/router.js +63 -2
- package/esm/streaming.js +85 -0
- package/package.json +2 -2
- package/router.d.ts +1 -0
- package/router.js +63 -2
- package/streaming.d.ts +41 -0
- package/streaming.js +91 -0
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
|
|
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
|
});
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentic-server",
|
|
3
|
-
"version": "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": "
|
|
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
|
|
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
|
+
}
|