Greasy Fork is available in English.

SmolLLM

LLM utility library

Dette scriptet burde ikke installeres direkte. Det er et bibliotek for andre script å inkludere med det nye metadirektivet // @require https://update.greasyfork.org/scripts/528704/1549030/SmolLLM.js

// ==UserScript==
// @name         SmolLLM
// @namespace    http://tampermonkey.net/
// @version      0.1.15
// @description  LLM utility library
// @author       RoCry
// @require https://update.greasyfork.org/scripts/528703/1546610/SimpleBalancer.js
// @license MIT
// ==/UserScript==

class SmolLLM {
  constructor() {
    if (typeof SimpleBalancer === 'undefined') {
      throw new Error('SimpleBalancer is required for SmolLLM to work');
    }

    this.balancer = new SimpleBalancer();
    this.logger = console;
    this.buffer = ''; // Buffer for incomplete SSE messages
  }

  /**
   * Prepares request data based on the provider
   * 
   * @param {string} prompt - User prompt
   * @param {string} systemPrompt - System prompt 
   * @param {string} modelName - Model name
   * @param {string} providerName - Provider name (anthropic, openai, gemini)
   * @param {string} baseUrl - API base URL
   * @returns {Object} - {url, data} for the request
   */
  prepareRequestData(prompt, systemPrompt, modelName, providerName, baseUrl) {
    let url, data;

    if (providerName === 'anthropic') {
      url = `${baseUrl}/v1/messages`;
      data = {
        model: modelName,
        max_tokens: 4096,
        messages: [{ role: 'user', content: prompt }],
        stream: true
      };
      if (systemPrompt) {
        data.system = systemPrompt;
      }
    } else if (providerName === 'gemini') {
      url = `${baseUrl}/v1beta/models/${modelName}:streamGenerateContent?alt=sse`;
      data = {
        contents: [{ parts: [{ text: prompt }] }]
      };
      if (systemPrompt) {
        data.system_instruction = { parts: [{ text: systemPrompt }] };
      }
    } else {
      // OpenAI compatible APIs
      const messages = [];
      if (systemPrompt) {
        messages.push({ role: 'system', content: systemPrompt });
      }
      messages.push({ role: 'user', content: prompt });

      data = {
        messages: messages,
        model: modelName,
        stream: true
      };

      // Handle URL based on suffix
      if (baseUrl.endsWith('#')) {
        url = baseUrl.slice(0, -1); // Remove the # and use exact URL
      } else if (baseUrl.endsWith('/')) {
        url = `${baseUrl}chat/completions`; // Skip v1 prefix
      } else {
        url = `${baseUrl}/v1/chat/completions`; // Default pattern
      }
    }

    return { url, data };
  }

  prepareHeaders(providerName, apiKey) {
    const headers = {
      'Content-Type': 'application/json'
    };

    if (providerName === 'anthropic') {
      headers['X-API-Key'] = apiKey;
      headers['Anthropic-Version'] = '2023-06-01';
    } else if (providerName === 'gemini') {
      headers['X-Goog-Api-Key'] = apiKey;
    } else {
      headers['Authorization'] = `Bearer ${apiKey}`;
    }

    return headers;
  }

  /**
   * Extract text content from a parsed JSON chunk
   * 
   * @param {Object} data - Parsed JSON data
   * @param {string} providerName - Provider name
   * @returns {string|null} - Extracted text content or null
   */
  extractTextFromChunk(data, providerName) {
    try {
      if (providerName === 'gemini') {
        const candidates = data.candidates || [];
        if (candidates.length > 0 && candidates[0].content) {
          const parts = candidates[0].content.parts;
          if (parts && parts.length > 0) {
            return parts[0].text || '';
          }
        }
        return null;
      } 
      
      if (providerName === 'anthropic') {
        // Handle content_block_delta which contains the actual text
        if (data.type === 'content_block_delta') {
          const delta = data.delta || {};
          if (delta.type === 'text_delta' || delta.text) {
            return delta.text || '';
          }
        }
        return null;
      } 
      
      // OpenAI compatible format
      const choices = data.choices || [];
      
      // Skip if no choices or has filter results only
      if (choices.length === 0) {
        return null;
      }
      
      const choice = choices[0];
      
      // Check if this indicates end of stream
      if (choice.finish_reason) {
        return null;
      }
      
      // Extract content from delta
      if (choice.delta && choice.delta.content) {
        return choice.delta.content;
      }
      
      return null;
    } catch (e) {
      this.logger.error(`Error extracting text from chunk: ${e.message}`);
      return null;
    }
  }

