| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| export interface StreamMetrics {
|
| startTime: number;
|
| firstTokenTime: number;
|
| totalTokens: number;
|
| totalChunks: number;
|
| elapsedMs: number;
|
| tokensPerSecond: number;
|
| complete: boolean;
|
| error: string | null;
|
| finishReason: string | null;
|
| }
|
|
|
| interface StreamTrackerOptions {
|
| onProgress?: (metrics: StreamMetrics) => void;
|
| progressIntervalMs?: number;
|
| }
|
|
|
| export class StreamTracker {
|
| private _onProgress: ((metrics: StreamMetrics) => void) | null;
|
| private _progressIntervalMs: number;
|
| private _startTime: number;
|
| private _firstTokenTime: number;
|
| private _totalTokens: number;
|
| private _totalChunks: number;
|
| private _complete: boolean;
|
| private _error: string | null;
|
| private _finishReason: string | null;
|
| private _lastProgressAt: number;
|
| private _buffer: string;
|
|
|
| constructor(options: StreamTrackerOptions = {}) {
|
| this._onProgress = options.onProgress || null;
|
| this._progressIntervalMs = options.progressIntervalMs || 500;
|
|
|
| this._startTime = Date.now();
|
| this._firstTokenTime = 0;
|
| this._totalTokens = 0;
|
| this._totalChunks = 0;
|
| this._complete = false;
|
| this._error = null;
|
| this._finishReason = null;
|
| this._lastProgressAt = 0;
|
| this._buffer = "";
|
| }
|
|
|
| |
| |
| |
|
|
| onChunk(chunk) {
|
| this._totalChunks++;
|
|
|
| if (this._totalChunks === 1) {
|
| this._firstTokenTime = Date.now() - this._startTime;
|
| }
|
|
|
|
|
| let data = chunk;
|
| if (typeof chunk === "string") {
|
|
|
| if (chunk.startsWith("data: ")) {
|
| const payload = chunk.slice(6).trim();
|
| if (payload === "[DONE]") {
|
| this._complete = true;
|
| this._emitProgress();
|
| return;
|
| }
|
| try {
|
| data = JSON.parse(payload);
|
| } catch {
|
| data = null;
|
| }
|
| }
|
| }
|
|
|
| if (data && typeof data === "object") {
|
|
|
| const content = data.choices?.[0]?.delta?.content;
|
| if (content) {
|
|
|
| this._totalTokens += Math.ceil(content.length / 4);
|
| }
|
|
|
|
|
| const reason = data.choices?.[0]?.finish_reason;
|
| if (reason) {
|
| this._finishReason = reason;
|
| }
|
|
|
|
|
| if (data.usage?.completion_tokens) {
|
| this._totalTokens = data.usage.completion_tokens;
|
| }
|
| }
|
|
|
| this._maybeEmitProgress();
|
| }
|
|
|
| |
| |
| |
|
|
| onError(error) {
|
| this._error = typeof error === "string" ? error : error.message;
|
| this._complete = true;
|
| this._emitProgress();
|
| }
|
|
|
|
|
| onComplete() {
|
| this._complete = true;
|
| this._emitProgress();
|
| }
|
|
|
|
|
| getMetrics() {
|
| const elapsedMs = Date.now() - this._startTime;
|
| const tokensPerSecond = elapsedMs > 0 ? this._totalTokens / (elapsedMs / 1000) : 0;
|
|
|
| return {
|
| startTime: this._startTime,
|
| firstTokenTime: this._firstTokenTime,
|
| totalTokens: this._totalTokens,
|
| totalChunks: this._totalChunks,
|
| elapsedMs,
|
| tokensPerSecond: Math.round(tokensPerSecond * 10) / 10,
|
| complete: this._complete,
|
| error: this._error,
|
| finishReason: this._finishReason,
|
| };
|
| }
|
|
|
|
|
| _maybeEmitProgress() {
|
| const now = Date.now();
|
| if (now - this._lastProgressAt >= this._progressIntervalMs) {
|
| this._emitProgress();
|
| }
|
| }
|
|
|
|
|
| _emitProgress() {
|
| this._lastProgressAt = Date.now();
|
| if (this._onProgress) {
|
| this._onProgress(this.getMetrics());
|
| }
|
| }
|
| }
|
|
|
| |
| |
| |
| |
| |
|
|
| export function createStreamTracker(options = {}) {
|
| const tracker = new StreamTracker(options);
|
|
|
| const stream = new TransformStream({
|
| transform(chunk, controller) {
|
| const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
| const lines = text.split("\n");
|
|
|
| for (const line of lines) {
|
| if (line.startsWith("data: ")) {
|
| tracker.onChunk(line);
|
| }
|
| }
|
|
|
| controller.enqueue(chunk);
|
| },
|
| flush() {
|
| tracker.onComplete();
|
| },
|
| });
|
|
|
| return { stream, tracker };
|
| }
|
|
|