Skip to content

Commit b552384

Browse files
metiu1claude
andcommitted
Connect remote/self-hosted inference + Ollama Cloud
- Ollama Cloud added as a provider (ollama.com, OpenAI-compatible). - 'Server personalizzato' option in Add cloud model: enter a base URL (vast.ai, home server, remote Ollama — IP:port, /v1, or full URL) + an optional key + model id. Saved endpoints appear in the picker (🖥) and stream via /api/cloud/chat with a per-request base_url/key. normalizeChatURL turns IP:port into the right /v1/chat/completions URL. - Works in Assistant + Agent, so a laptop can drive LLMs on a home/cloud box. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 74166e7 commit b552384

3 files changed

Lines changed: 137 additions & 19 deletions

File tree

vortelio/internal/cloud/cloud.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,16 @@ var Providers = []Provider{
136136
Format: FormatOpenAI,
137137
KeyHint: "https://www.perplexity.ai/settings/api",
138138
},
139+
{
140+
ID: "ollamacloud",
141+
Name: "Ollama Cloud",
142+
DefaultModel: "gpt-oss:120b",
143+
BaseURL: "https://ollama.com/v1/chat/completions",
144+
AuthHeader: "Authorization",
145+
AuthPrefix: "Bearer ",
146+
Format: FormatOpenAI,
147+
KeyHint: "https://ollama.com/settings/keys",
148+
},
139149
}
140150

141151
func FindProvider(id string) (Provider, bool) {

vortelio/internal/server/server_cloud.go

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"net/http"
7+
"strings"
78

89
"github.com/vortelio/vortelio/internal/cloud"
910
)
@@ -67,10 +68,37 @@ var cloudModelChoices = map[string][][2]string{
6768
{"sonar-pro", "Sonar Pro"},
6869
{"sonar-reasoning", "Sonar Reasoning"},
6970
},
71+
"ollamacloud": {
72+
{"gpt-oss:120b", "gpt-oss 120B"},
73+
{"deepseek-v3.1:671b", "DeepSeek V3.1 671B"},
74+
{"qwen3-coder:480b", "Qwen3 Coder 480B"},
75+
{"kimi-k2:1t", "Kimi K2 1T"},
76+
},
7077
}
7178

