// Extracted from newOS4 index.html — OllamaManager / LLMRouter
// Refactored to support multiple LLM providers with auto-fallback
// Class name kept as OllamaManager for backward compatibility
class OllamaManager {
constructor(terminal) {
this.terminal = terminal;
// --- Backward-compat properties (reflected from active provider) ---
this.endpoint = localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434';
this.connected = false;
this.models = [];
this.currentModel = localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud';
this.responseHooks = [];
this._abortController = null;
// --- Usage Tracking ---
this._usageLog = [];
this._usageBuffer = [];
this._usageFlushTimer = null;
this._loadUsageLog();
// Flush on page unload
if (typeof window !== 'undefined') {
window.addEventListener('beforeunload', () => this._flushUsageLog());
}
// --- AI Agent Loop Optimization Toggle ---
this.optimizationEnabled = localStorage.getItem('webx11_optimization_enabled') === 'true'; // v4.5.66: default OFF
// --- Streaming Toggle ---
this.streamEnabled = localStorage.getItem('webx11_stream_enabled') === 'true';
// --- Validator Agent Config ---
this.validatorEnabled = localStorage.getItem('webx11_validator_enabled') === 'true';
this.validatorModel = localStorage.getItem('webx11_validator_model') || 'gemma4:31b-cloud';
this.validatorEndpoint = localStorage.getItem('webx11_validator_endpoint') || '';
// --- Model Parameters (temperature, top_p, max_tokens, thinking) ---
this.modelParams = this._loadModelParams();
// Build fallback endpoints
this.fallbackEndpoints = [];
const origin = typeof window !== 'undefined' ? window.location.origin : '';
if (origin) {
this.fallbackEndpoints.push(origin + '/ollama/');
}
// --- Multi-provider architecture ---
this.providers = []; // array of { name, type, endpoint, apiKey, model, priority, enabled }
this.currentProvider = null; // currently active provider object
this.fallbackCount = 0;
this._loadProviders();
// If no active provider set, pick the highest-priority enabled one
if (!this.currentProvider) {
const sorted = this._getSortedEnabledProviders();
if (sorted.length > 0) {
this.currentProvider = sorted[0];
this._syncCompatProperties(this.currentProvider);
}
}
}
// ─── Provider management ─────────────────────────────────────────────────
_loadProviders() {
const stored = this._getSecret('webx11_llm_providers');
if (stored) {
try {
this.providers = JSON.parse(stored);
// Ensure all entries have the 'enabled' field
this.providers.forEach(p => { if (p.enabled === undefined) p.enabled = true; });
} catch (e) {
this.providers = [];
}
}
if (this.providers.length === 0) {
// Default single provider: Ollama
this.providers = [{
name: 'Ollama',
type: 'ollama',
endpoint: localStorage.getItem('webx11_ollama_endpoint') || 'http://localhost:11434',
apiKey: '',
model: localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud',
priority: 0,
enabled: true
}];
}
}
_saveProviders() {
this._setSecret('webx11_llm_providers', JSON.stringify(this.providers));
}
_loadModelParams() {
try {
const saved = localStorage.getItem('webx11_model_params');
if (saved) {
const parsed = JSON.parse(saved);
return {
temperature: parsed.temperature !== undefined ? parsed.temperature : 0.7,
top_p: parsed.top_p !== undefined ? parsed.top_p : 0.9,
max_tokens: parsed.max_tokens !== undefined ? parsed.max_tokens : 4096,
thinking: parsed.thinking || 'off'
};
}
} catch (e) {}
return { temperature: 0.7, top_p: 0.9, max_tokens: 4096, thinking: 'off' };
}
_saveModelParams() {
try {
localStorage.setItem('webx11_model_params', JSON.stringify(this.modelParams));
} catch (e) {}
}
_getSecret(key) {
if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.decrypt === 'function') {
return window.webx11_secretVault.decrypt(key);
}
return localStorage.getItem(key);
}
_setSecret(key, value) {
if (typeof window !== 'undefined' && window.webx11_secretVault && typeof window.webx11_secretVault.encrypt === 'function') {
window.webx11_secretVault.encrypt(key, value);
} else {
localStorage.setItem(key, value);
}
}
_getSortedEnabledProviders() {
return this.providers
.filter(p => p.enabled)
.sort((a, b) => (a.priority || 0) - (b.priority || 0));
}
_syncCompatProperties(provider) {
if (!provider) return;
this.endpoint = provider.endpoint;
this.currentModel = provider.model || this.currentModel;
// Don't override backward-compat localStorage unless explicitly set
}
_getProviderByType(type) {
return this.providers.find(p => p.type === type && p.enabled);
}
addProvider(name, type, endpoint, apiKey, model, priority) {
const existing = this.providers.findIndex(p => p.name === name);
const entry = {
name: name,
type: type || 'ollama',
endpoint: endpoint || 'http://localhost:11434',
apiKey: apiKey || '',
model: model || localStorage.getItem('webx11_ollama_model') || 'gemma4:31b-cloud',
priority: priority !== undefined ? priority : this.providers.length,
enabled: true
};
if (existing >= 0) {
this.providers[existing] = entry;
} else {
this.providers.push(entry);
}
this._saveProviders();
// If this is the new highest-priority enabled provider, activate it
const sorted = this._getSortedEnabledProviders();
if (sorted.length > 0 && sorted[0].name === entry.name) {
this.currentProvider = entry;
this._syncCompatProperties(entry);
}
return true;
}
removeProvider(name) {
const idx = this.providers.findIndex(p => p.name === name);
if (idx < 0) return false;
this.providers.splice(idx, 1);
if (this.currentProvider && this.currentProvider.name === name) {
this.currentProvider = null;
const sorted = this._getSortedEnabledProviders();
if (sorted.length > 0) {
this.currentProvider = sorted[0];
this._syncCompatProperties(this.currentProvider);
}
}
this._saveProviders();
return true;
}
listProviders() {
return this.providers;
}
setActiveProvider(name) {
const provider = this.providers.find(p => p.name === name);
if (!provider) {
this.terminal.print(`Provider "${name}" not found`, 'error');
return false;
}
this.currentProvider = provider;
this._syncCompatProperties(provider);
this.terminal.print(`Active provider set to: ${name} (${provider.type} at ${provider.endpoint})`, 'success');
return true;
}
getActiveProvider() {
return this.currentProvider;
}
getProviderStats() {
const sorted = this._getSortedEnabledProviders();
const connectedProvider = this.connected
? (this.currentProvider ? this.currentProvider.name : 'unknown')
: null;
return {
currentProvider: this.currentProvider ? this.currentProvider.name : null,
totalProviders: this.providers.length,
connectedProvider: connectedProvider,
fallbackCount: this.fallbackCount
};
}
// ─── Connection ──────────────────────────────────────────────────────────
async connect() {
const sortedProviders = this._getSortedEnabledProviders();
if (sortedProviders.length === 0) {
this.terminal.print('No enabled providers configured', 'error');
this.connected = false;
return false;
}
// Also build endpoint-based fallback list for backward compat
const allEndpoints = [];
// First try the original endpoint + fallbackEndpoints for plain Ollama connect
if (this._getProviderByType('ollama')) {
allEndpoints.push(this.endpoint);
for (const fb of this.fallbackEndpoints) {
if (!allEndpoints.includes(fb)) allEndpoints.push(fb);
}
}
// Try each provider in priority order
for (const provider of sortedProviders) {
let success = false;
if (provider.type === 'ollama') {
// Try provider endpoint first, then fallback endpoints
const eps = [provider.endpoint];
for (const fb of this.fallbackEndpoints) {
if (!eps.includes(fb)) eps.push(fb);
}
for (const ep of eps) {
try {
this.terminal.print(`Connecting to ${provider.name} at ${ep}...`, 'info');
const response = await fetch(`${ep}/api/tags`, {
method: 'GET', headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
this.models = data.models || [];
this.connected = true;
this.currentProvider = provider;
provider.endpoint = ep; // may have been updated via fallback
this._syncCompatProperties(provider);
this.saveSettings();
this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success');
this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + ep + ' with ' + this.models.length + ' model(s)');
if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) {
if (this.models.length > 0) {
this.currentModel = this.models[0].name;
provider.model = this.currentModel;
localStorage.setItem('webx11_ollama_model', this.currentModel);
this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info');
}
}
this._saveProviders();
success = true;
break;
} catch (error) {
this.terminal.log('WARN', 'Ollama', provider.name + ' endpoint ' + ep + ' failed: ' + error.message);
this.terminal.print(`${provider.name} endpoint ${ep} failed: ${error.message}`, 'warning');
}
}
} else {
// OpenAI-compatible provider (OpenRouter, etc.)
try {
this.terminal.print(`Connecting to ${provider.name} at ${provider.endpoint}...`, 'info');
const headers = { 'Content-Type': 'application/json' };
if (provider.apiKey) {
headers['Authorization'] = 'Bearer ' + provider.apiKey;
}
const response = await fetch(`${provider.endpoint}/v1/models`, {
method: 'GET', headers
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
this.models = (data.data || data.models || []).map(m => ({
name: m.id || m.name,
size: m.size || 0
}));
this.connected = true;
this.currentProvider = provider;
this._syncCompatProperties(provider);
this.saveSettings();
this.terminal.print(`Connected to ${provider.name}! Found ${this.models.length} model(s)`, 'success');
this.terminal.log('INFO', 'Ollama', 'Connected to ' + provider.name + ' at ' + provider.endpoint + ' with ' + this.models.length + ' model(s)');
if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) {
if (this.models.length > 0) {
this.currentModel = this.models[0].name;
provider.model = this.currentModel;
localStorage.setItem('webx11_ollama_model', this.currentModel);
this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info');
}
}
this._saveProviders();
success = true;
} catch (error) {
this.terminal.log('WARN', 'Ollama', provider.name + ' failed: ' + error.message);
this.terminal.print(`${provider.name} failed: ${error.message}`, 'warning');
}
}
if (success) return true;
}
// If provider-based connect failed, try bare endpoint fallback (legacy)
for (const ep of allEndpoints) {
try {
this.terminal.print(`Connecting (legacy fallback) to ${ep}...`, 'info');
const response = await fetch(`${ep}/api/tags`, {
method: 'GET', headers: { 'Content-Type': 'application/json' }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
this.models = data.models || [];
this.connected = true;
if (ep !== this.endpoint) {
this.endpoint = ep;
this.saveSettings();
this.terminal.print(`Endpoint updated to: ${ep}`, 'info');
}
this.terminal.print(`Connected! Found ${this.models.length} model(s)`, 'success');
this.terminal.log('INFO', 'Ollama', 'Connected to ' + ep + ' with ' + this.models.length + ' model(s)');
if (!this.currentModel || !this.models.some(m => m.name === this.currentModel)) {
if (this.models.length > 0) {
this.currentModel = this.models[0].name;
localStorage.setItem('webx11_ollama_model', this.currentModel);
this.terminal.print(`Auto-selected model: ${this.currentModel}`, 'info');
}
}
this.saveSettings();
return true;
} catch (error) {
this.terminal.log('WARN', 'Ollama', 'Endpoint ' + ep + ' failed: ' + error.message);
this.terminal.print(`Endpoint ${ep} failed: ${error.message}`, 'warning');
}
}
this.terminal.log('ERROR', 'Ollama', 'Connection failed on all providers');
this.connected = false;
return false;
}
// v4.5.68: Fetch with timeout — wraps fetch() with AbortController to prevent network hangs
async fetchWithTimeout(url, options = {}, timeoutMs = 30000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const resp = await fetch(url, { ...options, signal: controller.signal });
clearTimeout(timer);
return resp;
} catch (e) {
clearTimeout(timer);
if (e.name === 'AbortError') throw new Error(`Request timed out after ${timeoutMs / 1000}s`);
throw e;
}
}
// ─── Lazy connect ────────────────────────────────────────────────────────
/**
* Every request path calls this first. Nothing connects at boot, so the user's first message
* used to die with "Use ollamaconnect first" even when the endpoint was healthy
* (reported 2026-10-26/27). One attempt, then the caller prints the real reason.
* Concurrent callers share the same in-flight promise.
*/
async _ensureConnected() {
if (this.connected) return true;
if (this._connecting) {
try { await this._connecting; } catch (e) { /* fall through to the state check */ }
return this.connected;
}
// An automatic connect must not silently rewrite the model the user picked.
const savedModel = (typeof localStorage !== 'undefined') ? localStorage.getItem('webx11_ollama_model') : null;
this.terminal.print('Ollama: connecting on first use…', 'info');
let timedOut = false;
try {
this._connecting = this.connect();
// connect() uses a bare fetch(), so a blackholed endpoint would hang this promise
// forever and — because the promise is shared — every later message with it.
const deadline = this.connectTimeoutMs || 10000;
const outcome = await Promise.race([
this._connecting.then(() => 'done', e => { throw e; }),
new Promise(resolve => setTimeout(() => resolve('timeout'), deadline)),
]);
timedOut = outcome === 'timeout' && !this.connected;
} catch (e) {
// A throwing terminal.log() must not leave _connecting poisoned: log defensively.
try { this.terminal.log('WARN', 'Ollama', 'Auto-connect failed: ' + (e && e.message ? e.message : e)); } catch (_) {}
} finally {
this._connecting = null;
}
if (this.connected && savedModel && !(this.models || []).some(m => m.name === savedModel)) {
this.currentModel = savedModel;
if (this.currentProvider) this.currentProvider.model = savedModel;
// connect() may already have overwritten the stored preference; put it back.
try { localStorage.setItem('webx11_ollama_model', savedModel); } catch (_) {}
this.terminal.print('Keeping your saved model "' + savedModel + '" — this endpoint does not list it.', 'warning');
}
if (!this.connected) {
this.terminal.print(timedOut
? 'Auto-connect timed out after ' + ((this.connectTimeoutMs || 10000) / 1000) + 's — the endpoint is not answering. Check the Ollama tab or run ollamaconnect.'
: 'Auto-connect failed — check the endpoint in the Ollama tab (or run ollamaconnect).', 'warning');
}
return this.connected;
}
// ─── Chat (non-streaming) ────────────────────────────────────────────────
async chat(prompt, model = null) {
// Per-call state: never let a reason captured by an earlier failure be replayed here.
this._lastChatError = null;
if (!this.connected && !(await this._ensureConnected())) {
this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error');
return null;
}
const useModel = model || this.currentModel;
if (!useModel) {
this.terminal.print('No model selected. Use ollamamodels to select.', 'error');
return null;
}
const providersToTry = this._getFallbackChain();
let lastError = null;
for (const provider of providersToTry) {
const result = await this._chatWithProvider(provider, prompt, useModel);
if (result !== null && result !== undefined) {
// Success — if we fell back, increment fallbackCount
if (provider !== this.currentProvider) {
this.fallbackCount++;
this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning');
this.currentProvider = provider;
this._syncCompatProperties(provider);
}
return result;
}
lastError = result;
}
if (lastError === null) {
// Name the real cause when one was captured; the generic wording is only a fallback.
const reason = this._lastChatError;
this.terminal.print(
reason ? `Ollama error: ${reason}` : 'Ollama error: No provider responded',
'error');
}
return null;
}
async _chatWithProvider(provider, prompt, model) {
const useModel = model || provider.model || this.currentModel;
let fullText = '';
this._abortController = new AbortController();
const signal = this._abortController.signal;
const t0 = Date.now();
try {
if (provider.type === 'ollama') {
const response = await fetch(`${provider.endpoint}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled }),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (this.streamEnabled) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let isFirstToken = true;
let lastObj = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter(l => l.trim());
for (const line of lines) {
try {
const parsed = JSON.parse(line);
if (parsed.response) {
fullText += parsed.response;
this.terminal.printStreamingResponse(parsed.response, !isFirstToken);
isFirstToken = false;
}
lastObj = parsed; // capture last for eval_count
} catch (e) {
// Skip malformed partial lines
}
}
}
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, lastObj?.prompt_eval_count, lastObj?.eval_count);
} else {
const data = await response.json();
fullText = data.response || '';
if (fullText) {
this.terminal.printStreamingResponse(fullText, false);
}
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count);
}
return fullText || null;
} else {
// OpenAI-compatible provider
const headers = { 'Content-Type': 'application/json' };
if (provider.apiKey) {
headers['Authorization'] = 'Bearer ' + provider.apiKey;
}
const response = await fetch(`${provider.endpoint}/v1/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: useModel,
messages: [{ role: 'user', content: prompt }],
stream: this.streamEnabled
}),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (this.streamEnabled) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let isFirstToken = true;
let buffer = '';
let lastUsage = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') continue;
if (trimmed.startsWith('data: ')) {
try {
const parsed = JSON.parse(trimmed.substring(6));
const content = parsed.choices?.[0]?.delta?.content || '';
if (content) {
fullText += content;
this.terminal.printStreamingResponse(content, !isFirstToken);
isFirstToken = false;
}
if (parsed.usage) lastUsage = parsed.usage;
} catch (e) {
// Skip malformed SSE lines
}
}
}
}
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, lastUsage?.prompt_tokens, lastUsage?.completion_tokens);
} else {
const data = await response.json();
fullText = data.choices?.[0]?.message?.content || '';
if (fullText) {
this.terminal.printStreamingResponse(fullText, false);
}
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens);
}
return fullText || null;
}
} catch (error) {
// Remember why this provider failed so chat() can name the real cause
// instead of falling back to the generic "No provider responded".
this._lastChatError = error.message || String(error);
if (fullText && fullText.length > 0) {
this.terminal.printStreamingResponse(`\n\n[Stream error: ${error.message}]`, true);
return fullText;
}
return null; // signal failure so caller can try next provider
} finally {
this._abortController = null;
}
}
// ─── Chat Stream ─────────────────────────────────────────────────────────
async chatStream(prompt, onToken, onComplete, model = null) {
if (!this.connected && !(await this._ensureConnected())) {
this.terminal.print('Ollama not connected: the endpoint is unreachable.', 'error');
onComplete && onComplete(null); return;
}
const useModel = model || this.currentModel;
if (!useModel) { this.terminal.print('No model selected.', 'error'); onComplete && onComplete(null); return; }
const providersToTry = this._getFallbackChain();
let triedAny = false;
for (const provider of providersToTry) {
triedAny = true;
const result = await this._chatStreamWithProvider(provider, prompt, onToken, onComplete, useModel);
if (result !== null) {
if (provider !== this.currentProvider) {
this.fallbackCount++;
this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning');
this.currentProvider = provider;
this._syncCompatProperties(provider);
}
return result;
}
}
if (!triedAny) {
this.terminal.print('No model selected.', 'error');
onComplete && onComplete(null);
} else {
this.terminal.print(`Stream error: No provider responded`, 'error');
onComplete && onComplete(null);
}
return null;
}
async _chatStreamWithProvider(provider, prompt, onToken, onComplete, model) {
const useModel = model || provider.model || this.currentModel;
this._abortController = new AbortController();
const signal = this._abortController.signal;
const t0 = Date.now();
try {
if (provider.type === 'ollama') {
const response = await fetch(`${provider.endpoint}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: useModel, prompt: prompt, stream: this.streamEnabled }),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let fullResponse = '';
if (this.streamEnabled) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let lastObj = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.trim()) continue;
try {
const obj = JSON.parse(line);
if (obj.response) {
fullResponse += obj.response;
onToken && onToken(obj.response, fullResponse);
}
lastObj = obj;
} catch (e) { continue; }
}
}
if (buffer.trim()) {
try {
const obj = JSON.parse(buffer);
if (obj.response) { fullResponse += obj.response; onToken && onToken(obj.response, fullResponse); }
lastObj = obj;
} catch (e) {}
}
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, lastObj?.prompt_eval_count, lastObj?.eval_count);
} else {
const data = await response.json();
fullResponse = data.response || '';
if (fullResponse) {
onToken && onToken(fullResponse, fullResponse);
}
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count);
}
this.fireResponseHooks(fullResponse);
onComplete && onComplete(fullResponse);
return fullResponse;
} else {
// OpenAI-compatible provider
const headers = { 'Content-Type': 'application/json' };
if (provider.apiKey) {
headers['Authorization'] = 'Bearer ' + provider.apiKey;
}
const response = await fetch(`${provider.endpoint}/v1/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify({
model: useModel,
messages: [{ role: 'user', content: prompt }],
stream: this.streamEnabled
}),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
let fullResponse = '';
if (this.streamEnabled) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let lastUsage = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') continue;
if (trimmed.startsWith('data: ')) {
try {
const parsed = JSON.parse(trimmed.substring(6));
const content = parsed.choices?.[0]?.delta?.content || '';
if (content) {
fullResponse += content;
onToken && onToken(content, fullResponse);
}
if (parsed.usage) lastUsage = parsed.usage;
} catch (e) { continue; }
}
}
}
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, lastUsage?.prompt_tokens, lastUsage?.completion_tokens);
} else {
const data = await response.json();
fullResponse = data.choices?.[0]?.message?.content || '';
if (fullResponse) {
onToken && onToken(fullResponse, fullResponse);
}
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens);
}
this.fireResponseHooks(fullResponse);
onComplete && onComplete(fullResponse);
return fullResponse;
}
} catch (error) {
this.terminal.print(`Stream error with ${provider.name}: ${error.message}`, 'error');
onComplete && onComplete(null);
return null;
} finally {
this._abortController = null;
}
}
// ─── Chat With Tools ─────────────────────────────────────────────────────
_isModelType(modelName, type) {
if (!modelName) return false;
const lower = modelName.toLowerCase();
return lower.includes(type);
}
async chatWithTools(messages, tools, model = null) {
// Per-call state: never let a reason captured by an earlier failure be replayed here.
this._lastChatError = null;
if (!this.connected && !(await this._ensureConnected())) {
this.terminal.print('Ollama not connected: the endpoint is unreachable. Check the Ollama tab or run ollamaconnect.', 'error');
return { message: null, tool_calls: null, error: 'Not connected' };
}
const useModel = model || this.currentModel;
if (!useModel) {
this.terminal.print('No model selected.', 'error');
return { message: null, tool_calls: null, error: 'No model' };
}
const providersToTry = this._getFallbackChain();
let lastError = null;
for (const provider of providersToTry) {
const result = await this._chatWithToolsWithProvider(provider, messages, tools, useModel);
if (result && !result.error) {
if (provider !== this.currentProvider) {
this.fallbackCount++;
this.terminal.print(`Fell back to provider: ${provider.name}`, 'warning');
this.currentProvider = provider;
this._syncCompatProperties(provider);
}
return result;
}
// Only keep non-cancellation errors for fallback
if (result && result.error && result.error !== 'Cancelled') {
lastError = result;
}
}
// Name the real cause when one was captured; the generic wording is only a fallback.
const reason = this._lastChatError;
return lastError || {
message: null, tool_calls: null,
error: reason ? `All providers failed: ${reason}` : 'All providers failed'
};
}
async _chatWithToolsWithProvider(provider, messages, tools, model) {
const useModel = model || provider.model || this.currentModel;
this._abortController = new AbortController();
const signal = this._abortController.signal;
try {
if (provider.type === 'ollama') {
const body = { model: useModel, messages, stream: false };
if (tools && tools.length > 0) body.tools = tools;
const t0 = Date.now();
const response = await fetch(`${provider.endpoint}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal
});
const networkTime = Math.round((Date.now() - t0) / 1000);
if (!response.ok) {
if (response.status === 400) {
return await this.chatWithToolsFallback(messages, tools, useModel, signal);
}
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`);
this._logUsage(provider, useModel, networkTime * 1000, data.prompt_eval_count, data.eval_count);
return {
message: data.message || null,
tool_calls: data.message?.tool_calls || null,
error: null
};
} else {
// OpenAI-compatible provider (OpenRouter, DeepSeek, etc.)
const isDeepSeek = this._isModelType(useModel, 'deepseek');
const headers = { 'Content-Type': 'application/json' };
if (provider.apiKey) {
headers['Authorization'] = 'Bearer ' + provider.apiKey;
}
const body = {
model: useModel,
messages: messages,
stream: false
};
// Apply model parameters (user-configured temperature, top_p, max_tokens, thinking)
if (this.modelParams) {
body.temperature = this.modelParams.temperature;
body.top_p = this.modelParams.top_p;
body.max_tokens = this.modelParams.max_tokens;
}
if (isDeepSeek) {
// DeepSeek defaults: user params override hardcoded defaults
if (body.temperature === undefined) body.temperature = 0.3;
if (body.max_tokens === undefined) body.max_tokens = 4096;
body.stop = ['<|end|>'];
// DeepSeek thinking mode
if (this.modelParams && this.modelParams.thinking !== 'off') {
body.thinking = { type: this.modelParams.thinking };
}
}
if (tools && tools.length > 0) {
body.tools = tools;
}
const t0 = Date.now();
const response = await fetch(`${provider.endpoint}/v1/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal
});
const networkTime = Math.round((Date.now() - t0) / 1000);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
this.terminal.log('INFO', 'Ollama', `chatWithTools round-trip: ${networkTime}s (model: ${useModel}, provider: ${provider.name})`);
this._logUsage(provider, useModel, networkTime * 1000, data.usage?.prompt_tokens, data.usage?.completion_tokens);
const choice = data.choices?.[0];
const message = choice?.message || null;
const toolCalls = message?.tool_calls || null;
const content = message?.content || '';
// DeepSeek: handle null content with tool calls — send empty string
if (isDeepSeek && content === '' && toolCalls && toolCalls.length > 0) {
const normalised = toolCalls.map(tc => ({
id: tc.id,
type: tc.type || 'function',
function: {
name: tc.function?.name || '',
arguments: tc.function?.arguments || '{}'
}
}));
return {
// tool_calls must live INSIDE the assistant message: the caller pushes
// result.message and then a role:'tool' reply, and a tool message whose
// tool_call_id has no matching assistant call is the HTTP 400 this
// branch exists to avoid.
message: { role: 'assistant', content: '', tool_calls: normalised },
tool_calls: normalised,
error: null
};
}
// DeepSeek: empty response with no tools — retry once with stream forcing
if (isDeepSeek && content === '' && (!toolCalls || toolCalls.length === 0)) {
this.terminal.print('⚠️ DeepSeek returned empty — retrying with stream...', 'warning');
body.stream = true;
delete body.stop;
this._abortController = new AbortController();
const retrySignal = this._abortController.signal;
const retryResponse = await fetch(`${provider.endpoint}/v1/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: retrySignal
});
if (retryResponse.ok) {
const reader = retryResponse.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
let retryBuffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
retryBuffer += decoder.decode(value, { stream: true });
const lines = retryBuffer.split('\n');
retryBuffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed === 'data: [DONE]') continue;
if (trimmed.startsWith('data: ')) {
try {
const parsed = JSON.parse(trimmed.substring(6));
const chunk = parsed.choices?.[0]?.delta?.content || '';
if (chunk) fullText += chunk;
} catch (e) {}
}
}
}
if (fullText) {
return {
message: { role: 'assistant', content: fullText },
tool_calls: null,
error: null
};
}
}
return {
message: { role: 'assistant', content: '' },
tool_calls: null,
error: 'DeepSeek returned empty response after retry — model may be overloaded or context too long'
};
}
return {
message: message ? {
role: message.role || 'assistant',
content: content,
// OpenAI-compatible providers require tool_calls INSIDE the assistant message;
// a bare { role:'tool' } with no preceding tool_calls is rejected with HTTP 400.
...(Array.isArray(toolCalls) && toolCalls.length ? { tool_calls: toolCalls } : {}),
} : null,
tool_calls: toolCalls ? toolCalls.map(tc => ({
id: tc.id,
type: tc.type || 'function',
function: {
name: tc.function?.name || '',
arguments: tc.function?.arguments || '{}'
}
})) : null,
error: null
};
}
} catch (error) {
if (error.name === 'AbortError') {
this.terminal.print('⏹ AI request cancelled by user', 'warning');
return { message: null, tool_calls: null, error: 'Cancelled' };
}
if (error.message === 'Failed to fetch' && provider.type === 'ollama') {
return await this.chatWithToolsFallback(messages, tools, useModel);
}
this.terminal.print(`${provider.name} /api/chat error: ${error.message}`, 'error');
this._lastChatError = error.message || String(error);
return { message: null, tool_calls: null, error: error.message };
} finally {
this._abortController = null;
}
}
async chatWithToolsFallback(messages, tools, model, signal) {
const provider = this.currentProvider;
const useModel = model || (provider ? provider.model : this.currentModel);
const t0 = Date.now();
if (provider && provider.type !== 'ollama') {
// For OpenAI-compatible providers, try without tools
this.terminal.print(`Model doesn't support tool calling. Retrying without tools...`, 'warning');
try {
const headers = { 'Content-Type': 'application/json' };
if (provider.apiKey) {
headers['Authorization'] = 'Bearer ' + provider.apiKey;
}
const body = {
model: useModel,
messages: messages,
stream: false
};
const response = await fetch(`${provider.endpoint}/v1/chat/completions`, {
method: 'POST', headers,
body: JSON.stringify(body),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const text = data.choices?.[0]?.message?.content || '';
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, data.usage?.prompt_tokens, data.usage?.completion_tokens);
return { message: { role: 'assistant', content: text }, tool_calls: null, error: null };
} catch (e) {
if (e.name === 'AbortError') {
return { message: null, tool_calls: null, error: 'Cancelled' };
}
return { message: null, tool_calls: null, error: e.message };
}
}
// MiniCPM5 XML tool calling mode
if (useModel && useModel.includes('minicpm5') && tools && tools.length > 0) {
this.terminal.print(`🧩 Using XML tool mode for ${useModel}...`, 'info');
try {
const toolDesc = tools.map(t => JSON.stringify({type:'function',function:t.function})).join('\n');
const toolContext = { role: 'system', content:
`You are a tool orchestrator. When you need to call a tool, output XML like:
param-value
Available tools:
${toolDesc}
Only output XML function calls for tool tasks. If no tool is needed, answer normally.` };
const msgsWithTools = [toolContext, ...messages];
const body = { model: useModel, messages: msgsWithTools, stream: false, options: { temperature: 0.1 } };
const response = await fetch(`${this.endpoint}/api/chat`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const text = (data.message?.content || '').trim();
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count);
// Parse XML function calls from the response
const toolCalls = [];
const funcRegex = //gs;
let match;
while ((match = funcRegex.exec(text)) !== null) {
const funcName = match[1];
const args = {};
const paramRegex = /(.*?)<\/param>/gs;
let pmatch;
while ((pmatch = paramRegex.exec(match[0])) !== null) {
args[pmatch[1]] = pmatch[3]
.replace('', '')
.replace(/^\s+|\s+$/g, '');
}
if (funcName && Object.keys(args).length > 0) {
toolCalls.push({
id: 'call_' + Math.random().toString(36).slice(2, 10),
type: 'function',
function: { name: funcName, arguments: JSON.stringify(args) }
});
}
}
if (toolCalls.length > 0) {
this.terminal.print(`✅ XML tool call(s) parsed from ${useModel}`, 'success');
return { message: { role: 'assistant', content: '' }, tool_calls: toolCalls, error: null };
}
return { message: { role: 'assistant', content: text || '(no response)' }, tool_calls: null, error: null };
} catch (e) {
if (e.name === 'AbortError') {
return { message: null, tool_calls: null, error: 'Cancelled' };
}
return { message: null, tool_calls: null, error: e.message };
}
}
// Original Ollama fallback
this.terminal.print(`Model doesn't support tool calling. Retrying /api/chat without tools...`, 'warning');
try {
const body = { model: useModel, messages, stream: false };
const response = await fetch(`${this.endpoint}/api/chat`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
const text = data.message?.content || data.response || '';
const latency = Date.now() - t0;
this._logUsage(provider, useModel, latency, data.prompt_eval_count, data.eval_count);
return { message: { role: 'assistant', content: text }, tool_calls: null, error: null };
} catch (e) {
if (e.name === 'AbortError') {
return { message: null, tool_calls: null, error: 'Cancelled' };
}
return { message: null, tool_calls: null, error: e.message };
}
}
// ─── Usage Tracking ──────────────────────────────────────────────────────
_logUsage(provider, model, latencyMs, promptTokens, completionTokens) {
const entry = {
ts: Date.now(),
provider: provider?.name || 'unknown',
model: model || 'unknown',
latency: Math.round(latencyMs || 0),
prompt_tokens: promptTokens || 0,
completion_tokens: completionTokens || 0,
total_tokens: (promptTokens || 0) + (completionTokens || 0)
};
this._usageBuffer.push(entry);
this._debouncedFlush();
}
_debouncedFlush() {
if (this._usageFlushTimer) clearTimeout(this._usageFlushTimer);
if (this._usageBuffer.length >= 5) {
this._flushUsageLog();
} else {
this._usageFlushTimer = setTimeout(() => this._flushUsageLog(), 2000);
}
}
_flushUsageLog() {
if (this._usageBuffer.length === 0) return;
this._usageLog.push(...this._usageBuffer);
this._usageBuffer = [];
if (this._usageLog.length > 500) {
this._usageLog = this._usageLog.slice(-500);
}
this._saveUsageLog();
}
_loadUsageLog() {
try {
const raw = localStorage.getItem('webx11_ollama_usage_log');
this._usageLog = raw ? JSON.parse(raw) : [];
} catch (e) {
this._usageLog = [];
}
}
_saveUsageLog() {
try {
localStorage.setItem('webx11_ollama_usage_log', JSON.stringify(this._usageLog));
} catch (e) {
// localStorage quota exceeded — trim to 250
this._usageLog = this._usageLog.slice(-250);
try {
localStorage.setItem('webx11_ollama_usage_log', JSON.stringify(this._usageLog));
} catch (e2) {}
}
}
getUsageStats(filters = {}) {
let log = this._usageLog;
if (filters.model) {
log = log.filter(e => e.model === filters.model);
}
if (filters.provider) {
log = log.filter(e => e.provider === filters.provider);
}
if (filters.since_hours) {
const cutoff = Date.now() - (filters.since_hours * 3600 * 1000);
log = log.filter(e => e.ts >= cutoff);
}
const total = log.length;
const totalPrompt = log.reduce((s, e) => s + e.prompt_tokens, 0);
const totalCompletion = log.reduce((s, e) => s + e.completion_tokens, 0);
const totalLatency = log.reduce((s, e) => s + e.latency, 0);
const avgLatency = total > 0 ? Math.round(totalLatency / total) : 0;
// Per-model breakdown
const byModel = {};
for (const e of log) {
if (!byModel[e.model]) byModel[e.model] = { requests: 0, tokens: 0 };
byModel[e.model].requests++;
byModel[e.model].tokens += e.total_tokens;
}
// Per-provider breakdown
const byProvider = {};
for (const e of log) {
if (!byProvider[e.provider]) byProvider[e.provider] = { requests: 0, tokens: 0 };
byProvider[e.provider].requests++;
byProvider[e.provider].tokens += e.total_tokens;
}
// Time span
const timeSpan = total > 1
? Math.round((log[log.length - 1].ts - log[0].ts) / 1000)
: 0;
return { total, totalPrompt, totalCompletion, totalLatency, avgLatency, timeSpan, byModel, byProvider };
}
// ─── Internal helpers ────────────────────────────────────────────────────
_getFallbackChain() {
const sorted = this._getSortedEnabledProviders();
if (sorted.length === 0) return [];
// Start with the current provider (if any), then all others sorted by priority
const chain = [];
const seen = new Set();
if (this.currentProvider && this.currentProvider.enabled) {
chain.push(this.currentProvider);
seen.add(this.currentProvider.name);
}
for (const p of sorted) {
if (!seen.has(p.name)) {
chain.push(p);
seen.add(p.name);
}
}
return chain;
}
// ─── Backward-compat methods ─────────────────────────────────────────────
setEndpoint(url) {
this.endpoint = url;
// Also update the current provider's endpoint if it exists
if (this.currentProvider) {
this.currentProvider.endpoint = url;
this._saveProviders();
}
this.saveSettings();
}
setModel(name) {
this.currentModel = name;
// Also update the current provider's model
if (this.currentProvider) {
this.currentProvider.model = name;
this._saveProviders();
}
this.saveSettings();
this.terminal.print(`Model set to: ${name}`, 'success');
}
saveSettings() {
localStorage.setItem('webx11_ollama_endpoint', this.endpoint);
localStorage.setItem('webx11_ollama_model', this.currentModel);
localStorage.setItem('webx11_optimization_enabled', this.optimizationEnabled ? 'true' : 'false');
localStorage.setItem('webx11_stream_enabled', this.streamEnabled ? 'true' : 'false');
localStorage.setItem('webx11_validator_enabled', this.validatorEnabled ? 'true' : 'false');
localStorage.setItem('webx11_validator_model', this.validatorModel);
localStorage.setItem('webx11_validator_endpoint', this.validatorEndpoint || '');
}
setOptimizationEnabled(val) {
this.optimizationEnabled = !!val;
this.terminal?.print(`Agent optimization ${this.optimizationEnabled ? 'enabled' : 'disabled'}. Reload to apply.`, 'info');
this.saveSettings();
}
setStreamEnabled(val) {
this.streamEnabled = !!val;
this.terminal?.print(`Response streaming ${this.streamEnabled ? 'enabled' : 'disabled'}.`, 'info');
this.terminal?.updateStreamIndicator();
this.saveSettings();
}
setValidatorConfig(enabled, model) {
this.validatorEnabled = !!enabled;
if (model !== undefined) {
this.validatorModel = model;
localStorage.setItem('webx11_validator_model', model);
}
this.saveSettings();
}
async validateToolResult(toolName, args, result, aiAnswer) {
if (!this.validatorEnabled || !this.validatorModel) return true;
const endpoint = this.validatorEndpoint || this.endpoint;
const prompt = 'Tool called: ' + toolName + '(' + JSON.stringify(args) + ')\n' +
'Tool returned: ' + ((result || '').substring(0, 500)) + '\n' +
'AI answered: ' + ((aiAnswer || '').substring(0, 500)) + '\n' +
'QUESTION: Is the AI answer consistent with the tool result?\nAnswer only YES or NO.';
try {
const res = await fetch(endpoint + '/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: this.validatorModel, messages: [{ role: 'user', content: prompt }], stream: false })
});
const data = await res.json();
const answer = (data.message?.content || '').trim().toUpperCase();
return answer.startsWith('YES');
} catch (e) {
if (this.terminal) this.terminal.print('⚠️ Validator error: ' + e.message, 'warning');
return true;
}
}
getModels() { return this.models; }
addResponseHook(fn) { this.responseHooks.push(fn); }
removeResponseHook(fn) { this.responseHooks = this.responseHooks.filter(h => h !== fn); }
fireResponseHooks(text) { if (text) this.responseHooks.forEach(fn => { try { fn(text); } catch (e) {} }); }
abort() {
if (this._abortController) {
this._abortController.abort();
this._abortController = null;
return true;
}
return false;
}
}