SmolLLM

LLM utility library

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greatest.deepsurf.us/scripts/528704/1549030/SmolLLM.js

  1. // ==UserScript==
  2. // @name SmolLLM
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.1.15
  5. // @description LLM utility library
  6. // @author RoCry
  7. // @require https://update.greatest.deepsurf.us/scripts/528703/1546610/SimpleBalancer.js
  8. // @license MIT
  9. // ==/UserScript==
  10.  
  11. class SmolLLM {
  12. constructor() {
  13. if (typeof SimpleBalancer === 'undefined') {
  14. throw new Error('SimpleBalancer is required for SmolLLM to work');
  15. }
  16.  
  17. this.balancer = new SimpleBalancer();
  18. this.logger = console;
  19. this.buffer = ''; // Buffer for incomplete SSE messages
  20. }
  21.  
  22. /**
  23. * Prepares request data based on the provider
  24. *
  25. * @param {string} prompt - User prompt
  26. * @param {string} systemPrompt - System prompt
  27. * @param {string} modelName - Model name
  28. * @param {string} providerName - Provider name (anthropic, openai, gemini)
  29. * @param {string} baseUrl - API base URL
  30. * @returns {Object} - {url, data} for the request
  31. */
  32. prepareRequestData(prompt, systemPrompt, modelName, providerName, baseUrl) {
  33. let url, data;
  34.  
  35. if (providerName === 'anthropic') {
  36. url = `${baseUrl}/v1/messages`;
  37. data = {
  38. model: modelName,
  39. max_tokens: 4096,
  40. messages: [{ role: 'user', content: prompt }],
  41. stream: true
  42. };
  43. if (systemPrompt) {
  44. data.system = systemPrompt;
  45. }
  46. } else if (providerName === 'gemini') {
  47. url = `${baseUrl}/v1beta/models/${modelName}:streamGenerateContent?alt=sse`;
  48. data = {
  49. contents: [{ parts: [{ text: prompt }] }]
  50. };
  51. if (systemPrompt) {
  52. data.system_instruction = { parts: [{ text: systemPrompt }] };
  53. }
  54. } else {
  55. // OpenAI compatible APIs
  56. const messages = [];
  57. if (systemPrompt) {
  58. messages.push({ role: 'system', content: systemPrompt });
  59. }
  60. messages.push({ role: 'user', content: prompt });
  61.  
  62. data = {
  63. messages: messages,
  64. model: modelName,
  65. stream: true
  66. };
  67.  
  68. // Handle URL based on suffix
  69. if (baseUrl.endsWith('#')) {
  70. url = baseUrl.slice(0, -1); // Remove the # and use exact URL
  71. } else if (baseUrl.endsWith('/')) {
  72. url = `${baseUrl}chat/completions`; // Skip v1 prefix
  73. } else {
  74. url = `${baseUrl}/v1/chat/completions`; // Default pattern
  75. }
  76. }
  77.  
  78. return { url, data };
  79. }
  80.  
  81. prepareHeaders(providerName, apiKey) {
  82. const headers = {
  83. 'Content-Type': 'application/json'
  84. };
  85.  
  86. if (providerName === 'anthropic') {
  87. headers['X-API-Key'] = apiKey;
  88. headers['Anthropic-Version'] = '2023-06-01';
  89. } else if (providerName === 'gemini') {
  90. headers['X-Goog-Api-Key'] = apiKey;
  91. } else {
  92. headers['Authorization'] = `Bearer ${apiKey}`;
  93. }
  94.  
  95. return headers;
  96. }
  97.  
  98. /**
  99. * Extract text content from a parsed JSON chunk
  100. *
  101. * @param {Object} data - Parsed JSON data
  102. * @param {string} providerName - Provider name
  103. * @returns {string|null} - Extracted text content or null
  104. */
  105. extractTextFromChunk(data, providerName) {
  106. try {
  107. if (providerName === 'gemini') {
  108. const candidates = data.candidates || [];
  109. if (candidates.length > 0 && candidates[0].content) {
  110. const parts = candidates[0].content.parts;
  111. if (parts && parts.length > 0) {
  112. return parts[0].text || '';
  113. }
  114. }
  115. return null;
  116. }
  117. if (providerName === 'anthropic') {
  118. // Handle content_block_delta which contains the actual text
  119. if (data.type === 'content_block_delta') {
  120. const delta = data.delta || {};
  121. if (delta.type === 'text_delta' || delta.text) {
  122. return delta.text || '';
  123. }
  124. }
  125. return null;
  126. }
  127. // OpenAI compatible format
  128. const choices = data.choices || [];
  129. // Skip if no choices or has filter results only
  130. if (choices.length === 0) {
  131. return null;
  132. }
  133. const choice = choices[0];
  134. // Check if this indicates end of stream
  135. if (choice.finish_reason) {
  136. return null;
  137. }
  138. // Extract content from delta
  139. if (choice.delta && choice.delta.content) {
  140. return choice.delta.content;
  141. }
  142. return null;
  143. } catch (e) {
  144. this.logger.error(`Error extracting text from chunk: ${e.message}`);
  145. return null;
  146. }
  147. }
  148.  
  149. /**
  150. * @returns {Promise<string>} - Full final response text
  151. */
  152. async askLLM({
  153. prompt,
  154. providerName,
  155. systemPrompt = '',
  156. model,
  157. apiKey,
  158. baseUrl,
  159. handler = null, // handler(delta, fullText)
  160. timeout = 60000
  161. }) {
  162. if (!prompt || !providerName || !model || !apiKey || !baseUrl) {
  163. throw new Error('Required parameters missing');
  164. }
  165.  
  166. // Use balancer to choose API key and base URL pair
  167. [apiKey, baseUrl] = this.balancer.choosePair(apiKey, baseUrl);
  168.  
  169. const { url, data } = this.prepareRequestData(
  170. prompt, systemPrompt, model, providerName, baseUrl
  171. );
  172.  
  173. const headers = this.prepareHeaders(providerName, apiKey);
  174.  
  175. // Log request info (with masked API key)
  176. const apiKeyPreview = `${apiKey.slice(0, 5)}...${apiKey.slice(-4)}`;
  177. this.logger.info(
  178. `[SmolLLM] Request: ${url} | model=${model} | provider=${providerName} | api_key=${apiKeyPreview} | prompt=${prompt.length}`
  179. );
  180.  
  181. // Create an AbortController for timeout handling
  182. const controller = new AbortController();
  183. const timeoutId = setTimeout(() => {
  184. controller.abort();
  185. }, timeout);
  186.  
  187. try {
  188. const response = await fetch(url, {
  189. method: 'POST',
  190. headers: headers,
  191. body: JSON.stringify(data),
  192. signal: controller.signal
  193. });
  194.  
  195. if (!response.ok) {
  196. throw new Error(`HTTP error ${response.status}: ${await response.text() || 'Unknown error'}`);
  197. }
  198.  
  199. // Reset buffer before starting new stream processing
  200. this.buffer = '';
  201. // Handle streaming response
  202. const reader = response.body.getReader();
  203. const decoder = new TextDecoder();
  204. let fullText = '';
  205.  
  206. while (true) {
  207. const { done, value } = await reader.read();
  208. if (done) break;
  209. const chunk = decoder.decode(value, { stream: true });
  210. const deltas = this.processStreamChunks(chunk, providerName);
  211. for (const delta of deltas) {
  212. if (delta) {
  213. fullText += delta;
  214. if (handler) handler(delta, fullText);
  215. }
  216. }
  217. }
  218.  
  219. // Process any remaining buffer content
  220. if (this.buffer) {
  221. this.logger.log(`Processing remaining buffer: ${this.buffer}`);
  222. const deltas = this.processStreamChunks('\n', providerName); // Force processing any remaining buffer
  223. for (const delta of deltas) {
  224. if (delta) {
  225. fullText += delta;
  226. if (handler) handler(delta, fullText);
  227. }
  228. }
  229. }
  230.  
  231. clearTimeout(timeoutId);
  232. return fullText;
  233. } catch (error) {
  234. clearTimeout(timeoutId);
  235. if (error.name === 'AbortError') {
  236. throw new Error(`Request timed out after ${timeout}ms`);
  237. }
  238. throw error;
  239. }
  240. }
  241.  
  242. /**
  243. * Process stream chunks and extract text content
  244. *
  245. * @param {string} chunk - Raw stream chunk
  246. * @param {string} providerName - Provider name
  247. * @returns {Array<string>} - Array of extracted text deltas
  248. */
  249. processStreamChunks(chunk, providerName) {
  250. const deltas = [];
  251. // Add chunk to buffer
  252. this.buffer += chunk;
  253. // Split buffer by newlines
  254. const lines = this.buffer.split('\n');
  255. // Keep the last line in the buffer (might be incomplete)
  256. this.buffer = lines.pop() || '';
  257. for (const line of lines) {
  258. const trimmed = line.trim();
  259. if (!trimmed) continue;
  260. // Check for SSE data prefix
  261. if (trimmed.startsWith('data: ')) {
  262. const data = trimmed.slice(6).trim();
  263. // Skip [DONE] marker
  264. if (data === '[DONE]') continue;
  265. try {
  266. // Parse JSON data
  267. const jsonData = JSON.parse(data);
  268. // Extract text content
  269. const delta = this.extractTextFromChunk(jsonData, providerName);
  270. if (delta) {
  271. deltas.push(delta);
  272. }
  273. } catch (e) {
  274. // Log JSON parse errors but continue processing
  275. if (e instanceof SyntaxError) {
  276. this.logger.log(`Incomplete or invalid JSON: ${data}`);
  277. } else {
  278. this.logger.error(`Error processing chunk: ${e.message}, chunk: ${data}`);
  279. }
  280. }
  281. }
  282. }
  283. return deltas;
  284. }
  285. }
  286.  
  287. // Make it available globally
  288. window.SmolLLM = SmolLLM;
  289.  
  290. // Export for module systems if needed
  291. if (typeof module !== 'undefined') {
  292. module.exports = SmolLLM;
  293. }