// 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; } }