Skip to content

Commit 3c3367c

Browse files
Autodiscover ollama support (#1953)
1 parent 0077944 commit 3c3367c

File tree

4 files changed

+245
-2
lines changed

4 files changed

+245
-2
lines changed

src/backend/src/modules/puterai/AIChatService.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -321,12 +321,18 @@ class AIChatService extends BaseService {
321321
Messages.normalize_messages(parameters.messages);
322322
}
323323

324-
if ( ! test_mode && ! await this.moderate(parameters) ) {
324+
// Skip moderation for Ollama (local service) and other local services
325+
const should_moderate = ! test_mode &&
326+
intended_service !== 'ollama' &&
327+
! parameters.model?.startsWith('ollama:');
328+
329+
if ( should_moderate && ! await this.moderate(parameters) ) {
325330
test_mode = true;
326331
throw APIError.create('moderation_failed');
327332
}
328333

329-
if ( ! test_mode ) {
334+
// Only set moderated flag if we actually ran moderation
335+
if ( ! test_mode && should_moderate ) {
330336
Context.set('moderated', true);
331337
}
332338

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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+
};

src/backend/src/modules/puterai/PuterAIModule.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,24 @@ class PuterAIModule extends AdvancedBase {
119119
services.registerService('openrouter', OpenRouterService);
120120
}
121121

122+
// Autodiscover Ollama service and then check if its disabled in the config
123+
// if config.services.ollama.enabled is undefined, it means the user hasn't set it, so we should default to true
124+
const ollama_available = await fetch('http://localhost:11434/api/tags').then(resp => resp.json()).then(data => {
125+
const ollama_enabled = config?.services?.['ollama']?.enabled;
126+
if ( ollama_enabled === undefined ) {
127+
return true;
128+
}
129+
return ollama_enabled;
130+
}).catch(err => {
131+
return false;
132+
});
133+
// User can disable ollama in the config, but by default it should be enabled if discovery is successful
134+
if ( ollama_available || config?.services?.['ollama']?.enabled ) {
135+
console.log("Local AI support detected! Registering Ollama");
136+
const { OllamaService } = require('./OllamaService');
137+
services.registerService('ollama', OllamaService);
138+
}
139+
122140
const { AIChatService } = require('./AIChatService');
123141
services.registerService('ai-chat', AIChatService);
124142

src/puter-js/src/modules/AI.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,9 @@ class AI{
743743
else if ( requestParams.model.startsWith('openrouter:') ) {
744744
driver = 'openrouter';
745745
}
746+
else if ( requestParams.model.startsWith('ollama:') ) {
747+
driver = 'ollama';
748+
}
746749

747750
// stream flag from userParams
748751
if(userParams.stream !== undefined && typeof userParams.stream === 'boolean'){

0 commit comments

Comments
 (0)