7279
// GET /api/cloud/providers
7380
// Lists providers, whether a key is stored, and the model choices.
81+
// normalizeChatURL turns a user-supplied server address into a full OpenAI-style
82+
// chat-completions URL. Accepts "host:port", "http://host:port", ".../v1", or a
83+
// full ".../chat/completions" URL.
84+
func normalizeChatURL(u string) string {
85+
u = strings.TrimSpace(u)
86+
if u == "" {
87+
return ""
88+
}
89+
if !strings.HasPrefix(u, "http://") && !strings.HasPrefix(u, "https://") {
90+
u = "http://" + u
91+
}
92+
if strings.Contains(u, "/chat/completions") {
93+
return u
94+
}
95+
u = strings.TrimRight(u, "/")
96+
if strings.HasSuffix(u, "/v1") {
97+
return u + "/chat/completions"
98+
}
99+
return u + "/v1/chat/completions"
100+
}
101+
74102
func handleCloudProviders(w http.ResponseWriter, r *http.Request) {
75103
type modelOut struct {
76104
ID string `json:"id"`
@@ -159,30 +187,48 @@ func handleCloudChat(w http.ResponseWriter, r *http.Request) {
159187
Messages []cloud.Message `json:"messages"`
160188
Agentic *AgenticConfig `json:"agentic"`
161189
System string `json:"system"`
190+
BaseURL string `json:"base_url"` // custom/self-hosted endpoint (vast.ai, home server, Ollama)
191+
Key string `json:"key"` // optional key for the custom endpoint
162192
}
163193
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
164194
jsonError(w, 400, "invalid request")
165195
return
166196
}
167-
p, ok := cloud.FindProvider(req.Provider)
168-
if !ok {
169-
jsonError(w, 400, "unknown provider")
170-
return
171-
}
172-
key := cloud.LoadKey(req.Provider)
173-
if key == "" {
174-
jsonError(w, 400, fmt.Sprintf("no API key for %s — add your own key in Cloud Models", p.Name))
175-
return
176-
}
177197
if len(req.Messages) == 0 {
178198
jsonError(w, 400, "messages required")
179199
return
180200
}
181-
// Override the model if the caller picked one.
182-
if req.Model != "" {
183-
p.DefaultModel = req.Model
184-
if p.Format == cloud.FormatGemini {
185-
p.BaseURL = "https://generativelanguage.googleapis.com/v1beta/models/" + req.Model + ":generateContent"
201+
202+
var p cloud.Provider
203+
var key string
204+
if req.Provider == "custom" || req.BaseURL != "" {
205+
// Custom / self-hosted OpenAI- or Ollama-compatible endpoint.
206+
bu := normalizeChatURL(req.BaseURL)
207+
if bu == "" {
208+
jsonError(w, 400, "base_url required for a custom server")
209+
return
210+
}
211+
p = cloud.Provider{ID: "custom", Name: "Custom server", BaseURL: bu,
212+
AuthHeader: "Authorization", AuthPrefix: "Bearer ", Format: cloud.FormatOpenAI, DefaultModel: req.Model}
213+
key = req.Key // may be empty for no-auth home servers
214+
} else {
215+
var ok bool
216+
p, ok = cloud.FindProvider(req.Provider)
217+
if !ok {
218+
jsonError(w, 400, "unknown provider")
219+
return
220+
}
221+
key = cloud.LoadKey(req.Provider)
222+
if key == "" {
223+
jsonError(w, 400, fmt.Sprintf("no API key for %s — add your own key in Cloud Models", p.Name))
224+
return
225+
}
226+
// Override the model if the caller picked one.
227+
if req.Model != "" {
228+
p.DefaultModel = req.Model
229+
if p.Format == cloud.FormatGemini {
230+
p.BaseURL = "https://generativelanguage.googleapis.com/v1beta/models/" + req.Model + ":generateContent"
231+
}
186232
}
187233
}
188234

vortelio/internal/server/ui.html

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2368,6 +2368,14 @@ <h3 style="margin:0">☁️ Add cloud model</h3>
23682368
<label style="font-size:12px;font-weight:500;display:block;margin-bottom:4px">Provider</label>
23692369
<select id="ac-provider" class="tool-input" style="width:100%;margin-bottom:12px" onchange="acProviderChanged()"></select>
23702370

2371+
<div id="ac-custom-fields" style="display:none">
2372+
<label style="font-size:12px;font-weight:500;display:block;margin-bottom:4px">Nome (a tua scelta)</label>
2373+
<input id="ac-name" class="tool-input" placeholder="es. Server di casa / Vast.ai" style="width:100%;margin-bottom:12px">
2374+
<label style="font-size:12px;font-weight:500;display:block;margin-bottom:4px">Indirizzo server (OpenAI/Ollama compatibile)</label>
2375+
<input id="ac-baseurl" class="tool-input" placeholder="es. http://192.168.1.50:11434 oppure https://host-vast:porta/v1" style="width:100%;margin-bottom:4px">
2376+
<div style="font-size:11px;color:var(--text3);margin-bottom:12px">Accetta IP:porta, .../v1 o l'URL completo. Per Ollama remoto basta http://IP:11434</div>
2377+
</div>
2378+
23712379
<label style="font-size:12px;font-weight:500;display:block;margin-bottom:4px">Tipo</label>
23722380
<select id="ac-type" class="tool-input" style="width:100%;margin-bottom:12px">
23732381
<option value="llm">LLM (chat / testo)</option>
@@ -3006,6 +3014,8 @@ <h3 style="margin:0">☁️ Add cloud model</h3>
30063014
let selectedModelIsCloud = false; // true when the picked assistant is a cloud model
30073015
let selectedCloudProvider = ''; // provider id when cloud (e.g. "openai")
30083016
let selectedCloudModel = ''; // model id when cloud (e.g. "gpt-4o")
3017+
let selectedCustomBaseURL = ''; // base URL when using a custom/self-hosted endpoint
3018+
let selectedCustomKey = ''; // optional key for the custom endpoint
30093019
let chatHistory = [];
30103020
let sessions = [];
30113021
let currentSession = null;
@@ -3279,6 +3289,10 @@ <h3 style="margin:0">☁️ Add cloud model</h3>
32793289
const v = `cloud:${cm.provider}:${cm.id}`;
32803290
if (p && p.has_key && !seen[v]) { seen[v] = 1; models.push({ v, l: cm.label || cm.id, tag: '☁️ ' + (p.name || cm.provider), cloud: true }); }
32813291
}
3292+
// Custom / self-hosted endpoints (always usable).
3293+
for (const ep of getCustomEndpoints()) {
3294+
models.push({ v: 'customep:' + ep.id, l: ep.label || ep.model, tag: '🖥 ' + (ep.name || 'server'), cloud: true });
3295+
}
32823296
}
32833297
buildModelDD(models);
32843298
if (!models.find(m => m.v === selectedModel)) {
@@ -3388,6 +3402,15 @@ <h3 style="margin:0">☁️ Add cloud model</h3>
33883402
function getCustomCloudModels() {
33893403
try { return JSON.parse(localStorage.getItem('vortelio_cloud_models') || '[]'); } catch { return []; }
33903404
}
3405+
// Custom/self-hosted endpoints (vast.ai, home server, remote Ollama): {id,name,baseURL,key,model,label}
3406+
function getCustomEndpoints() {
3407+
try { return JSON.parse(localStorage.getItem('vortelio_custom_endpoints') || '[]'); } catch { return []; }
3408+
}
3409+
function saveCustomEndpoint(ep) {
3410+
const list = getCustomEndpoints().filter(e => e.id !== ep.id);
3411+
list.push(ep);
3412+
localStorage.setItem('vortelio_custom_endpoints', JSON.stringify(list));
3413+
}
33913414
function saveCustomCloudModel(provider, id, label) {
33923415
const list = getCustomCloudModels();
33933416
if (!list.some(m => m.provider === provider && m.id === id)) {
@@ -3398,17 +3421,31 @@ <h3 style="margin:0">☁️ Add cloud model</h3>
33983421
function openAddCloudModel() {
33993422
const sel = document.getElementById('ac-provider');
34003423
if (!_cloudProviders || !_cloudProviders.length) { loadCloudProviders().then(openAddCloudModel); return; }
3401-
sel.innerHTML = _cloudProviders.map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.has_key ? ' ✓' : ''}</option>`).join('');
3424+
sel.innerHTML = _cloudProviders.map(p => `<option value="${esc(p.id)}">${esc(p.name)}${p.has_key ? ' ✓' : ''}</option>`).join('')
3425+
+ `<option value="__custom__">🖥 Server personalizzato (vast.ai / casa / Ollama)</option>`;
34023426
acProviderChanged();
34033427
document.getElementById('ac-key').value = '';
34043428
document.getElementById('addcloud-overlay').classList.add('show');
34053429
}
34063430
function closeAddCloudModel() { document.getElementById('addcloud-overlay').classList.remove('show'); }
34073431
function acProviderChanged() {
3408-
const p = (_cloudProviders || []).find(x => x.id === document.getElementById('ac-provider').value);
3432+
const pid = document.getElementById('ac-provider').value;
3433+
const isCustom = pid === '__custom__';
3434+
document.getElementById('ac-custom-fields').style.display = isCustom ? 'block' : 'none';
3435+
if (isCustom) {
3436+
// Self-hosted/remote endpoint: model is a free-typed id, key optional.
3437+
document.getElementById('ac-model').innerHTML = `<option value="__custom__">Scrivi l'ID del modello</option>`;
3438+
acModelChanged();
3439+
document.getElementById('ac-model-custom').placeholder = 'ID modello sul server (es. llama3.1:8b, qwen2.5:7b)';
3440+
document.getElementById('ac-key-note').textContent = '— opzionale (solo se il server la richiede)';
3441+
document.getElementById('ac-key-link').style.display = 'none';
3442+
return;
3443+
}
3444+
const p = (_cloudProviders || []).find(x => x.id === pid);
34093445
let opts = ((p && p.models) || []).map(cm => `<option value="${esc(cm.id)}">${esc(cm.label || cm.id)}</option>`).join('');
34103446
opts += `<option value="__custom__">✏️ Altro… (scrivi l'ID)</option>`;
34113447
document.getElementById('ac-model').innerHTML = opts;
3448+
document.getElementById('ac-model-custom').placeholder = 'ID modello (es. gpt-4.1, claude-opus-4)';
34123449
acModelChanged();
34133450
document.getElementById('ac-key-note').textContent = (p && p.has_key) ? '— già salvata (lascia vuoto per tenerla)' : '— richiesta';
34143451
const link = document.getElementById('ac-key-link');
@@ -3420,9 +3457,26 @@ <h3 style="margin:0">☁️ Add cloud model</h3>
34203457
}
34213458
async function saveAddCloudModel() {
34223459
const pid = document.getElementById('ac-provider').value;
3423-
const p = (_cloudProviders || []).find(x => x.id === pid);
34243460
let modelId = document.getElementById('ac-model').value;
34253461
if (modelId === '__custom__') modelId = document.getElementById('ac-model-custom').value.trim();
3462+
3463+
// Custom / self-hosted server (vast.ai, home server, remote Ollama).
3464+
if (pid === '__custom__') {
3465+
const name = (document.getElementById('ac-name').value.trim()) || 'Server';
3466+
const baseURL = document.getElementById('ac-baseurl').value.trim();
3467+
const key = document.getElementById('ac-key').value.trim();
3468+
if (!baseURL) { toast('Inserisci l\'indirizzo del server', 'err'); return; }
3469+
if (!modelId) { toast('Scrivi l\'ID del modello (es. llama3.1:8b)', 'err'); return; }
3470+
const id = 'ep' + Date.now();
3471+
saveCustomEndpoint({ id, name, baseURL, key, model: modelId, label: modelId + ' · ' + name });
3472+
updateModelDDForDomain();
3473+
selectModel('customep:' + id, modelId + ' · ' + name);
3474+
closeAddCloudModel();
3475+
toast('🖥 Server aggiunto: ' + name, 'ok');
3476+
return;
3477+
}
3478+
3479+
const p = (_cloudProviders || []).find(x => x.id === pid);
34263480
if (!modelId) { toast('Scegli o scrivi un modello', 'err'); return; }
34273481
const key = document.getElementById('ac-key').value.trim();
34283482
if ((!p || !p.has_key) && !key) { toast('Inserisci la API key per questo provider', 'err'); return; }
@@ -3452,7 +3506,13 @@ <h3 style="margin:0">☁️ Add cloud model</h3>
34523506

34533507
function selectModel(v, l) {
34543508
selectedModel = v;
3455-
if (v.startsWith('cloud:')) {
3509+
selectedCustomBaseURL = ''; selectedCustomKey = '';
3510+
if (v.startsWith('customep:')) {
3511+
// A saved custom/self-hosted endpoint (vast.ai, home server, remote Ollama).
3512+
const ep = getCustomEndpoints().find(e => e.id === v.slice(9));
3513+
selectedModelIsCloud = true; selectedCloudProvider = 'custom';
3514+
if (ep) { selectedCloudModel = ep.model; selectedCustomBaseURL = ep.baseURL; selectedCustomKey = ep.key || ''; l = l || ep.label; }
3515+
} else if (v.startsWith('cloud:')) {
34563516
const parts = v.split(':');
34573517
selectedModelIsCloud = true;
34583518
selectedCloudProvider = parts[1] || '';
@@ -4532,6 +4592,8 @@ <h3 style="margin:0">☁️ Add cloud model</h3>
45324592
// Shared by the unified assistant chat and the legacy Cloud Models panel.
45334593
async function streamCloudInto(provider, model, history, content, canvas, signal) {
45344594
const payload = { provider, model, messages: history };
4595+
// Custom / self-hosted endpoint (vast.ai, home server, remote Ollama).
4596+
if (provider === 'custom') { payload.base_url = selectedCustomBaseURL || ''; payload.key = selectedCustomKey || ''; }
45354597
const ag = buildAgenticPayload(); if (ag) payload.agentic = ag;
45364598
const r = await apiFetch('/api/cloud/chat', {
45374599
method: 'POST', headers: {'Content-Type':'application/json'},

0 commit comments

Comments
 (0)