llmforge
Version:
One API, every AI model, instant switching. Change from GPT-4 to Gemini to local models with a single config update. LLMForge is the lightweight, TypeScript-first solution for multi-provider AI applications with zero vendor lock-in.
418 lines • 19.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.GoogleStreamProcessor = void 0;
const logger_1 = require("../../utils/logger");
class GoogleStreamProcessor {
constructor() {
this.decoder = new TextDecoder();
}
async processStream(response, options = {}) {
if (!response.body) {
throw new Error('Response body is empty');
}
const reader = response.body.getReader();
let buffer = '';
let aggregatedResponse = { candidates: [] };
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
buffer += this.decoder.decode(value, { stream: true });
// Try to extract complete JSON objects from buffer
const { objects, remainingBuffer } = this.extractCompleteJsonObjects(buffer);
buffer = remainingBuffer;
for (const jsonObject of objects) {
try {
const chunk = JSON.parse(jsonObject);
if (chunk) {
// Handle thinking content
if (this.hasThinkingContent(chunk)) {
const thinking = this.extractThinkingContent(chunk);
if (thinking && options.onThinking) {
options.onThinking(thinking);
}
}
// Aggregate the response
this.aggregateChunk(aggregatedResponse, chunk);
// Call chunk handler
if (options.onChunk) {
options.onChunk(chunk);
}
}
}
catch (error) {
logger_1.logger.warn('Failed to parse stream chunk:', jsonObject, error);
}
}
}
// Process any remaining buffer content
if (buffer.trim()) {
try {
const chunk = JSON.parse(buffer.trim());
if (chunk) {
this.aggregateChunk(aggregatedResponse, chunk);
if (options.onChunk) {
options.onChunk(chunk);
}
}
}
catch (error) {
logger_1.logger.warn('Failed to parse final chunk:', buffer, error);
}
}
if (options.onComplete) {
options.onComplete(aggregatedResponse);
}
return { ...aggregatedResponse };
}
catch (error) {
if (options.onError) {
options.onError(error);
}
throw error;
}
finally {
reader.releaseLock();
}
}
extractCompleteJsonObjects(buffer) {
const objects = [];
let currentBuffer = buffer;
let bracketCount = 0;
let startIndex = -1;
let inString = false;
let escapeNext = false;
for (let i = 0; i < currentBuffer.length; i++) {
const char = currentBuffer[i];
if (escapeNext) {
escapeNext = false;
continue;
}
if (char === '\\' && inString) {
escapeNext = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char === '{') {
if (bracketCount === 0) {
startIndex = i;
}
bracketCount++;
}
else if (char === '}') {
bracketCount--;
if (bracketCount === 0 && startIndex !== -1) {
// Found complete JSON object
const jsonStr = currentBuffer.substring(startIndex, i + 1);
objects.push(jsonStr);
startIndex = -1;
}
}
}
// Return remaining buffer starting from the last incomplete object
const remainingBuffer = startIndex !== -1 ? currentBuffer.substring(startIndex) : '';
return { objects, remainingBuffer };
}
parseStreamChunk(line) {
logger_1.logger.debug(` parseStreamChunk input:`, JSON.stringify(line));
// Remove "data: " prefix if present (for SSE format)
let cleanLine = line.replace(/^data:\s*/, '').trim();
logger_1.logger.debug(` After data: prefix removal:`, JSON.stringify(cleanLine));
// Handle common SSE termination signals
if (!cleanLine || cleanLine === '[DONE]' || cleanLine === 'data: [DONE]') {
logger_1.logger.debug(`⏹ Termination signal detected: "${cleanLine}"`);
return null;
}
// Handle potential comma-separated JSON objects (streaming JSON)
if (cleanLine.startsWith(',')) {
cleanLine = cleanLine.substring(1).trim();
logger_1.logger.debug(` Removed leading comma:`, JSON.stringify(cleanLine));
}
if (cleanLine.endsWith(',')) {
cleanLine = cleanLine.substring(0, cleanLine.length - 1).trim();
logger_1.logger.debug(` Removed trailing comma:`, JSON.stringify(cleanLine));
}
if (!cleanLine) {
logger_1.logger.debug(` Empty line after cleanup`);
return null;
}
try {
const parsed = JSON.parse(cleanLine);
logger_1.logger.debug(` Successfully parsed JSON:`, JSON.stringify(parsed, null, 2));
return parsed;
}
catch (error) {
logger_1.logger.warn(`❌ JSON parse error for line: "${cleanLine}"`, error);
return null;
}
}
hasThinkingContent(chunk) {
var _a;
return ((_a = chunk.candidates) === null || _a === void 0 ? void 0 : _a.some((candidate) => { var _a, _b; return (_b = (_a = candidate.content) === null || _a === void 0 ? void 0 : _a.parts) === null || _b === void 0 ? void 0 : _b.some((part) => { var _a; return 'text' in part && ((_a = part.text) === null || _a === void 0 ? void 0 : _a.includes('**')); }); })) || false;
}
extractThinkingContent(chunk) {
var _a;
for (const candidate of chunk.candidates || []) {
for (const part of ((_a = candidate.content) === null || _a === void 0 ? void 0 : _a.parts) || []) {
if ('text' in part && part.text) {
const thinkingMatch = part.text.match(/\*\*([\s\S]*?)\*\*/);
if (thinkingMatch) {
return thinkingMatch[1];
}
}
}
}
return null;
}
aggregateChunk(aggregated, chunk) {
var _a;
if (!chunk.candidates)
return;
for (let i = 0; i < chunk.candidates.length; i++) {
const candidate = chunk.candidates[i];
if (!aggregated.candidates[i]) {
aggregated.candidates[i] = {
content: { parts: [] },
index: i,
};
}
const aggregatedCandidate = aggregated.candidates[i];
if ((_a = candidate.content) === null || _a === void 0 ? void 0 : _a.parts) {
for (let j = 0; j < candidate.content.parts.length; j++) {
const part = candidate.content.parts[j];
if (!aggregatedCandidate.content.parts[j]) {
aggregatedCandidate.content.parts[j] = { text: '' };
}
if ('text' in part && part.text) {
const aggregatedPart = aggregatedCandidate.content.parts[j];
if ('text' in aggregatedPart) {
aggregatedPart.text += part.text;
}
}
}
}
if (candidate.finishReason) {
aggregatedCandidate.finishReason = candidate.finishReason;
}
if (candidate.safetyRatings) {
aggregatedCandidate.safetyRatings = candidate.safetyRatings;
}
}
if (chunk.usageMetadata) {
aggregated.usageMetadata = chunk.usageMetadata;
}
}
async *createAsyncGenerator(response) {
if (!response.body) {
throw new Error('Response body is empty');
}
logger_1.logger.debug(' Starting stream processing');
const headersObj = {};
response.headers.forEach((value, key) => {
headersObj[key] = value;
});
logger_1.logger.debug(' Response headers:', headersObj);
logger_1.logger.debug(' Content-Type:', response.headers.get('content-type'));
logger_1.logger.debug(' Transfer-Encoding:', response.headers.get('transfer-encoding'));
const reader = response.body.getReader();
let buffer = '';
let aggregatedText = '';
let thinkingOutput = '';
let model = '';
let usage = undefined;
let status = '';
let fallback = null;
let chunkCount = 0;
let totalBytesReceived = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
logger_1.logger.debug('Stream reading completed');
break;
}
totalBytesReceived += value.byteLength;
chunkCount++;
const rawChunk = this.decoder.decode(value, { stream: true });
logger_1.logger.debug(`Raw chunk #${chunkCount} (${value.byteLength} bytes):`, JSON.stringify(rawChunk));
buffer += rawChunk;
logger_1.logger.debug(` Current buffer length: ${buffer.length}`);
const { objects, remainingBuffer } = this.extractCompleteJsonObjects(buffer);
buffer = remainingBuffer;
logger_1.logger.debug(`Extracted ${objects.length} complete JSON objects, buffer remainder: ${buffer.length} chars`);
for (let i = 0; i < objects.length; i++) {
const jsonObject = objects[i];
logger_1.logger.debug(`rocessing JSON object ${i}:`, JSON.stringify(jsonObject));
try {
const chunk = JSON.parse(jsonObject);
if (!chunk) {
logger_1.logger.debug(` Failed to parse JSON object ${i}`);
continue;
}
logger_1.logger.debug(`Parsed chunk:`, JSON.stringify(chunk, null, 2));
if (chunk.candidates && chunk.candidates.length > 0) {
logger_1.logger.debug(` Processing ${chunk.candidates.length} candidates`);
for (let candidateIndex = 0; candidateIndex < chunk.candidates.length; candidateIndex++) {
const candidate = chunk.candidates[candidateIndex];
logger_1.logger.debug(` Processing candidate ${candidateIndex}:`, JSON.stringify(candidate, null, 2));
if (candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
logger_1.logger.debug(`Found ${candidate.content.parts.length} content parts`);
for (let partIndex = 0; partIndex < candidate.content.parts.length; partIndex++) {
const part = candidate.content.parts[partIndex];
logger_1.logger.debug(` Processing part ${partIndex}:`, JSON.stringify(part));
if ('text' in part && part.text && part.text.trim() !== '') {
logger_1.logger.debug(`💬 Yielding text token: "${part.text}"`);
aggregatedText += part.text;
const deltaResponse = {
type: 'delta',
token: part.text,
};
logger_1.logger.debug(` Yielding delta:`, JSON.stringify(deltaResponse));
yield deltaResponse;
}
else {
logger_1.logger.debug(` Skipping empty/whitespace part ${partIndex}`);
}
}
}
else {
logger_1.logger.debug(` No content parts in candidate ${candidateIndex}`);
}
if (candidate.finishReason) {
logger_1.logger.debug(` Finish reason found: ${candidate.finishReason}`);
}
}
const thinking = this.extractThinkingContent(chunk);
if (thinking) {
logger_1.logger.debug(` Thinking content extracted: "${thinking}"`);
thinkingOutput = thinking;
}
if (chunk.modelVersion) {
model = chunk.modelVersion;
logger_1.logger.debug(` Model version: ${model}`);
}
else if (chunk.responseId) {
model = chunk.responseId;
logger_1.logger.debug(` Response ID as model: ${model}`);
}
if (chunk.usageMetadata) {
usage = {
input_tokens: chunk.usageMetadata.promptTokenCount,
output_tokens: chunk.usageMetadata.candidatesTokenCount,
total_tokens: chunk.usageMetadata.totalTokenCount,
};
logger_1.logger.debug(` Usage metadata:`, JSON.stringify(usage));
}
}
else {
logger_1.logger.debug(` No candidates found in chunk`);
}
}
catch (error) {
logger_1.logger.warn(` Failed to process JSON object ${i}: "${jsonObject}"`, error);
}
}
}
if (buffer.trim()) {
logger_1.logger.debug(` Processing remaining buffer: ${buffer.length} chars`);
logger_1.logger.debug(` Buffer content: `, JSON.stringify(buffer));
try {
const chunk = JSON.parse(buffer.trim());
if (chunk && chunk.candidates && chunk.candidates.length > 0) {
logger_1.logger.debug(`✨ Final chunk parsed: `, JSON.stringify(chunk, null, 2));
for (const candidate of chunk.candidates) {
if (candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
for (const part of candidate.content.parts) {
if ('text' in part && part.text && part.text.trim() !== '') {
logger_1.logger.debug(`💬 Final text token: "${part.text}"`);
aggregatedText += part.text;
const deltaResponse = {
type: 'delta',
token: part.text,
};
yield deltaResponse;
}
}
}
}
const thinking = this.extractThinkingContent(chunk);
if (thinking) {
thinkingOutput = thinking;
}
if (chunk.usageMetadata) {
usage = {
input_tokens: chunk.usageMetadata.promptTokenCount,
output_tokens: chunk.usageMetadata.candidatesTokenCount,
total_tokens: chunk.usageMetadata.totalTokenCount,
};
}
}
}
catch (error) {
logger_1.logger.warn(`⚠️ Failed to parse final buffer content: `, error);
}
}
yield {
// for memic the response structure of openAI
type: 'done',
token: '',
completeOutput: aggregatedText,
thinkingOutput: '',
model: '',
usage: {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
},
status: '',
fallback: null,
};
const finalResponse = {
type: 'completed',
token: '',
completeOutput: aggregatedText,
thinkingOutput,
model,
usage,
status,
fallback,
};
logger_1.logger.debug(`🎉 Final completion response: `, JSON.stringify(finalResponse, null, 2));
logger_1.logger.debug(`📊 Stream summary: ${chunkCount} chunks, ${totalBytesReceived} bytes, aggregated text length: ${aggregatedText.length}`);
yield finalResponse;
}
catch (error) {
logger_1.logger.error(`💥 Stream processing error: `, error);
throw error;
}
finally {
reader.releaseLock();
logger_1.logger.debug(`🔒 Stream reader released`);
}
}
// Helper to extract all candidate content text from a StreamChunk
extractAllCandidateText(chunk) {
if (!chunk.candidates || chunk.candidates.length === 0)
return '';
let text = '';
for (const candidate of chunk.candidates) {
if (candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
for (const part of candidate.content.parts) {
if ('text' in part && part.text) {
text += part.text;
}
}
}
}
return text;
}
}
exports.GoogleStreamProcessor = GoogleStreamProcessor;
//# sourceMappingURL=google.stream.js.map