|
| 1 | +/* |
| 2 | + * Copyright (C) 2024-present Puter Technologies Inc. |
| 3 | + * |
| 4 | + * This file is part of Puter. |
| 5 | + * |
| 6 | + * Puter is free software: you can redistribute it and/or modify |
| 7 | + * it under the terms of the GNU Affero General Public License as published |
| 8 | + * by the Free Software Foundation, either version 3 of the License, or |
| 9 | + * (at your option) any later version. |
| 10 | + * |
| 11 | + * This program is distributed in the hope that it will be useful, |
| 12 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 13 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 14 | + * GNU Affero General Public License for more details. |
| 15 | + * |
| 16 | + * You should have received a copy of the GNU Affero General Public License |
| 17 | + * along with this program. If not, see <https://www.gnu.org/licenses/>. |
| 18 | + */ |
| 19 | + |
| 20 | +// METADATA // {"ai-commented":{"service":"claude"}} |
| 21 | +const APIError = require('../../api/APIError'); |
| 22 | +const BaseService = require('../../services/BaseService'); |
| 23 | +const OpenAIUtil = require('./lib/OpenAIUtil'); |
| 24 | +const { Context } = require('../../util/context'); |
| 25 | + |
| 26 | +/** |
| 27 | +* OllamaService class - Provides integration with Ollama's API for chat completions |
| 28 | +* Extends BaseService to implement the puter-chat-completion interface. |
| 29 | +* Handles model management, message adaptation, streaming responses, |
| 30 | +* and usage tracking for Ollama's language models. |
| 31 | +* @extends BaseService |
| 32 | +*/ |
| 33 | +class OllamaService extends BaseService { |
| 34 | + static MODULES = { |
| 35 | + openai: require('openai'), |
| 36 | + kv: globalThis.kv, |
| 37 | + uuidv4: require('uuid').v4, |
| 38 | + axios: require('axios'), |
| 39 | + }; |
| 40 | + |
| 41 | + /** |
| 42 | + * Gets the system prompt used for AI interactions |
| 43 | + * @returns {string} The base system prompt that identifies the AI as running on Puter |
| 44 | + */ |
| 45 | + adapt_model(model) { |
| 46 | + return model; |
| 47 | + } |
| 48 | + |
| 49 | + /** |
| 50 | + * Initializes the Ollama service by setting up the Ollama client and registering with the AI chat provider |
| 51 | + * @private |
| 52 | + * @returns {Promise<void>} Resolves when initialization is complete |
| 53 | + */ |
| 54 | + async _init() { |
| 55 | + // Ollama typically runs on HTTP, not HTTPS |
| 56 | + this.api_base_url = this.config?.api_base_url || 'http://localhost:11434'; |
| 57 | + |
| 58 | + // OpenAI SDK is used to interact with the Ollama API |
| 59 | + this.openai = new this.modules.openai.OpenAI({ |
| 60 | + apiKey: "ollama", // Ollama doesn't use an API key, it uses the "ollama" string |
| 61 | + baseURL: this.api_base_url + '/v1', |
| 62 | + }); |
| 63 | + this.kvkey = this.modules.uuidv4(); |
| 64 | + |
| 65 | + const svc_aiChat = this.services.get('ai-chat'); |
| 66 | + svc_aiChat.register_provider({ |
| 67 | + service_name: this.service_name, |
| 68 | + alias: true, |
| 69 | + }); |
| 70 | + // We don't need to meter usage for Ollama because it's a local service |
| 71 | + } |
| 72 | + |
| 73 | + /** |
| 74 | + * Returns the default model identifier for the Ollama service |
| 75 | + * @returns {string} The default model ID 'gpt-oss:20b' |
| 76 | + */ |
| 77 | + get_default_model() { |
| 78 | + return 'gpt-oss:20b'; |
| 79 | + } |
| 80 | + |
| 81 | + static IMPLEMENTS = { |
| 82 | + 'puter-chat-completion': { |
| 83 | + /** |
| 84 | + * Returns a list of available models and their details. |
| 85 | + * See AIChatService for more information. |
| 86 | + * |
| 87 | + * @returns Promise<Array<Object>> Array of model details |
| 88 | + */ |
| 89 | + async models() { |
| 90 | + return await this.models_(); |
| 91 | + }, |
| 92 | + /** |
| 93 | + * Returns a list of available model names including their aliases |
| 94 | + * @returns {Promise<string[]>} Array of model identifiers and their aliases |
| 95 | + * @description Retrieves all available model IDs and their aliases, |
| 96 | + * flattening them into a single array of strings that can be used for model selection |
| 97 | + */ |
| 98 | + async list() { |
| 99 | + const models = await this.models_(); |
| 100 | + const model_names = []; |
| 101 | + for ( const model of models ) { |
| 102 | + model_names.push(model.id); |
| 103 | + } |
| 104 | + return model_names; |
| 105 | + }, |
| 106 | + |
| 107 | + /** |
| 108 | + * AI Chat completion method. |
| 109 | + * See AIChatService for more details. |
| 110 | + */ |
| 111 | + async complete({ messages, stream, model, tools, max_tokens, temperature }) { |
| 112 | + model = this.adapt_model(model); |
| 113 | + |
| 114 | + if ( model.startsWith('ollama:') ) { |
| 115 | + model = model.slice('ollama:'.length); |
| 116 | + } |
| 117 | + |
| 118 | + const actor = Context.get('actor'); |
| 119 | + |
| 120 | + messages = await OpenAIUtil.process_input_messages(messages); |
| 121 | + const sdk_params = { |
| 122 | + messages, |
| 123 | + model: model ?? this.get_default_model(), |
| 124 | + ...(tools ? { tools } : {}), |
| 125 | + max_tokens, |
| 126 | + temperature: temperature, // default to 1.0 |
| 127 | + stream, |
| 128 | + ...(stream ? { |
| 129 | + stream_options: { include_usage: true }, |
| 130 | + } : {}), |
| 131 | + } |
| 132 | + |
| 133 | + const completion = await this.openai.chat.completions.create(sdk_params); |
| 134 | + |
| 135 | + const modelDetails = (await this.models_()).find(m => m.id === 'ollama:' + model); |
| 136 | + return OpenAIUtil.handle_completion_output({ |
| 137 | + usage_calculator: ({ usage }) => { |
| 138 | + // custom open router logic because its free |
| 139 | + const trackedUsage = { |
| 140 | + prompt: 0, |
| 141 | + completion: 0, |
| 142 | + input_cache_read: 0, |
| 143 | + }; |
| 144 | + const legacyCostCalculator = OpenAIUtil.create_usage_calculator({ |
| 145 | + model_details: modelDetails, |
| 146 | + }); |
| 147 | + return legacyCostCalculator({ usage }); |
| 148 | + }, |
| 149 | + stream, |
| 150 | + completion, |
| 151 | + }); |
| 152 | + }, |
| 153 | + }, |
| 154 | + }; |
| 155 | + |
| 156 | + /** |
| 157 | + * Retrieves available AI models and their specifications |
| 158 | + * @returns Array of model objects containing: |
| 159 | + * - id: Model identifier string |
| 160 | + * - name: Human readable model name |
| 161 | + * - context: Maximum context window size |
| 162 | + * - cost: Pricing information object with currency and rates |
| 163 | + * @private |
| 164 | + */ |
| 165 | + async models_(rawPriceKeys = false) { |
| 166 | + const axios = this.require('axios'); |
| 167 | + |
| 168 | + let models = this.modules.kv.get(`${this.kvkey}:models`); |
| 169 | + if ( !models ) { |
| 170 | + try { |
| 171 | + const resp = await axios.request({ |
| 172 | + method: 'GET', |
| 173 | + url: this.api_base_url + '/api/tags', |
| 174 | + }); |
| 175 | + models = resp.data.models || []; |
| 176 | + if ( models.length > 0 ) { |
| 177 | + this.modules.kv.set(`${this.kvkey}:models`, models); |
| 178 | + } |
| 179 | + } catch (error) { |
| 180 | + this.log.error('Failed to fetch models from Ollama:', error.message); |
| 181 | + // Return empty array if Ollama is not available |
| 182 | + return []; |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + if ( !models || models.length === 0 ) { |
| 187 | + return []; |
| 188 | + } |
| 189 | + |
| 190 | + const coerced_models = []; |
| 191 | + for ( const model of models ) { |
| 192 | + // Ollama API returns models with 'name' property, not 'model' |
| 193 | + const modelName = model.name || model.model || 'unknown'; |
| 194 | + const microcentCosts = { |
| 195 | + input: 0, |
| 196 | + output: 0, |
| 197 | + }; |
| 198 | + coerced_models.push({ |
| 199 | + id: 'ollama:' + modelName, |
| 200 | + name: modelName + ' (Ollama)', |
| 201 | + max_tokens: model.size || model.max_context || 8192, |
| 202 | + cost: { |
| 203 | + currency: 'usd-cents', |
| 204 | + tokens: 1_000_000, |
| 205 | + ...microcentCosts, |
| 206 | + }, |
| 207 | + }); |
| 208 | + } |
| 209 | + console.log("coerced_models", coerced_models); |
| 210 | + return coerced_models; |
| 211 | + } |
| 212 | +} |
| 213 | + |
| 214 | +module.exports = { |
| 215 | + OllamaService, |
| 216 | +}; |
0 commit comments