call-ai
Version:
Lightweight library for making AI API calls with streaming support
365 lines • 18.5 kB
JavaScript
import { CallAIError } from "./types.js";
import { globalDebug } from "./key-management.js";
import { responseMetadata, boxString } from "./response-metadata.js";
import { checkForInvalidModelError } from "./error-handling.js";
import { PACKAGE_VERSION, FALLBACK_MODEL } from "./non-streaming.js";
async function* createStreamingGenerator(response, options, schemaStrategy, model) {
const meta = {
model,
endpoint: options.endpoint || "https://openrouter.ai/api/v1",
timing: {
startTime: Date.now(),
endTime: 0,
duration: 0,
},
};
let toolCallsAssembled = "";
let completeText = "";
let chunkCount = 0;
if (options.debug || globalDebug) {
console.log(`[callAi:${PACKAGE_VERSION}] Starting streaming generator with model: ${model}`);
}
try {
const reader = response.body?.getReader();
if (!reader) {
throw new Error("Response body is undefined - API endpoint may not support streaming");
}
const textDecoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
if (options.debug || globalDebug) {
console.log(`[callAi-streaming:complete v${PACKAGE_VERSION}] Stream finished after ${chunkCount} chunks`);
}
break;
}
const chunk = textDecoder.decode(value, { stream: true });
buffer += chunk;
const messages = buffer.split(/\n\n/);
buffer = messages.pop() || "";
for (const message of messages) {
if (!message.trim() || !message.startsWith("data: ")) {
continue;
}
const jsonStr = message.slice(6);
if (jsonStr === "[DONE]") {
if (options.debug || globalDebug) {
console.log(`[callAi:${PACKAGE_VERSION}] Received [DONE] signal`);
}
continue;
}
chunkCount++;
try {
const json = JSON.parse(jsonStr);
if (json.error ||
json.type === "error" ||
(json.choices && json.choices.length > 0 && json.choices[0].finish_reason === "error")) {
const errorMessage = json.error?.message || json.error || json.choices?.[0]?.message?.content || "Unknown streaming error";
if (options.debug || globalDebug) {
console.error(`[callAi:${PACKAGE_VERSION}] Detected error in streaming response:`, json);
}
const detailedError = new CallAIError({
message: `API streaming error: ${errorMessage}`,
status: json.error?.status || 400,
statusText: json.error?.type || "Bad Request",
details: JSON.stringify(json.error || json),
contentType: "application/json",
});
console.error(`[callAi:${PACKAGE_VERSION}] Throwing stream error:`, detailedError);
throw detailedError;
}
const isClaudeWithSchema = /claude/i.test(model) && schemaStrategy.strategy === "tool_mode";
if (isClaudeWithSchema) {
if (json.choices && json.choices.length > 0) {
const choice = json.choices[0];
if (choice.finish_reason === "tool_calls") {
if (options.debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Received tool_calls finish reason. Assembled JSON:`, toolCallsAssembled);
}
try {
if (toolCallsAssembled) {
try {
JSON.parse(toolCallsAssembled);
}
catch (parseError) {
if (options.debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Attempting to fix malformed JSON in tool call:`, toolCallsAssembled);
}
let fixedJson = toolCallsAssembled;
fixedJson = fixedJson.replace(/,\s*([\}\]])/, "$1");
const openBraces = (fixedJson.match(/\{/g) || []).length;
const closeBraces = (fixedJson.match(/\}/g) || []).length;
if (openBraces > closeBraces) {
fixedJson += "}".repeat(openBraces - closeBraces);
}
if (!fixedJson.trim().startsWith("{")) {
fixedJson = "{" + fixedJson.trim();
}
if (!fixedJson.trim().endsWith("}")) {
fixedJson += "}";
}
fixedJson = fixedJson.replace(/"(\w+)"\s*:\s*$/g, '"$1":null');
fixedJson = fixedJson.replace(/"(\w+)"\s*:\s*,/g, '"$1":null,');
fixedJson = fixedJson.replace(/"(\w+)"\s*:\s*"(\w+)$/g, '"$1$2"');
const openBrackets = (fixedJson.match(/\[/g) || []).length;
const closeBrackets = (fixedJson.match(/\]/g) || []).length;
if (openBrackets > closeBrackets) {
fixedJson += "]".repeat(openBrackets - closeBrackets);
}
if (options.debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Applied comprehensive JSON fixes:`, `\nBefore: ${toolCallsAssembled}`, `\nAfter: ${fixedJson}`);
}
toolCallsAssembled = fixedJson;
}
}
completeText = toolCallsAssembled;
yield completeText;
continue;
}
catch (e) {
console.error("[callAIStreaming] Error handling assembled tool call:", e);
}
}
if (choice && choice.delta && choice.delta.tool_calls) {
const toolCall = choice.delta.tool_calls[0];
if (toolCall && toolCall.function && toolCall.function.arguments !== undefined) {
toolCallsAssembled += toolCall.function.arguments;
if (options.debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Accumulated tool call chunk:`, toolCall.function.arguments);
}
}
}
}
}
if (isClaudeWithSchema && (json.stop_reason === "tool_use" || json.type === "tool_use")) {
if (json.type === "tool_use") {
completeText = schemaStrategy.processResponse(json);
yield completeText;
continue;
}
if (json.content && Array.isArray(json.content)) {
const toolUseBlock = json.content.find((block) => block.type === "tool_use");
if (toolUseBlock) {
completeText = schemaStrategy.processResponse(toolUseBlock);
yield completeText;
continue;
}
}
if (json.choices && Array.isArray(json.choices)) {
const choice = json.choices[0];
if (choice.message && Array.isArray(choice.message.content)) {
const toolUseBlock = choice.message.content.find((block) => block.type === "tool_use");
if (toolUseBlock) {
completeText = schemaStrategy.processResponse(toolUseBlock);
yield completeText;
continue;
}
}
if (choice.delta && Array.isArray(choice.delta.content)) {
const toolUseBlock = choice.delta.content.find((block) => block.type === "tool_use");
if (toolUseBlock) {
completeText = schemaStrategy.processResponse(toolUseBlock);
yield completeText;
continue;
}
}
}
}
if (json.choices?.[0]?.delta?.content !== undefined) {
const content = json.choices[0].delta.content || "";
completeText += content;
yield schemaStrategy.processResponse(completeText);
}
// Handle message content format (non-streaming deltas)
else if (json.choices?.[0]?.message?.content !== undefined) {
const content = json.choices[0].message.content || "";
completeText += content;
yield schemaStrategy.processResponse(completeText);
}
// Handle content blocks for Claude/Anthropic response format
else if (json.choices?.[0]?.message?.content && Array.isArray(json.choices[0].message.content)) {
const contentBlocks = json.choices[0].message.content;
for (const block of contentBlocks) {
if (block.type === "text") {
completeText += block.text || "";
}
else if (isClaudeWithSchema && block.type === "tool_use") {
completeText = schemaStrategy.processResponse(block);
break;
}
}
yield schemaStrategy.processResponse(completeText);
}
if (json.type === "content_block_delta" && json.delta && json.delta.type === "text_delta" && json.delta.text) {
if (options.debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Received text delta:`, json.delta.text);
}
completeText += json.delta.text;
if (!isClaudeWithSchema) {
yield schemaStrategy.processResponse(completeText);
}
}
}
catch (e) {
if (options.debug) {
console.error(`[callAIStreaming] Error parsing JSON chunk:`, e);
}
}
}
}
if (toolCallsAssembled && (!completeText || completeText.length === 0)) {
let result = toolCallsAssembled;
try {
JSON.parse(result);
}
catch (e) {
if (options.debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Final JSON validation failed:`, e, `\nAttempting to fix JSON:`, result);
}
result = result.replace(/,\s*([\}\]])/, "$1");
const openBraces = (result.match(/\{/g) || []).length;
const closeBraces = (result.match(/\}/g) || []).length;
if (openBraces > closeBraces) {
result += "}".repeat(openBraces - closeBraces);
}
if (!result.trim().startsWith("{")) {
result = "{" + result.trim();
}
if (!result.trim().endsWith("}")) {
result += "}";
}
result = result.replace(/"(\w+)"\s*:\s*$/g, '"$1":null');
result = result.replace(/"(\w+)"\s*:\s*,/g, '"$1":null,');
const openBrackets = (result.match(/\[/g) || []).length;
const closeBrackets = (result.match(/\]/g) || []).length;
if (openBrackets > closeBrackets) {
result += "]".repeat(openBrackets - closeBrackets);
}
if (options.debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Applied final JSON fixes:`, result);
}
}
completeText = result;
try {
JSON.parse(completeText);
}
catch (finalParseError) {
if (options.debug) {
console.error(`[callAi:${PACKAGE_VERSION}] Final JSON validation still failed:`, finalParseError);
}
}
yield completeText;
}
const endTime = Date.now();
meta.timing.endTime = endTime;
meta.timing.duration = endTime - meta.timing.startTime;
meta.rawResponse = completeText;
const boxed = boxString(completeText);
responseMetadata.set(boxed, meta);
return completeText;
}
catch (error) {
if (options.debug || globalDebug) {
console.error(`[callAi:${PACKAGE_VERSION}] Streaming error:`, error);
}
throw error;
}
}
async function* callAIStreaming(prompt, options = {}, isRetry = false) {
const messages = Array.isArray(prompt) ? prompt : [{ role: "user", content: prompt }];
const apiKey = options.apiKey;
const model = options.model || "openai/gpt-3.5-turbo";
const endpoint = options.endpoint || "https://openrouter.ai/api/v1";
const url = `${endpoint}/chat/completions`;
const schemaStrategy = options.schemaStrategy;
const responseFormat = options.responseFormat || /gpt-4/.test(model) || /gpt-3.5/.test(model) ? "json" : undefined;
const debug = options.debug === undefined ? globalDebug : options.debug;
if (debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Making streaming request to: ${url}`);
console.log(`[callAi:${PACKAGE_VERSION}] With model: ${model}`);
}
const requestBody = {
model,
messages,
max_tokens: options.maxTokens || 2048,
temperature: options.temperature !== undefined ? options.temperature : 0.7,
top_p: options.topP !== undefined ? options.topP : 1,
stream: true,
};
if (responseFormat === "json") {
requestBody.response_format = { type: "json_object" };
}
if (options.schema) {
Object.assign(requestBody, schemaStrategy?.prepareRequest(options.schema, messages));
}
const headers = {
Authorization: `Bearer ${apiKey}`,
"HTTP-Referer": options.referer || "https://vibes.diy",
"X-Title": options.title || "Vibes",
"Content-Type": "application/json",
};
if (options.headers) {
Object.assign(headers, options.headers);
}
Object.keys(options).forEach((key) => {
if (![
"apiKey",
"model",
"endpoint",
"stream",
"schema",
"maxTokens",
"temperature",
"topP",
"responseFormat",
"referer",
"title",
"headers",
"skipRefresh",
"debug",
].includes(key)) {
requestBody[key] = options[key];
}
});
if (debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Request headers:`, headers);
console.log(`[callAi:${PACKAGE_VERSION}] Request body:`, requestBody);
}
let response;
try {
response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const { isInvalidModel, errorData } = await checkForInvalidModelError(response, model, debug);
if (isInvalidModel && !isRetry && !options.skipRetry) {
if (debug) {
console.log(`[callAi:${PACKAGE_VERSION}] Invalid model "${model}", falling back to "${FALLBACK_MODEL}"`);
}
yield* callAIStreaming(prompt, {
...options,
model: FALLBACK_MODEL,
}, true);
return "";
}
const errorText = errorData ? JSON.stringify(errorData) : `HTTP error! Status: ${response.status}`;
throw new Error(errorText);
}
if (!schemaStrategy) {
throw new Error("Schema strategy is required for streaming");
}
yield* createStreamingGenerator(response, options, schemaStrategy, model);
return "";
}
catch (fetchError) {
if (debug) {
console.error(`[callAi:${PACKAGE_VERSION}] Network error during fetch:`, fetchError);
}
throw fetchError;
}
}
export { createStreamingGenerator, callAIStreaming };
//# sourceMappingURL=streaming.js.map