aboutsummaryrefslogtreecommitdiff
path: root/modules/core/ollama-manager.js
diff options
context:
space:
mode:
Diffstat (limited to 'modules/core/ollama-manager.js')
-rw-r--r--modules/core/ollama-manager.js143
1 files changed, 140 insertions, 3 deletions
diff --git a/modules/core/ollama-manager.js b/modules/core/ollama-manager.js
index e3a3fdb..13472fc 100644
--- a/modules/core/ollama-manager.js
+++ b/modules/core/ollama-manager.js
@@ -11,6 +11,15 @@ class OllamaManager {
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 ---
@@ -402,6 +411,7 @@ class OllamaManager {
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`, {
@@ -416,6 +426,7 @@ class OllamaManager {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let isFirstToken = true;
+ let lastObj = null;
while (true) {
const { done, value } = await reader.read();
@@ -432,17 +443,22 @@ class OllamaManager {
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 {
@@ -468,6 +484,7 @@ class OllamaManager {
const decoder = new TextDecoder();
let isFirstToken = true;
let buffer = '';
+ let lastUsage = null;
while (true) {
const { done, value } = await reader.read();
@@ -489,18 +506,23 @@ class OllamaManager {
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;
}
@@ -553,6 +575,7 @@ class OllamaManager {
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`, {
@@ -568,6 +591,7 @@ class OllamaManager {
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;
@@ -582,6 +606,7 @@ class OllamaManager {
fullResponse += obj.response;
onToken && onToken(obj.response, fullResponse);
}
+ lastObj = obj;
} catch (e) { continue; }
}
}
@@ -589,14 +614,19 @@ class OllamaManager {
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);
@@ -624,6 +654,7 @@ class OllamaManager {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
+ let lastUsage = null;
while (true) {
const { done, value } = await reader.read();
@@ -642,16 +673,21 @@ class OllamaManager {
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);
@@ -733,6 +769,7 @@ class OllamaManager {
}
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,
@@ -782,6 +819,7 @@ class OllamaManager {
}
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;
@@ -885,6 +923,7 @@ class OllamaManager {
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');
@@ -906,6 +945,8 @@ class OllamaManager {
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') {
@@ -915,12 +956,10 @@ class OllamaManager {
}
}
- // MiniCPM5 XML tool calling mode — sends tools in message context instead of API parameter
- // Model natively outputs <function name="x"><param name="y">val</param> XML
+ // 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 {
- // Inject tool defs into a system message (the orchestrator model knows XML format)
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:
@@ -942,6 +981,8 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
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 = [];
@@ -991,6 +1032,8 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
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') {
@@ -1000,6 +1043,100 @@ Only output XML function calls for tool tasks. If no tool is needed, answer norm
}
}
+ // ─── 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() {