  /**
   * @returns {Promise<string>} - Full final response text
   */
  async askLLM({
    prompt,
    providerName,
    systemPrompt = '',
    model,
    apiKey,
    baseUrl,
    handler = null,  // handler(delta, fullText)
    timeout = 60000
  }) {
    if (!prompt || !providerName || !model || !apiKey || !baseUrl) {
      throw new Error('Required parameters missing');
    }

    // Use balancer to choose API key and base URL pair
    [apiKey, baseUrl] = this.balancer.choosePair(apiKey, baseUrl);

    const { url, data } = this.prepareRequestData(
      prompt, systemPrompt, model, providerName, baseUrl
    );

    const headers = this.prepareHeaders(providerName, apiKey);

    // Log request info (with masked API key)
    const apiKeyPreview = `${apiKey.slice(0, 5)}...${apiKey.slice(-4)}`;
    this.logger.info(
      `[SmolLLM] Request: ${url} | model=${model} | provider=${providerName} | api_key=${apiKeyPreview} | prompt=${prompt.length}`
    );

    // Create an AbortController for timeout handling
    const controller = new AbortController();
    const timeoutId = setTimeout(() => {
      controller.abort();
    }, timeout);

    try {
      const response = await fetch(url, {
        method: 'POST',
        headers: headers,
        body: JSON.stringify(data),
        signal: controller.signal
      });

      if (!response.ok) {
        throw new Error(`HTTP error ${response.status}: ${await response.text() || 'Unknown error'}`);
      }

      // Reset buffer before starting new stream processing
      this.buffer = '';
      
      // Handle streaming response
      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let fullText = '';

      while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        
        const chunk = decoder.decode(value, { stream: true });
        const deltas = this.processStreamChunks(chunk, providerName);
        
        for (const delta of deltas) {
          if (delta) {
            fullText += delta;
            if (handler) handler(delta, fullText);
          }
        }
      }

      // Process any remaining buffer content
      if (this.buffer) {
        this.logger.log(`Processing remaining buffer: ${this.buffer}`);
        const deltas = this.processStreamChunks('\n', providerName); // Force processing any remaining buffer
        
        for (const delta of deltas) {
          if (delta) {
            fullText += delta;
            if (handler) handler(delta, fullText);
          }
        }
      }

      clearTimeout(timeoutId);
      return fullText;
    } catch (error) {
      clearTimeout(timeoutId);
      if (error.name === 'AbortError') {
        throw new Error(`Request timed out after ${timeout}ms`);
      }
      throw error;
    }
  }

  /**
   * Process stream chunks and extract text content
   * 
   * @param {string} chunk - Raw stream chunk
   * @param {string} providerName - Provider name
   * @returns {Array<string>} - Array of extracted text deltas
   */
  processStreamChunks(chunk, providerName) {
    const deltas = [];
    
    // Add chunk to buffer
    this.buffer += chunk;
    
    // Split buffer by newlines
    const lines = this.buffer.split('\n');
    
    // Keep the last line in the buffer (might be incomplete)
    this.buffer = lines.pop() || '';
    
    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed) continue;
      
      // Check for SSE data prefix
      if (trimmed.startsWith('data: ')) {
        const data = trimmed.slice(6).trim();
        
        // Skip [DONE] marker
        if (data === '[DONE]') continue;
        
        try {
          // Parse JSON data
          const jsonData = JSON.parse(data);
          
          // Extract text content
          const delta = this.extractTextFromChunk(jsonData, providerName);
          if (delta) {
            deltas.push(delta);
          }
        } catch (e) {
          // Log JSON parse errors but continue processing
          if (e instanceof SyntaxError) {
            this.logger.log(`Incomplete or invalid JSON: ${data}`);
          } else {
            this.logger.error(`Error processing chunk: ${e.message}, chunk: ${data}`);
          }
        }
      }
    }
    
    return deltas;
  }
}

// Make it available globally
window.SmolLLM = SmolLLM;

// Export for module systems if needed
if (typeof module !== 'undefined') {
  module.exports = SmolLLM;
}