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.
297 lines • 11.8 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.StreamProcessor = exports.OpenAIStreamProcessor = void 0;
class OpenAIStreamProcessor {
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 });
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim()) {
try {
const openAIChunk = this.parseOpenAIStreamChunk(line);
if (openAIChunk) {
// Handle reasoning content
if (this.hasReasoningContent(openAIChunk)) {
const reasoning = this.extractReasoningContent(openAIChunk);
if (reasoning && options.onReasoning) {
options.onReasoning(reasoning);
}
}
// Handle thinking content (from JSON schema responses)
if (this.hasThinkingContent(openAIChunk)) {
const thinking = this.extractThinkingContent(openAIChunk);
if (thinking && options.onThinking) {
options.onThinking(thinking);
}
}
// Convert to Gemini format and aggregate
const geminiChunk = this.convertToGeminiChunk(openAIChunk);
this.aggregateChunk(aggregatedResponse, geminiChunk);
// Call chunk handler with converted chunk
if (options.onChunk) {
options.onChunk(geminiChunk);
}
}
}
catch (error) {
console.warn('Failed to parse stream chunk:', line, error);
}
}
}
}
// Process any remaining buffer content
if (buffer.trim()) {
try {
const openAIChunk = this.parseOpenAIStreamChunk(buffer);
if (openAIChunk) {
const geminiChunk = this.convertToGeminiChunk(openAIChunk);
this.aggregateChunk(aggregatedResponse, geminiChunk);
if (options.onChunk) {
options.onChunk(geminiChunk);
}
}
}
catch (error) {
console.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();
}
}
parseOpenAIStreamChunk(line) {
// Remove "data: " prefix if present
const cleanLine = line.replace(/^data:\s*/, '').trim();
if (!cleanLine || cleanLine === '[DONE]') {
return null;
}
try {
return JSON.parse(cleanLine);
}
catch (error) {
// Some lines might not be valid JSON
return null;
}
}
convertToGeminiChunk(openAIChunk) {
const geminiChunk = {
candidates: [],
};
if (openAIChunk.choices) {
geminiChunk.candidates = openAIChunk.choices.map(choice => {
var _a, _b;
return ({
content: {
role: 'model',
parts: [{
text: ((_a = choice.delta) === null || _a === void 0 ? void 0 : _a.content) || ((_b = choice.message) === null || _b === void 0 ? void 0 : _b.content) || ''
}]
},
finishReason: choice.finish_reason,
index: choice.index
});
});
}
if (openAIChunk.usage) {
geminiChunk.usageMetadata = {
promptTokenCount: openAIChunk.usage.prompt_tokens,
candidatesTokenCount: openAIChunk.usage.completion_tokens,
totalTokenCount: openAIChunk.usage.total_tokens
};
}
return geminiChunk;
}
hasReasoningContent(chunk) {
return chunk.type === 'reasoning' || !!chunk.reasoning;
}
extractReasoningContent(chunk) {
return chunk.reasoning;
}
hasThinkingContent(chunk) {
var _a;
// Check if the content contains JSON with thinking field
return ((_a = chunk.choices) === null || _a === void 0 ? void 0 : _a.some(choice => {
var _a, _b;
const content = ((_a = choice.delta) === null || _a === void 0 ? void 0 : _a.content) || ((_b = choice.message) === null || _b === void 0 ? void 0 : _b.content);
if (!content)
return false;
try {
const parsed = JSON.parse(content);
return parsed.thinking !== undefined;
}
catch (_c) {
return content.includes('"thinking"') || content.includes('thinking:');
}
})) || false;
}
extractThinkingContent(chunk) {
var _a, _b;
for (const choice of chunk.choices || []) {
const content = ((_a = choice.delta) === null || _a === void 0 ? void 0 : _a.content) || ((_b = choice.message) === null || _b === void 0 ? void 0 : _b.content);
if (!content)
continue;
try {
const parsed = JSON.parse(content);
if (parsed.thinking) {
return parsed.thinking;
}
}
catch (_c) {
// Try to extract thinking from partial JSON or text
const thinkingMatch = content.match(/"thinking":\s*"([^"]*)"/) ||
content.match(/thinking:\s*(.+?)(?:,|\}|$)/);
if (thinkingMatch) {
return thinkingMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"');
}
}
}
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) {
aggregated.candidates = [];
}
if (!aggregated.candidates[i]) {
aggregated.candidates[i] = {
content: { parts: [] },
index: i,
};
}
const aggregatedCandidate = aggregated.candidates[i];
// Merge content parts
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');
}
const reader = response.body.getReader();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
buffer += this.decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
const openAIChunk = this.parseOpenAIStreamChunk(line);
if (openAIChunk) {
const geminiChunk = this.convertToGeminiChunk(openAIChunk);
yield geminiChunk;
}
}
}
}
if (buffer.trim()) {
const openAIChunk = this.parseOpenAIStreamChunk(buffer);
if (openAIChunk) {
const geminiChunk = this.convertToGeminiChunk(openAIChunk);
yield geminiChunk;
}
}
}
finally {
reader.releaseLock();
}
}
// OpenAI-specific method to handle raw OpenAI chunks without conversion
async *createOpenAIAsyncGenerator(response) {
if (!response.body) {
throw new Error('Response body is empty');
}
const reader = response.body.getReader();
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done)
break;
buffer += this.decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim()) {
const chunk = this.parseOpenAIStreamChunk(line);
if (chunk) {
yield chunk;
}
}
}
}
if (buffer.trim()) {
const chunk = this.parseOpenAIStreamChunk(buffer);
if (chunk) {
yield chunk;
}
}
}
finally {
reader.releaseLock();
}
}
}
exports.OpenAIStreamProcessor = OpenAIStreamProcessor;
// Legacy compatibility - extend the original StreamProcessor
class StreamProcessor extends OpenAIStreamProcessor {
}
exports.StreamProcessor = StreamProcessor;
//# sourceMappingURL=openai.stream.js.map