UNPKG

call-ai

Version:

Lightweight library for making AI API calls with streaming support

365 lines 18.5 kB
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