From 4686af1a4f5e34207c83c0a9b0573432212acef4 Mon Sep 17 00:00:00 2001 From: evangit2 <105747602+evangit2@users.noreply.github.com> Date: Tue, 9 Jun 2026 21:06:39 -0400 Subject: [PATCH 1/4] =?UTF-8?q?release:=20v0.5.0=20=E2=80=94=20usage=20tra?= =?UTF-8?q?cking,=20ROI=20dashboard,=20multi-account,=20live=20usage-level?= =?UTF-8?q?=20scraping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * v0.4.3 — add kimi-k2.6 vision model, default all roles to nemotron-3-nano:30b - KNOWN_CLOUD_MODELS: add kimi-k2.6 with [vision, tools, thinking, cloud] - LLMConfig defaults: reranker/scraper/summary/default all set to nemotron-3-nano:30b - available_models: add nemotron-3-nano:30b and kimi-k2.6 - dashboard.html: add kimi-k2.6 and nemotron-3-nano capability maps - cli.py setup wizard: update all model prompts/default placeholders - version bump: 0.4.2 → 0.4.3 in __init__.py, app.py, pyproject.toml Update-safe: only new model registry entries + default value changes. Existing configs preserve their saved values (pydantic). Zero schema or API break. * feat: cost-weighted analytics (usage_multiplier) + add deepseek-v4 models - Add usage_multiplier column to analytics DB (auto-resolved from model name) - Log weighted token totals (prompt*multiplier, completion*multiplier) in summary - Show cost bars + weighted totals in dashboard analytics tab - Add per-model multiplier column to analytics table - Auto-compute total_tokens in analytics if not provided - Add deepseek-v4-pro (1.0x) and deepseek-v4-flash (0.50x) to KNOWN_CLOUD_MODELS - Add gemini-3-flash-preview to available_models and dashboard capability maps - Add new models to config.py available_models list * feat: auto-infer capabilities and multiplier for unknown models Previously new Ollama Cloud models showed no capability badges and assumed 1.00x multiplier until manually added to KNOWN_CLOUD_MODELS. Now both backend (client.py) and frontend (dashboard.html) fall back to name-based inference for unknown models: - Capabilities: 'vision' from vl/gemma/gemini/kimi/deepseek, 'tools' from coder/minimax/glm/mistral/gpt-oss/devstral/nemotron/deepseek, 'thinking' from deepseek/cogito/reason/think and kimi-k* families - Multiplier: extract :XXb|:Xt from model name for size-based tier (<=20b=0.25, <=80b=0.50, <=400b=0.75, >400b/1t=1.00), then fall back to name heuristics (nano/mini/small=0.25, flash/gemma=0.50, pro/mistral-large=1.00) Models explicitly in KNOWN_CLOUD_MODELS still use exact values. This means new models like deepseek-v5-pro or kimi-k3 show up correctly in the dashboard immediately without code changes. * feat: experimental Subscription Value Calculator (ROI) vs OpenRouter Adds a new experimental feature to the Status tab that compares your total subscription cost to what you'd pay with OpenRouter's cheapest provider pricing. Backend: - guanaco/roi.py: Fetches live prices from OpenRouter API, matches Ollama model names to OpenRouter model IDs via family inference (exact → family prefix → same parameter size → size window → global average), multiplies tokens by $/Mt cost. - Calculates this week's cost, extrapolates to 100% weekly usage, then monthly. Shows ROI multiplier (monthly value / subscription). - /dashboard/api/roi/calculate: fresh calculation - /dashboard/api/roi/last: cached last result - /dashboard/api/roi/reset: clear data - /dashboard/api/roi/config: enable/disable, set price tier Dashboard (Status tab): - Toggle to enable/disable the feature - Pro (0/mo) / Max (00/mo) / Custom price selector - Shows: this-week cost, weekly @ 100%, monthly value, ROI multiplier - Per-model table: prompt_tokens, completion_tokens, $/Mt in/out, total cost, % of usage - Warnings for unmatched models and stale prices - Reset button clears cached data ROIConfig added to config.py (persisted in config.yaml). * fix(config): v0.4.3 backward compat and install robustness Changes: - Add missing UsageConfig fields (last_plan, redirect_on_full, last_session_reset, last_weekly_reset, last_checked) to fix AttributeError crashes - Add config migration layer in load_config() for v0.4.2 to v0.4.3+ configs - Rename package guanaco to guanaco-llm-proxy to avoid PyPI collision - Add Ollama API key validation during install.sh setup (fixes: use real key var, fix env file write, fix grep pattern) - Add startup version sanity check detecting repo/venv mismatch - Fix systemd service WorkingDirectory to repo checkout - Add GUANACO_CONFIG_DIR env var to systemd service * feat(v0.4.3): token estimation fallback, ROI dashboard, per-model usage breakdown Token tracking: - analytics.py: fall back to skimtoken estimation when API omits usage (~15% error, better than zero). Proper total_tokens = prompt + completion. Removed broken usage_multiplier column. - router.py: unchanged — already passes usage → analytics correctly Dashboard / ROI: - roi.py: OpenRouter price fetcher with cache-hit discount logic - dashboard.py: expose ROI config with cache_hit_pct slider - dashboard.html: cost breakdown UI, per-model value scoring Models: - client.py: added minimax-m3, nemotron-3-ultra, kimi-k2.6 200k context - client.py: per-model Ollama usage breakdown scraping from /settings Config: - config.py: backward compat for installs missing SearchConfig * feat(usage-levels): scrape real per-tag usage tiers from ollama.com library pages - Add _fetch_usage_level_sync() to parse ollama.com/library/{model} HTML - Handle both top-level model badges (x-test-model-cost-slot-active) and per-tag listings (x-test-model-tag-cost + x-test-model-tag-usage-slot-active) - Add async fetch_usage_levels() with 1h global cache + parallel fetching - Wire usage_multiplier into /v1/models and /api/ollama/models responses - Replace heuristic _get_model_multiplier fallback with real scraped levels - Fixes gemma3:4b/12b showing 1.00x instead of 0.25x, qwen3-vl 0.25x→0.75x, etc. --------- Co-authored-by: evangit2 Co-authored-by: Evan Rice --- .gitignore | 2 +- guanaco/__init__.py | 2 +- guanaco/analytics.py | 21 + guanaco/app.py | 2 +- guanaco/cli.py | 70 ++- guanaco/client.py | 307 +++++++++++-- guanaco/config.py | 78 +++- guanaco/dashboard/dashboard.py | 104 +++++ guanaco/dashboard/templates/dashboard.html | 511 ++++++++++++++++++++- guanaco/roi.py | 473 +++++++++++++++++++ guanaco/router/router.py | 95 +++- install.sh | 22 + pyproject.toml | 4 +- 13 files changed, 1602 insertions(+), 89 deletions(-) create mode 100644 guanaco/roi.py diff --git a/.gitignore b/.gitignore index 0850ce1..d9db564 100644 --- a/.gitignore +++ b/.gitignore @@ -87,4 +87,4 @@ dmypy.json Thumbs.db # Project-specific -.oct/ \ No newline at end of file +.oct/.venv-test/ diff --git a/guanaco/__init__.py b/guanaco/__init__.py index ee3f884..5bf932f 100644 --- a/guanaco/__init__.py +++ b/guanaco/__init__.py @@ -3,7 +3,7 @@ # Single source of truth for version. # importlib.metadata can return stale values after git-pull without re-pip-install, # so we always use the hardcoded fallback and only override if metadata matches. -__version__ = "0.4.2-dev" +__version__ = "0.4.3" try: from importlib.metadata import version as _version diff --git a/guanaco/analytics.py b/guanaco/analytics.py index 8fb6212..b57f8c5 100644 --- a/guanaco/analytics.py +++ b/guanaco/analytics.py @@ -15,6 +15,11 @@ import uuid from pathlib import Path from typing import Optional +try: + from skimtoken.multilingual_simple import estimate_tokens as _estimate_tokens +except Exception: + _estimate_tokens = None + def _default_db_path() -> Path: from guanaco.config import get_default_config_dir @@ -159,6 +164,22 @@ class AnalyticsLogger: # Normalize model name so glm-5.1:cloud and glm-5.1 are grouped together model = _normalize_model_name(model) fallback_for = _normalize_model_name(fallback_for) if fallback_for else fallback_for + + # Fallback: if API returned zeros or None but we have text, estimate tokens + # using skimtoken for multilingual/CJK-aware approximation (~15% error). + # This prevents silently losing token data when providers omit the usage block. + if (not prompt_tokens or prompt_tokens == 0) and input_text: + if _estimate_tokens is not None: + prompt_tokens = max(1, _estimate_tokens(input_text)) + else: + prompt_tokens = max(1, len(input_text) // 3) + if (not completion_tokens or completion_tokens == 0) and output_text: + if _estimate_tokens is not None: + completion_tokens = max(1, _estimate_tokens(output_text)) + else: + completion_tokens = max(1, len(output_text) // 4) + total_tokens = prompt_tokens + completion_tokens + entry_id = str(uuid.uuid4()) with sqlite3.connect(self.db_path) as conn: conn.execute( diff --git a/guanaco/app.py b/guanaco/app.py index 117876f..0efa761 100644 --- a/guanaco/app.py +++ b/guanaco/app.py @@ -14,7 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware from guanaco.config import load_config, get_config, AppConfig, get_base_url, get_tailscale_ip from guanaco.client import OllamaClient from guanaco.accounts import AccountPool -__version__ = "0.4.2" +__version__ = "0.4.3" from guanaco.router.router import create_router as create_llm_router from guanaco.search.providers import ALL_PROVIDERS from guanaco.dashboard import create_dashboard_router diff --git a/guanaco/cli.py b/guanaco/cli.py index d30dea9..a088e3b 100644 --- a/guanaco/cli.py +++ b/guanaco/cli.py @@ -159,10 +159,10 @@ def _run_setup(): print("\n📡 LLM Configuration") print(" Available Ollama Cloud models: qwen3:480b, gpt-oss:120b, deepseek-v3.1, oss120b") print(" Also: qwen3.5:122b, glm-5.1, minimax-m2.7, llama4:109b, etc.") - reranker = input("Reranker model [oss120b]: ").strip() or "oss120b" - scraper = input("Scraper model [qwen3:480b]: ").strip() or "qwen3:480b" - summary = input("Summary model [qwen3:480b]: ").strip() or "qwen3:480b" - default_model = input("Default chat model [qwen3:480b]: ").strip() or "qwen3:480b" + reranker = input("Reranker model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b" + scraper = input("Scraper model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b" + summary = input("Summary model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b" + default_model = input("Default chat model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b" emulate_anthropic = input("Enable Anthropic /v1/messages emulation? [Y/n]: ").strip().lower() != "n" emulate_openai = input("Enable OpenAI /v1/chat/completions? [Y/n]: ").strip().lower() != "n" @@ -379,6 +379,9 @@ def _run_start(args): config = load_config() + # ── Version sanity check: repo vs installed package ── + _check_version_sanity() + if args.host: config.router.host = args.host if args.port: @@ -802,5 +805,64 @@ def _run_config(args): print(f" {en} {name} {key_status}") +def _check_version_sanity(): + """Warn if the installed package is out of sync with the repo checkout. + + Detects the common footgun where: + - install.sh clones to ~/.guanaco/repo and does `pip install -e .` + - But later someone edits the repo code without reinstalling + - Or installs a different version from PyPI over the editable install + """ + import importlib.util + from pathlib import Path + + try: + # Where does `guanaco` load from? + spec = importlib.util.find_spec("guanaco") + if spec is None or spec.origin is None: + return # can't determine, skip + installed_path = Path(spec.origin).resolve() + + # Check if it's an editable install (points into a repo checkout) + is_editable = False + repo_root = None + if installed_path.parts: + # Walk up to find .git + for parent in installed_path.parents: + if (parent / ".git").is_dir(): + is_editable = True + repo_root = parent + break + + # Read __version__ from the installed package + from guanaco import __version__ as installed_version + + if repo_root: + # Compare with repo __init__.py version + repo_init = repo_root / "guanaco" / "__init__.py" + if repo_init.exists(): + repo_version = "unknown" + for line in repo_init.read_text().splitlines(): + if '__version__' in line and '=' in line: + repo_version = line.split('=')[1].strip().strip('"').strip("'") + break + if repo_version != installed_version: + print(f"⚠️ VERSION MISMATCH DETECTED") + print(f" Installed package: v{installed_version} at {installed_path}") + print(f" Repo checkout: v{repo_version} at {repo_root}") + print(f" Fix: cd {repo_root} && pip install -e .") + print() + + # Also warn if installed from PyPI (site-packages) rather than editable + elif "site-packages" in str(installed_path): + print(f"⚠️ Installed from PyPI/site-packages, not editable install:") + print(f" {installed_path}") + print(f" If you're developing, use: pip install -e .") + print() + + except Exception: + pass # Don't crash startup for a sanity check + + if __name__ == "__main__": main() \ No newline at end of file diff --git a/guanaco/client.py b/guanaco/client.py index 4f3660f..ae7b9aa 100644 --- a/guanaco/client.py +++ b/guanaco/client.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import time import logging +import re from typing import Optional import httpx @@ -20,39 +21,51 @@ OLLAMA_FETCH_URL = f"{OLLAMA_BASE}/api/web_fetch" OLLAMA_USAGE_URL = f"{OLLAMA_BASE}/api/account/usage" OLLAMA_SETTINGS_URL = f"{OLLAMA_BASE}/api/account/settings" +# Usage-level cache: maps model name → level (1-4) +_USAGE_LEVEL_CACHE: dict[str, int] = {} +_USAGE_LEVEL_CACHE_TIME: float = 0 +_USAGE_LEVEL_CACHE_TTL: float = 3600 # 1 hour + # Known cloud models (fallback + display info) # Names must match /v1/models response (e.g. "gemma4:31b", "qwen3.5:397b") +# usage_multiplier: relative GPU cost tier (0.25, 0.50, 0.75, 1.00) pulled from +# ollama.com model pages — used for weighted analytics + visual cost badges. KNOWN_CLOUD_MODELS = { - "gemma4": {"sizes": ["31b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"]}, - "gemma3": {"sizes": ["4b", "12b", "27b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"]}, - "qwen3.5": {"sizes": ["397b"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"]}, - "qwen3-vl": {"sizes": ["235b", "235b-instruct"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"]}, - "qwen3-coder": {"sizes": ["480b"], "family": "qwen", "capabilities": ["tools", "cloud"]}, - "qwen3-coder-next": {"sizes": [], "family": "qwen", "capabilities": ["tools", "cloud"]}, - "qwen3-next": {"sizes": ["80b"], "family": "qwen", "capabilities": ["tools", "thinking", "cloud"]}, - "minimax-m2": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]}, - "minimax-m2.7": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]}, - "minimax-m2.5": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]}, - "minimax-m2.1": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]}, - "glm-5.1": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]}, - "glm-5": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]}, - "glm-4.7": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]}, - "glm-4.6": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]}, - "gpt-oss": {"sizes": ["20b", "120b"], "family": "gpt-oss", "capabilities": ["tools", "thinking", "cloud"]}, - "deepseek-v3.1": {"sizes": ["671b"], "family": "deepseek", "capabilities": ["thinking", "cloud"]}, - "deepseek-v3.2": {"sizes": [], "family": "deepseek", "capabilities": ["thinking", "cloud"]}, - "devstral-small-2": {"sizes": ["24b"], "family": "devstral", "capabilities": ["tools", "cloud"]}, - "devstral-2": {"sizes": ["123b"], "family": "devstral", "capabilities": ["tools", "cloud"]}, - "nemotron-3-super": {"sizes": [], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"]}, - "nemotron-3-nano": {"sizes": ["30b"], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"]}, - "mistral-large-3": {"sizes": ["675b"], "family": "mistral", "capabilities": ["tools", "thinking", "cloud"]}, - "ministral-3": {"sizes": ["3b", "8b", "14b"], "family": "mistral", "capabilities": ["tools", "cloud"]}, - "kimi-k2.5": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "cloud"]}, - "kimi-k2-thinking": {"sizes": [], "family": "kimi", "capabilities": ["thinking", "cloud"]}, - "kimi-k2": {"sizes": ["1t"], "family": "kimi", "capabilities": ["tools", "thinking", "cloud"]}, - "cogito-2.1": {"sizes": ["671b"], "family": "cogito", "capabilities": ["thinking", "cloud"]}, - "gemini-3-flash-preview": {"sizes": [], "family": "gemini", "capabilities": ["vision", "tools", "thinking", "cloud"]}, - "rnj-1": {"sizes": ["8b"], "family": "rnj", "capabilities": ["tools", "cloud"]}, + "gemma4": {"sizes": ["31b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "gemma3": {"sizes": ["4b", "12b", "27b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "qwen3.5": {"sizes": ["397b"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 1.00}, + "qwen3-vl": {"sizes": ["235b", "235b-instruct"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75}, + "qwen3-coder": {"sizes": ["480b"], "family": "qwen", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.75}, + "qwen3-coder-next": {"sizes": [], "family": "qwen", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.75}, + "qwen3-next": {"sizes": ["80b"], "family": "qwen", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.75}, + "minimax-m2": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "minimax-m2.7": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "minimax-m2.5": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "minimax-m2.1": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "glm-5.1": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.75}, + "glm-5": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.75}, + "glm-4.7": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "glm-4.6": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "gpt-oss": {"sizes": ["20b", "120b"], "family": "gpt-oss", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "deepseek-v3.1": {"sizes": ["671b"], "family": "deepseek", "capabilities": ["thinking", "cloud"], "usage_multiplier": 1.00}, + "deepseek-v3.2": {"sizes": [], "family": "deepseek", "capabilities": ["thinking", "cloud"], "usage_multiplier": 1.00}, + "deepseek-v4-pro": {"sizes": [], "family": "deepseek", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 1.00}, + "deepseek-v4-flash": {"sizes": [], "family": "deepseek", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "devstral-small-2": {"sizes": ["24b"], "family": "devstral", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.50}, + "devstral-2": {"sizes": ["123b"], "family": "devstral", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.75}, + "nemotron-3-super": {"sizes": [], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "nemotron-3-nano": {"sizes": ["30b"], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.25}, + "mistral-large-3": {"sizes": ["675b"], "family": "mistral", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 1.00}, + "ministral-3": {"sizes": ["3b", "8b", "14b"], "family": "mistral", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.25}, + "kimi-k2.6": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75, "context_length": 200000}, + "kimi-k2.5": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75}, + "kimi-k2-thinking": {"sizes": [], "family": "kimi", "capabilities": ["thinking", "cloud"], "usage_multiplier": 0.75}, + "kimi-k2": {"sizes": ["1t"], "family": "kimi", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 1.00}, + "cogito-2.1": {"sizes": ["671b"], "family": "cogito", "capabilities": ["thinking", "cloud"], "usage_multiplier": 1.00}, + "gemini-3-flash-preview": {"sizes": [], "family": "gemini", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50}, + "rnj-1": {"sizes": ["8b"], "family": "rnj", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.25}, + "minimax-m3": {"sizes": [], "family": "minimax", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75}, + "nemotron-3-ultra": {"sizes": [], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 1.00}, } @@ -68,6 +81,100 @@ class OllamaClient: self._models_cache_time: float = 0 self._models_cache_ttl: float = 300.0 # 5 minutes + @staticmethod + def _fetch_usage_level_sync(model_name: str) -> int: + """Scrape Ollama.com library page to count usage slots (1-4). + + Handles both top-level model badges and per-tag listings. + Returns 0 if the model page can't be found. + """ + import urllib.request + base = model_name.split(":")[0] + tag = model_name.split(":")[1] if ":" in model_name else None + url = f"https://ollama.com/library/{base}" + try: + req = urllib.request.Request(url, headers={"User-Agent": "Guanaco/1.0"}) + with urllib.request.urlopen(req, timeout=10) as resp: + html = resp.read().decode("utf-8", errors="replace") + + # 1) Top-level model badge (unified tier models) + top_active = len(re.findall(r'x-test-model-cost-slot-active', html)) + if top_active > 0: + return min(top_active, 4) + + # 2) Per-tag listing — parse each tag's usage slots + # The page shows tags in order; split by cost containers and + # match each cost section with the preceding tag name. + tag_levels: dict[str, int] = {} + sections = re.split(r'x-test-model-tag-cost', html) + for i in range(len(sections) - 1): + # Tag name is in the current section (last command input) + inputs = re.findall(r'value="' + re.escape(base) + r':([^"]+)"', sections[i]) + if not inputs: + continue + tag = inputs[-1].replace("-cloud", "") + # Cost slots are in the next section, before the next tag name + cost_part = re.split(r'value="' + re.escape(base) + r':', sections[i + 1])[0] + active = cost_part.count('x-test-model-tag-usage-slot-active') + if active > 0: + tag_levels[tag] = active + + # If we were asked for a specific tag, return its level + if tag and tag in tag_levels: + return min(tag_levels[tag], 4) + + # Otherwise return the max level across all tags (model's highest tier) + if tag_levels: + return min(max(tag_levels.values()), 4) + + # 3) Raw fallback: count all tag slots + raw_active = len(re.findall(r'x-test-model-tag-usage-slot-active', html)) + return min(raw_active, 4) if raw_active > 0 else 0 + except Exception: + return 0 + + async def fetch_usage_levels(self, model_names: list[str]) -> dict[str, int]: + """Fetch usage levels for multiple models in parallel. + + Results are cached globally for _USAGE_LEVEL_CACHE_TTL seconds. + Returns dict {model_name: level} where level is 1-4 (0 = unknown). + """ + global _USAGE_LEVEL_CACHE, _USAGE_LEVEL_CACHE_TIME + now = time.time() + # Refresh cache if stale + if now - _USAGE_LEVEL_CACHE_TIME > _USAGE_LEVEL_CACHE_TTL: + _USAGE_LEVEL_CACHE.clear() + _USAGE_LEVEL_CACHE_TIME = now + + # Deduplicate base names + to_fetch = [] + results: dict[str, int] = {} + for name in model_names: + base = name.split(":")[0] + if base in _USAGE_LEVEL_CACHE: + results[name] = _USAGE_LEVEL_CACHE[base] + elif base not in to_fetch: + to_fetch.append(base) + + if to_fetch: + import asyncio + loop = asyncio.get_event_loop() + # Run blocking scrapes in thread pool + tasks = [loop.run_in_executor(None, self._fetch_usage_level_sync, m) for m in to_fetch] + levels = await asyncio.gather(*tasks, return_exceptions=True) + for base, raw in zip(to_fetch, levels): + if isinstance(raw, Exception): + _USAGE_LEVEL_CACHE[base] = 0 + else: + _USAGE_LEVEL_CACHE[base] = raw # type: ignore[reportArgumentType] + + # Fill in results for all requested names + for name in model_names: + if name not in results: + base = name.split(":")[0] + results[name] = _USAGE_LEVEL_CACHE.get(base, 0) + return results + async def _get_client(self, api_key_override: Optional[str] = None) -> httpx.AsyncClient: """Get or create the httpx client, optionally with a different API key. @@ -182,38 +289,104 @@ class OllamaClient: return model_name in available_names or f"{model_name}-cloud" in available_names async def get_cloud_models(self) -> list[dict]: - """Get list of cloud-capable models with metadata.""" + """Get list of cloud-capable models with metadata. + + Fetches real usage levels from ollama.com library pages and includes + them as usage_multiplier (0.25-1.00) alongside capabilities. + """ models = await self.list_models() + # Fetch real usage levels from ollama.com + model_names = [m.get("name", m.get("model", "")) for m in models] + usage_levels = await self.fetch_usage_levels(model_names) + cloud_models = [] for m in models: name = m.get("name", m.get("model", "")) details = m.get("details", {}) - # Check if model has cloud capability (or is available via cloud API) - is_cloud = True # All models from /api/tags with auth are cloud-available - size_info = details.get("parameter_size", "") - family = details.get("family", "") - quant = details.get("quantization_level", "") + level = usage_levels.get(name, 0) + multiplier = level * 0.25 if level else self._get_model_multiplier(name) cloud_models.append({ "name": name, "display_name": name.replace("-cloud", ""), "size_bytes": m.get("size", 0), - "parameter_size": size_info, - "family": family, - "quantization": quant, + "parameter_size": details.get("parameter_size", ""), + "family": details.get("family", ""), + "quantization": details.get("quantization_level", ""), "capabilities": self._get_model_capabilities(name), + "usage_multiplier": multiplier, + "usage_level": level, # 1-4, 0 = unknown "modified_at": m.get("modified_at", ""), "digest": m.get("digest", "")[:12] if m.get("digest") else "", }) return cloud_models def _get_model_capabilities(self, model_name: str) -> list[str]: - """Get known capabilities for a model name.""" + """Get known capabilities for a model name. Falls back to name-based inference.""" base_name = model_name.split(":")[0].replace("-cloud", "") if base_name in KNOWN_CLOUD_MODELS: return KNOWN_CLOUD_MODELS[base_name].get("capabilities", ["cloud"]) - # Default capabilities for unknown models - return ["cloud"] + # ── Inference for unknown new models ── + lc = base_name.lower() + caps = ["cloud"] + # vision: VL models, gemma, gemini, kimi, deepseek (frontier), anything with "vision" in name + if any(k in lc for k in ("vl", "vision", "gemma", "gemini", "deepseek")) or lc.startswith("kimi-"): + caps.append("vision") + # tools: explicit coder/minimax/glm/mistral/gpt-oss/devstral/nemotron families, deepseek + if any(k in lc for k in ("coder", "minimax", "glm-", "mistral", "ministral", + "gpt-oss", "devstral", "nemotron", "deepseek", "rnj-1")): + caps.append("tools") + # thinking: deepseek, cogito, reasoning, think suffixes, kimi-k2* except k2.5/2.6, any kimi-k* with large sizes + if any(k in lc for k in ("deepseek", "cogito", "reason", "-thinking", "think")): + caps.append("thinking") + elif lc.startswith("kimi-k") and not ("k2.5" in lc or "k2.6" in lc): + # kimi-k2 (1t) and future kimi-k3, k4 etc are reasoning models + caps.append("thinking") + # Deduplicate and sort for consistency + return sorted(set(caps)) + + def _get_model_multiplier(self, model_name: str) -> float: + """Get usage multiplier (cost tier) for a model name. Falls back to size-based inference.""" + base_name = model_name.split(":")[0].replace("-cloud", "") + if base_name in KNOWN_CLOUD_MODELS: + return KNOWN_CLOUD_MODELS[base_name].get("usage_multiplier", 1.00) + # ── Inference from parameter size hints in the name ── + lc = base_name.lower() + # Extract size hint like ":30b" or "-30b" from the full model name + size_match = None + for part in model_name.replace("-cloud", "").split(":"): + m = __import__("re").search(r"(\d+)(b|t)", part, __import__("re").I) + if m: + num = int(m.group(1)) + unit = m.group(2).lower() + # If unit is 't' (trillion), treat as very large + if unit == "t": + return 1.00 + size_match = num + break + if size_match is not None: + if size_match <= 20: + return 0.25 + elif size_match <= 80: + return 0.50 + elif size_match <= 400: + return 0.75 + else: + return 1.00 + # Fallback: use name heuristics when no size hint + if any(k in lc for k in ("nano", "mini", "small", "rnj-1")): + return 0.25 + if any(k in lc for k in ("flash", "gemma", "gpt-oss", "minimax", "devstral-small", + "glm-4.", "super")): + return 0.50 + if any(k in lc for k in ("kimi-k", "qwen3-vl", "qwen3-coder", "qwen3-next", + "devstral-2", "glm-5")): + return 0.75 + if any(k in lc for k in ("pro", "qwen3.5", "deepseek-v3", "mistral-large", + "cogito", "kimi-k2:1t")): + return 1.00 + # Safest default — unknown might be expensive + return 1.00 # ── Usage / Quota ── @@ -258,6 +431,11 @@ class OllamaClient: Weekly usage 30.9% used ... Resets in 3 days + + Per-model breakdown (new feature): +
+
""" import re result = {} @@ -300,6 +478,45 @@ class OllamaClient: if plan_match: result["plan"] = plan_match.group(1).strip().lower() + # ── Per-model usage breakdown ── + # Find the two data-usage-track containers (session first, weekly second) + usage_tracks = re.findall( + r'data-usage-track[^\u003e]*aria-label="([^"]*usage[^"]*)"[^\u003e]*\u003e(.*?)\u003c/div\u003e\s*\u003c/div\u003e', + html, re.DOTALL | re.IGNORECASE + ) + + session_breakdown = [] + weekly_breakdown = [] + + for aria_label, track_html in usage_tracks: + # Extract segments within this track + # Each segment is a + +

+ Compares your Ollama subscription vs pay-as-you-go pricing on OpenRouter. + Shows per-model $/Mt cost and total value you get. +

+ +
+ + + + +
+ +
+ + + Models with OpenRouter cache pricing get discounted accordingly +
+ + + + + +
+ Enable the ROI calculator above to see subscription value estimates. +
+ +

📋 Event Log

@@ -810,7 +911,7 @@ function showTab(tab) { if (tab === 'cache') loadCacheStats(); if (tab === 'analytics') loadAnalytics(); if (tab === 'history') { loadHistoryConfig(); loadHistory(); loadHistoryLogs(); } - if (tab === 'status') { checkOllamaStatus(); loadUsageConfig(); checkUsage(); loadStatusLog(); } + if (tab === 'status') { checkOllamaStatus(); loadUsageConfig(); checkUsage(); loadStatusLog(); loadROIConfig(); } if (tab === 'accounts') { loadAccounts(); checkAllAccountUsage(); } if (tab === 'system') { loadAutostart(); checkForUpdate(); } } @@ -1208,7 +1309,7 @@ function loadModels() { // Fallback to config model list when API is down const cfg = CONFIG.llm || {}; const names = cfg.available_models || []; - const fallbackModels = names.map(n => ({name: n, model: n, id: n, capabilities: _getCapabilities(n)})); + const fallbackModels = names.map(n => ({name: n, model: n, id: n, capabilities: _getCapabilities(n), usage_multiplier: _getMultiplier(n)})); availableModels = fallbackModels; document.getElementById('models-loading').style.display = 'none'; document.getElementById('models-loading').textContent = ''; @@ -1237,6 +1338,9 @@ function renderModels(models) { const paramSize = details.parameter_size || ''; const quant = details.quantization_level || ''; const caps = m.capabilities || ['cloud']; + const mult = typeof m.usage_multiplier === 'number' ? m.usage_multiplier : (m.usage_multiplier || 0.75); + const filled = Math.round(mult * 4); // 4 slots, 0.25 = 1 filled, 1.00 = 4 filled + const slots = [0,1,2,3].map(i => ``).join(''); const capHtml = caps.map(c => `${c}`).join(''); return `
${display}
@@ -1246,6 +1350,7 @@ function renderModels(models) { ${quant ? `⚡ ${quant}` : ''}
${capHtml}
+
${slots}${mult.toFixed(2)}x
`; }).join(''); } @@ -1332,6 +1437,7 @@ function loadAnalytics() {
${m.requests}
${(m.prompt_tokens || 0).toLocaleString()}
${(m.completion_tokens || 0).toLocaleString()}
+
${((m.prompt_tokens || 0) + (m.completion_tokens || 0)).toLocaleString()}
${m.avg_tps || '—'}
${m.avg_ttft ? (m.avg_ttft * 1000).toFixed(0) + 'ms' : '—'}
@@ -1359,9 +1465,10 @@ function loadAnalytics() { `).join('') : '
No errors 🎉
'; - // Top stats + // Top stats — show raw tokens as primary count document.getElementById('stat-requests').textContent = (data.total_requests || 0).toLocaleString(); - document.getElementById('stat-tokens').textContent = ((data.prompt_tokens || 0) + (data.completion_tokens || 0)).toLocaleString(); + document.getElementById('stat-tokens').textContent = (data.total_tokens || 0).toLocaleString(); + document.getElementById('stat-tokens').nextElementSibling.textContent = 'Tokens'; document.getElementById('stat-tps').textContent = data.avg_tps || 0; document.getElementById('stat-ttft').textContent = data.avg_ttft ? (data.avg_ttft * 1000).toFixed(0) + 'ms' : '—'; document.getElementById('stat-keys').textContent = KEYS.length; @@ -1679,6 +1786,8 @@ function checkUsage() { const weeklyBar = document.getElementById('usage-weekly-bar'); const sessionReset = document.getElementById('usage-session-reset'); const weeklyReset = document.getElementById('usage-weekly-reset'); + const sessionBreakdown = document.getElementById('usage-session-breakdown'); + const weeklyBreakdown = document.getElementById('usage-weekly-breakdown'); const lastCheckedRow = document.getElementById('usage-last-checked-row'); const lastCheckedEl = document.getElementById('usage-last-checked'); planEl.textContent = 'Loading...'; @@ -1693,6 +1802,8 @@ function checkUsage() { weeklyBar.style.width = '0%'; sessionReset.textContent = ''; weeklyReset.textContent = ''; + sessionBreakdown.innerHTML = ''; + weeklyBreakdown.innerHTML = ''; lastCheckedRow.style.display = 'none'; } else { const plan = data.plan ? data.plan.charAt(0).toUpperCase() + data.plan.slice(1) : '—'; @@ -1715,6 +1826,72 @@ function checkUsage() { // Reset timers sessionReset.textContent = data.session_reset ? '⏱ Resets in ' + data.session_reset : ''; weeklyReset.textContent = data.weekly_reset ? '⏱ Resets in ' + data.weekly_reset : ''; + // Per-model breakdowns + function renderBreakdown(breakdown, container, idSuffix) { + if (!breakdown || breakdown.length === 0) { + container.innerHTML = ''; + return; + } + // Sort by percentage descending + const sorted = [...breakdown].sort((a, b) => b.pct - a.pct); + + // Separate significant vs tiny + const significant = []; + const tiny = []; + for (const b of sorted) { + if (b.pct >= 1.0) { + significant.push(b); + } else { + tiny.push(b); + } + } + + // Always show top 3 at minimum, even if tiny + while (significant.length < 3 && tiny.length > 0) { + significant.push(tiny.shift()); + } + + const hasMore = tiny.length > 0; + const all = [...significant, ...tiny]; + + function makeRow(b, hidden) { + const pctStr = b.pct >= 0.1 ? b.pct.toFixed(1) + '%' : '< 0.1%'; + return ` +
+ ${b.model} + ${b.requests.toLocaleString()} req · ${pctStr} +
+ `; + } + + const topHtml = significant.map(b => makeRow(b, false)).join(''); + const restHtml = tiny.map(b => makeRow(b, true)).join(''); + + const expandId = 'ub-expand-' + idSuffix; + const collapseId = 'ub-collapse-' + idSuffix; + + container.innerHTML = ` + +
+ ${topHtml} +
+ +
+ +
+ `; + } + renderBreakdown(data.session_breakdown, sessionBreakdown, 'session'); + renderBreakdown(data.weekly_breakdown, weeklyBreakdown, 'weekly'); // Last checked if (data.last_checked) { lastCheckedRow.style.display = ''; @@ -1772,6 +1949,8 @@ function clearSessionCookie() { document.getElementById('usage-weekly-bar').style.width = '0%'; document.getElementById('usage-session-reset').textContent = ''; document.getElementById('usage-weekly-reset').textContent = ''; + document.getElementById('usage-session-breakdown').innerHTML = ''; + document.getElementById('usage-weekly-breakdown').innerHTML = ''; } setTimeout(() => { statusEl.textContent = ''; statusEl.style.color = ''; }, 3000); }); @@ -2035,20 +2214,82 @@ function _getCapabilities(modelName) { 'gpt-oss': ['cloud','tools','thinking'], 'deepseek-v3.1': ['cloud','thinking'], 'deepseek-v3.2': ['cloud','thinking'], + 'deepseek-v4-pro': ['cloud','tools','thinking','vision'], + 'deepseek-v4-flash': ['cloud','tools','thinking','vision'], + 'gemini-3-flash-preview': ['cloud','tools','thinking','vision'], 'glm-5.1': ['cloud','tools','thinking'], 'glm-5': ['cloud','tools','thinking'], 'minimax-m2.7': ['cloud','tools','thinking'], 'minimax-m2.5': ['cloud','tools','thinking'], 'minimax-m2.1': ['cloud','tools','thinking'], + 'minimax-m2': ['cloud','tools','thinking'], + 'minimax-m3': ['cloud','tools','thinking','vision'], 'devstral-small-2': ['cloud','tools'], 'devstral-2': ['cloud','tools'], 'nemotron-3-super': ['cloud','tools','thinking'], + 'nemotron-3-nano': ['cloud','tools','thinking'], + 'nemotron-3-ultra': ['cloud','tools','thinking'], 'mistral-large-3': ['cloud','tools','thinking'], 'ministral-3': ['cloud','tools'], + 'kimi-k2.6': ['cloud','tools','thinking','vision'], 'kimi-k2.5': ['cloud','tools','thinking','vision'], 'cogito-2.1': ['cloud','thinking'], }; - return known[base] || ['cloud']; + if (known[base]) return known[base]; + // ── Inference for unknown models ── + const lc = base.toLowerCase(); + const caps = ['cloud']; + if (lc.includes('vl') || lc.includes('vision') || lc.includes('gemma') || lc.includes('gemini') || lc.includes('deepseek') || lc.startsWith('kimi-')) + caps.push('vision'); + if (lc.includes('coder') || lc.includes('minimax') || lc.startsWith('glm-') || lc.includes('mistral') || + lc.includes('ministral') || lc.includes('gpt-oss') || lc.includes('devstral') || lc.includes('nemotron') || lc.includes('deepseek') || lc.includes('rnj-1')) + caps.push('tools'); + if (lc.includes('deepseek') || lc.includes('cogito') || lc.includes('reason') || lc.includes('-thinking') || lc.includes('think')) + caps.push('thinking'); + else if (lc.startsWith('kimi-k') && !(lc.includes('k2.5') || lc.includes('k2.6'))) + caps.push('thinking'); + // deduplicate + return [...new Set(caps)].sort(); +} + +function _getMultiplier(modelName) { + const base = modelName.split(':')[0]; + const mults = { + 'nemotron-3-nano': 0.25, 'ministral-3': 0.25, 'rnj-1': 0.25, + 'gemma4': 0.50, 'gemma3': 0.50, 'minimax-m2.7': 0.50, 'minimax-m2.5': 0.50, 'minimax-m2.1': 0.50, 'minimax-m2': 0.50, + 'gpt-oss': 0.50, 'devstral-small-2': 0.50, 'nemotron-3-super': 0.50, 'gemini-3-flash-preview': 0.50, + 'glm-4.7': 0.50, 'glm-4.6': 0.50, + 'qwen3-vl': 0.75, 'qwen3-coder': 0.75, 'qwen3-next': 0.75, 'devstral-2': 0.75, + 'glm-5.1': 0.75, 'glm-5': 0.75, + 'kimi-k2.6': 0.75, 'kimi-k2.5': 0.75, 'kimi-k2-thinking': 0.75, + 'qwen3.5': 1.00, 'deepseek-v3.1': 1.00, 'deepseek-v3.2': 1.00, 'deepseek-v4-pro': 1.00, + 'mistral-large-3': 1.00, 'cogito-2.1': 1.00, 'kimi-k2': 1.00, + 'deepseek-v4-flash': 0.50, 'gemini-3-flash-preview': 0.50, + 'minimax-m3': 0.75, + 'nemotron-3-ultra': 1.00, + }; + if (mults[base]) return mults[base]; + // ── Inference from size hint ── + const m = modelName.match(/(\d+)(b|t)/i); + if (m) { + const num = parseInt(m[1], 10); + const unit = m[2].toLowerCase(); + if (unit === 't') return 1.00; + if (num <= 20) return 0.25; + if (num <= 80) return 0.50; + if (num <= 400) return 0.75; + return 1.00; + } + // ── Fallback heuristics ── + const lc = base.toLowerCase(); + if (lc.includes('nano') || lc.includes('mini') || lc.includes('small') || lc.includes('rnj-1')) return 0.25; + if (lc.includes('flash') || lc.includes('gemma') || lc.includes('gpt-oss') || lc.includes('minimax') || + lc.includes('devstral-small') || lc.includes('glm-4.') || lc.includes('super')) return 0.50; + if (lc.includes('kimi-k') || lc.includes('qwen3-vl') || lc.includes('qwen3-coder') || lc.includes('qwen3-next') || + lc.includes('devstral-2') || lc.includes('glm-5')) return 0.75; + if (lc.includes('pro') || lc.includes('qwen3.5') || lc.includes('deepseek-v3') || lc.includes('mistral-large') || + lc.includes('cogito')) return 1.00; + return 0.75; } // ─── Init ─── @@ -2466,6 +2707,262 @@ async function toggleAutoUpdate() { toggle.checked = !enabled; } } + +// ─── ROI Calculator (Experimental) ─── + +let roiEnabled = false; +let roiPrice = 20.0; +let roiCacheHit = 0.0; + +function loadROIConfig() { + fetch('/dashboard/api/roi/config').then(r => r.json()).then(data => { + roiEnabled = data.enabled; + roiPrice = data.subscription_price || 20.0; + roiCacheHit = data.cache_hit_pct || 0.0; + document.getElementById('roi-enabled').checked = roiEnabled; + document.getElementById('roi-card').style.display = 'block'; + const planSel = document.getElementById('roi-plan'); + const customInput = document.getElementById('roi-custom-price'); + if (roiPrice == 20) planSel.value = '20'; + else if (roiPrice == 100) planSel.value = '100'; + else { + planSel.value = '0'; + customInput.value = roiPrice; + customInput.disabled = false; + } + + // Set cache hit slider + const cacheSlider = document.getElementById('cache-hit-slider'); + const cacheValue = document.getElementById('cache-hit-value'); + if (cacheSlider && cacheValue) { + cacheSlider.value = roiCacheHit; + cacheValue.textContent = roiCacheHit + '%'; + } + + if (roiEnabled) { + document.getElementById('roi-disabled-msg').style.display = 'none'; + calculateROI(); + } else { + document.getElementById('roi-results').style.display = 'none'; + } + }).catch(() => { + // ROI endpoints may not exist yet — hide the card + document.getElementById('roi-card').style.display = 'none'; + }); +} + +function toggleROI() { + const enabled = document.getElementById('roi-enabled').checked; + roiEnabled = enabled; + const price = parseFloat(document.getElementById('roi-plan').value) || parseFloat(document.getElementById('roi-custom-price').value) || 20.0; + const cacheHit = roiCacheHit; + fetch('/dashboard/api/roi/config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ enabled, subscription_price: price, cache_hit_pct: cacheHit }) + }).then(r => r.json()).then(data => { + if (data.status === 'ok') { + if (enabled) { + document.getElementById('roi-disabled-msg').style.display = 'none'; + calculateROI(); + } else { + document.getElementById('roi-results').style.display = 'none'; + document.getElementById('roi-disabled-msg').style.display = 'block'; + } + } + }); +} + +function updateCacheHitDisplay() { + const val = document.getElementById('cache-hit-slider').value; + document.getElementById('cache-hit-value').textContent = val + '%'; +} + +function setCacheHit() { + const val = parseFloat(document.getElementById('cache-hit-slider').value) || 0; + roiCacheHit = val; + const price = parseFloat(document.getElementById('roi-plan').value) || parseFloat(document.getElementById('roi-custom-price').value) || 20.0; + fetch('/dashboard/api/roi/config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ enabled: roiEnabled, subscription_price: price, cache_hit_pct: val }) + }).then(r => r.json()).then(data => { + if (data.status === 'ok' && roiEnabled) { + calculateROI(); + } + }); +} + +function setROIPlan() { + const val = document.getElementById('roi-plan').value; + const custom = document.getElementById('roi-custom-price'); + if (val === '0') { + custom.disabled = false; + custom.focus(); + } else { + custom.disabled = true; + custom.value = ''; + roiPrice = parseFloat(val); + } + if (roiEnabled) { + fetch('/dashboard/api/roi/config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ enabled: true, subscription_price: roiPrice }) + }); + } +} + +async function calculateROI() { + if (!roiEnabled) return; + const loading = document.getElementById('roi-loading'); + const results = document.getElementById('roi-results'); + loading.style.display = 'block'; + results.style.display = 'none'; + try { + const resp = await fetch('/dashboard/api/roi/calculate'); + const data = await resp.json(); + loading.style.display = 'none'; + if (data.error) { + document.getElementById('roi-model-rows').innerHTML = '
' + data.error + '
'; + results.style.display = 'block'; + return; + } + renderROIResults(data); + loadROIComparison(); + results.style.display = 'block'; + } catch (e) { + loading.style.display = 'none'; + document.getElementById('roi-model-rows').innerHTML = '
Failed to calculate ROI
'; + results.style.display = 'block'; + } +} + +function renderROIResults(data) { + document.getElementById('roi-total-cost').textContent = '$' + (data.total_cost || 0).toLocaleString(undefined, {maximumFractionDigits: 2}); + document.getElementById('roi-weekly').textContent = '$' + (data.weekly_value || 0).toLocaleString(undefined, {maximumFractionDigits: 2}); + const monthly = data.monthly_value || 0; + document.getElementById('roi-monthly').textContent = '$' + monthly.toLocaleString(undefined, {maximumFractionDigits: 2}); + document.getElementById('roi-sub-cost').textContent = '$' + (data.subscription_monthly || 0).toLocaleString(undefined, {maximumFractionDigits: 0}); + document.getElementById('roi-est-value').textContent = '$' + monthly.toLocaleString(undefined, {maximumFractionDigits: 2}); + const savings = Math.max(0, monthly - (data.subscription_monthly || 0)); + document.getElementById('roi-savings').textContent = '$' + savings.toLocaleString(undefined, {maximumFractionDigits: 2}); + const mult = data.roi_multiplier || 0; + document.getElementById('roi-mult').textContent = mult.toFixed(1) + 'x'; + document.getElementById('roi-mult').style.color = mult > 1 ? 'var(--success)' : (mult > 0.5 ? '#facc15' : 'var(--danger)'); + // Use total_raw_tokens if available (new calc), otherwise sum from by_model (cached) + let rawTotal = data.total_raw_tokens || 0; + if (!rawTotal && data.by_model) { + rawTotal = data.by_model.reduce((s, m) => s + (m.prompt_tokens || 0) + (m.completion_tokens || 0), 0); + } + document.getElementById('roi-total-tokens').textContent = rawTotal.toLocaleString(); + + const rows = document.getElementById('roi-model-rows'); + if (data.by_model && data.by_model.length > 0) { + rows.innerHTML = data.by_model.map(m => { + const pt = m.prompt_tokens || 0; + const ct = m.completion_tokens || 0; + const totalToks = pt + ct; + return '
' + + '
' + m.model + '
' + + '
$' + (m.prompt_per_mt || 0).toFixed(4) + '
' + + '
$' + (m.completion_per_mt || 0).toFixed(4) + '
' + + '
$' + (m.cost || 0).toFixed(2) + '
' + + '
' + (m.pct_of_total || 0).toFixed(1) + '%
' + + '
' + totalToks.toLocaleString() + '
' + + '
'; + }).join(''); + } else { + rows.innerHTML = '
No usage data yet this week
'; + } + + // Weekly usage % + const weeklyPct = data.weekly_pct_used || 0; + document.getElementById('roi-weekly-pct').textContent = weeklyPct > 0 ? '· Based on ' + weeklyPct.toFixed(1) + '% of weekly quota used' : ''; + + // Unmatched models warning + const unmatchedEl = document.getElementById('roi-unmatched'); + if (data.unmatched_models && data.unmatched_models.length > 0) { + unmatchedEl.textContent = '⚠ Could not price: ' + data.unmatched_models.join(', '); + unmatchedEl.style.display = 'block'; + } else { + unmatchedEl.style.display = 'none'; + } + + // Stale prices warning + document.getElementById('roi-stale').style.display = data.prices_stale ? 'block' : 'none'; +} + +// ─── Model Value Comparison ─── + +async function loadROIComparison() { + if (!roiEnabled) return; + try { + const resp = await fetch('/dashboard/api/roi/comparison?period=weekly'); + const data = await resp.json(); + renderROIComparison(data); + } catch (e) { + document.getElementById('roi-comparison-rows').innerHTML = '
Failed to load comparison
'; + } +} + +function renderROIComparison(data) { + const rows = document.getElementById('roi-comparison-rows'); + const summary = document.getElementById('roi-comparison-summary'); + + if (data.error) { + rows.innerHTML = '
' + data.error + '
'; + summary.style.display = 'none'; + return; + } + + if (!data.models || data.models.length === 0) { + rows.innerHTML = '
No usage data for this period
'; + summary.style.display = 'none'; + return; + } + + rows.innerHTML = data.models.map(m => { + const score = m.score || 0; + const scoreColor = score > 0 ? 'var(--success)' : (score < 0 ? 'var(--danger)' : 'var(--text2)'); + const scoreSign = score > 0 ? '+' : ''; + return '
' + + '
' + m.model + '
' + + '
' + (m.requests || 0).toLocaleString() + '
' + + '
' + (m.total_tokens || 0).toLocaleString() + '
' + + '
$' + (m.actual_value || 0).toFixed(2) + '
' + + '
$' + (m.fair_share || 0).toFixed(2) + '
' + + '
' + scoreSign + score.toFixed(2) + '
' + + '
'; + }).join(''); + + // Summary bar + const net = data.net_score || 0; + const netColor = net > 0 ? 'var(--success)' : (net < 0 ? 'var(--danger)' : 'var(--text2)'); + const netSign = net > 0 ? '+' : ''; + summary.innerHTML = + '
' + + 'Period cost: $' + (data.period_sub_cost || 0).toFixed(2) + ' · Total value: $' + (data.total_actual_value || 0).toFixed(2) + '' + + 'Net: ' + netSign + net.toFixed(2) + '' + + '
'; + summary.style.display = 'block'; +} + +function loadLastROI() { + fetch('/dashboard/api/roi/last').then(r => r.json()).then(data => { + if (!data.error && (data.by_model || []).length > 0) { + renderROIResults(data); + document.getElementById('roi-results').style.display = 'block'; + } + }).catch(() => {}); +} + +function resetROIData() { + fetch('/dashboard/api/roi/reset', {method: 'POST'}).then(() => { + document.getElementById('roi-results').style.display = 'none'; + if (roiEnabled) calculateROI(); + }); +} \ No newline at end of file diff --git a/guanaco/roi.py b/guanaco/roi.py new file mode 100644 index 0000000..978b170 --- /dev/null +++ b/guanaco/roi.py @@ -0,0 +1,473 @@ +""" +OpenRouter price-based subscription value calculator. + +This module: +1. Fetches live model prices from OpenRouter's API +2. Maps Ollama Cloud model names to OpenRouter model IDs +3. Calculates "what would this usage have cost on OpenRouter?" +4. Compares against subscription price to show value multiplier + +Prices are cached for 1 hour to avoid rate limits. +""" +from __future__ import annotations + +import logging +import sqlite3 +import time +from pathlib import Path +from typing import Optional + +import httpx + +logger = logging.getLogger(__name__) + +# ── OpenRouter API ── +OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models" +OPENROUTER_CACHE_TTL = 3600 # 1 hour + +# Model family mappings: ollama_name_fragment -> openrouter_id_fragment +# These are used when exact match fails +FAMILY_MAP = { + "gemma": ("google/gemma", "google/gemma"), + "gemma3": ("google/gemma", "google/gemma"), + "gemma4": ("google/gemma", "google/gemma"), + "qwen": ("qwen/qwen", "qwen/qwen"), + "qwen3": ("qwen/qwen3", "qwen/qwen"), + "qwen3.5": ("qwen/qwen3.5", "qwen/qwen"), + "qwen3-vl": ("qwen/qwen3-vl", "qwen/qwen3-vl"), + "qwen3-coder": ("qwen/qwen3-coder", "qwen/qwen"), + "qwen3-next": ("qwen/qwen3-next", "qwen/qwen"), + "deepseek": ("deepseek/deepseek", "deepseek/deepseek"), + "deepseek-v3": ("deepseek/deepseek", "deepseek/deepseek-v3"), + "deepseek-v4": ("deepseek/deepseek", "deepseek/deepseek-v4"), + "gpt-oss": ("openai/gpt-oss", "openai/gpt"), + "minimax": ("minimax/minimax", "minimax/minimax"), + "glm": ("zhipu/glm", "zhipu/glm"), + "glm-5": ("zhipu/glm-5", "zhipu/glm"), + "kimi": ("moonshot/kimi", "moonshot/kimi"), + "kimi-k2": ("moonshot/kimi", "moonshot/kimi"), + "devstral": ("mistral/devstral", "mistral/devstral"), + "mistral": ("mistral/mistral", "mistral/mistral"), + "ministral": ("mistral/ministral", "mistral/ministral"), + "nemotron": ("nvidia/nemotron", "nvidia/nemotron"), + "cogito": ("cogito/cogito", "cogito/"), + "gemini": ("google/gemini", "google/gemini"), + "rnj": ("", ""), +} + + +def _normalized(name: str) -> str: + """Strip provider prefix, ~leaderboard prefix, :cloud/:local suffixes, and lower-case.""" + base = name.split(":")[0].lower() + # Strip ~ prefix (leaderboard indicator on OpenRouter) + if base.startswith("~"): + base = base[1:] + # Strip provider/ prefix (e.g. moonshotai/kimi-k2.6 → kimi-k2.6) + if "/" in base: + base = base.split("/", 1)[1] + if base.endswith("-cloud"): + base = base[:-6] + return base + + +def _model_size(name: str) -> int: + """Extract parameter size in billions from model name, 0 if unknown.""" + import re + m = re.search(r"(\d+)(b|t)", name, re.I) + if not m: + return 0 + n = int(m.group(1)) + unit = m.group(2).lower() + return n * 1000 if unit == "t" else n + + +class PriceCache: + """Holds cached OpenRouter prices in memory with TTL.""" + def __init__(self): + self.prices: dict[str, dict] = {} + self.fetched_at: float = 0 + + def is_fresh(self) -> bool: + return self.prices and (time.time() - self.fetched_at) < OPENROUTER_CACHE_TTL + + def fetch(self) -> dict[str, dict]: + if self.is_fresh(): + logger.debug("Using cached OpenRouter prices") + return self.prices + + prices = {} + try: + logger.info("Fetching OpenRouter model prices...") + r = httpx.get(OPENROUTER_MODELS_URL, timeout=30) + r.raise_for_status() + data = r.json() + for model in data.get("data", []): + model_id = model.get("id", "") + pricing = model.get("pricing", {}) + prompt = float(pricing.get("prompt", 0) or 0) + completion = float(pricing.get("completion", 0) or 0) + cache_read = float(pricing.get("input_cache_read", 0) or 0) + if prompt > 0 or completion > 0: + # Convert from per-token ($/token) to per-million-tokens ($/Mt) + entry = { + "prompt": prompt * 1_000_000, + "completion": completion * 1_000_000, + } + if cache_read > 0: + entry["input_cache_read"] = cache_read * 1_000_000 + prices[model_id] = entry + self.prices = prices + self.fetched_at = time.time() + logger.info(f"Fetched {len(prices)} OpenRouter price entries") + except Exception as e: + logger.warning(f"Failed to fetch OpenRouter prices: {e}") + return self.prices + + +# Singleton cache +_price_cache = PriceCache() + + +def _find_best_price(prices: dict, ollama_name: str) -> dict: + """ + Given OpenRouter prices dict {model_id: {prompt, completion}} and an + Ollama model name, return best matching price dict. + """ + norm = _normalized(ollama_name) + size = _model_size(ollama_name) + + # 1. Exact normalized match (handles provider-prefixed OR IDs like moonshotai/kimi-k2.6) + for orouter_id, price_info in prices.items(): + if _normalized(orouter_id) == norm: + return price_info + + # 2. Family prefix match — use raw orouter_id so provider/ prefix matches + best_family_price = None + best_family_score = -9999 + for orouter_id, price_info in prices.items(): + for frag, (family_exact, family_prefix) in FAMILY_MAP.items(): + if frag in norm and family_prefix and family_prefix in orouter_id: + # Score by size closeness + o_size = _model_size(orouter_id) + score = -(abs(o_size - size)) # higher = closer size + if score > best_family_score: + best_family_score = score + best_family_price = price_info + if best_family_price: + return best_family_price + + # 3. Same parameter size match + if size > 0: + for orouter_id, price_info in prices.items(): + if _model_size(orouter_id) == size: + return price_info + + # 4. Size-window fallback + candidates = [] + for orouter_id, price_info in prices.items(): + o_size = _model_size(orouter_id) + if o_size == 0: + continue + window = max(20, size * 0.5) + if abs(o_size - size) <= window: + candidates.append(price_info) + if candidates: + candidates.sort(key=lambda p: p["completion"] + p["prompt"]) + return candidates[len(candidates) // 2] + + # 5. Global average + all_prices = [p for p in prices.values() if p["prompt"] > 0 or p["completion"] > 0] + if all_prices: + avg_prompt = sum(p["prompt"] for p in all_prices) / len(all_prices) + avg_comp = sum(p["completion"] for p in all_prices) / len(all_prices) + return {"prompt": avg_prompt, "completion": avg_comp} + + return {"prompt": 0.0, "completion": 0.0} + + +def _map_usage_to_prices(usage_by_model: dict, prices: dict, cache_hit_pct: float = 0.0) -> dict: + """ + Map usage to prices, optionally applying prompt-cache hit discount. + + For models with input_cache_read pricing (e.g. Claude Fable, Qwen, Minimax): + - uncached_prompt = prompt_tokens * (1 - cache_hit_pct) + - cached_prompt = prompt_tokens * cache_hit_pct + - prompt cost = uncached_prompt * prompt_price + cached_prompt * cache_read_price + """ + result = {} + cache_rate = max(0.0, min(100.0, cache_hit_pct)) / 100.0 + for model, usage in usage_by_model.items(): + price = _find_best_price(prices, model) + pt = usage.get("prompt_tokens", 0) + ct = usage.get("completion_tokens", 0) + + # Apply cache discount if model supports it + if "input_cache_read" in price and cache_rate > 0: + uncached_pt = pt * (1 - cache_rate) + cached_pt = pt * cache_rate + prompt_cost = (uncached_pt / 1_000_000 * price["prompt"]) + (cached_pt / 1_000_000 * price["input_cache_read"]) + # Store effective prompt price for display + effective_prompt = prompt_cost / (pt / 1_000_000) if pt > 0 else price["prompt"] + else: + prompt_cost = (pt / 1_000_000) * price["prompt"] + effective_prompt = price["prompt"] + + comp_cost = (ct / 1_000_000) * price["completion"] + cost = prompt_cost + comp_cost + + result[model] = { + "prompt_tokens": pt, + "completion_tokens": ct, + "prompt_per_mt": effective_prompt, + "completion_per_mt": price["completion"], + "cost": cost, + "matched_price_model": _find_best_price.__module__, + "cache_applied": "input_cache_read" in price and cache_rate > 0, + "cache_read_per_mt": price.get("input_cache_read"), + } + return result + + +def get_usage_from_analytics(db_path: Path | str, since: float = 0) -> tuple[dict, float]: + usage_by_model = {} + total_weighted = 0.0 + try: + conn = sqlite3.connect(str(db_path)) + rows = conn.execute( + """SELECT model, + IFNULL(SUM(prompt_tokens),0), + IFNULL(SUM(completion_tokens),0), + IFNULL(SUM(prompt_tokens * IFNULL(usage_multiplier,1.0)),0), + IFNULL(SUM(completion_tokens * IFNULL(usage_multiplier,1.0)),0), + COUNT(*) + FROM request_log WHERE type='llm' AND ts > ? GROUP BY model""", + (since,), + ).fetchall() + for row in rows: + model, pt, ct, w_pt, w_ct, req_count = row + usage_by_model[model] = { + "prompt_tokens": pt, + "completion_tokens": ct, + "weighted_prompt": w_pt, + "weighted_completion": w_ct, + "requests": req_count, + } + total_weighted += (w_pt + w_ct) + conn.close() + except Exception as e: + logger.warning(f"Failed to read analytics DB: {e}") + return usage_by_model, total_weighted + + +def _get_ollama_week_start() -> float: + """Return Unix timestamp of the most recent Sunday at 20:00 UTC. + + Ollama resets its weekly quota every Sunday at 8 PM UTC. + """ + from datetime import datetime, timedelta, timezone + now = datetime.now(timezone.utc) + days_since_sunday = now.weekday() + 1 if now.weekday() != 6 else 0 + sunday = now - timedelta(days=days_since_sunday) + reset = sunday.replace(hour=20, minute=0, second=0, microsecond=0) + if now < reset: + reset -= timedelta(days=7) + return reset.timestamp() + + +def calculate_roi( + db_path: Path | str, + subscription_monthly: float = 20.0, + weekly_pct_used: float = 0.0, + cache_hit_pct: float = 0.0, +) -> dict: + """ + Calculate subscription value vs OpenRouter pay-as-you-go. + + Args: + db_path: path to analytics SQLite DB + subscription_monthly: monthly sub cost (20 Pro, 100 Max) + weekly_pct_used: % of weekly quota consumed (from usage check) + cache_hit_pct: estimated % of prompt tokens hitting cache (0-100). + Used for models with input_cache_read pricing on OpenRouter. + + Returns dict with: + total_cost, total_prompt_tokens, total_completion_tokens, + total_weighted_tokens, weekly_value, monthly_value, + subscription_monthly, plan ("pro"|"max"), roi_multiplier, + weekly_pct_used, cache_hit_pct, prices_stale, + by_model[] with prompt_tokens, completion_tokens, prompt_per_mt, completion_per_mt, cost, + unmatched_models[] names with no price match + """ + # 1. Fetch prices + prices = _price_cache.fetch() + prices_stale = not prices or len(prices) < 10 + plan = "pro" if subscription_monthly <= 25 else "max" + + # 2. Get usage + since = _get_ollama_week_start() + usage_by_model, total_weighted = get_usage_from_analytics(db_path, since) + + # 3. Map usage to prices (with cache hit estimation) + priced = _map_usage_to_prices(usage_by_model, prices, cache_hit_pct) + + total_cost = sum(m["cost"] for m in priced.values()) + total_prompt = sum(m["prompt_tokens"] for m in priced.values()) + total_comp = sum(m["completion_tokens"] for m in priced.values()) + + # 4. Extrapolate to 100% weekly + if weekly_pct_used > 0: + weekly_value = total_cost / (weekly_pct_used / 100.0) + else: + weekly_value = total_cost + + monthly_value = weekly_value * 4 + roi_multiplier = (monthly_value / subscription_monthly) if subscription_monthly > 0 else 0 + + unmatched = [m for m in usage_by_model if priced.get(m, {}).get("cost", 0) == 0] + + # Per-model breakdown + by_model = [] + for model, detail in priced.items(): + pt = detail["prompt_tokens"] + ct = detail["completion_tokens"] + by_model.append({ + "model": model, + "prompt_tokens": pt, + "completion_tokens": ct, + "prompt_per_mt": round(detail["prompt_per_mt"], 6), + "completion_per_mt": round(detail["completion_per_mt"], 6), + "cost": round(detail["cost"], 4), + "pct_of_total": round((detail["cost"] / total_cost * 100), 2) if total_cost > 0 else 0, + "cache_applied": detail.get("cache_applied", False), + "cache_read_per_mt": round(detail.get("cache_read_per_mt"), 6) if detail.get("cache_read_per_mt") else None, + }) + by_model.sort(key=lambda x: x["cost"], reverse=True) + + return { + "total_cost": round(total_cost, 2), + "total_prompt_tokens": int(total_prompt), + "total_completion_tokens": int(total_comp), + "total_raw_tokens": int(total_prompt + total_comp), + "total_weighted_tokens": int(total_weighted), + "weekly_value": round(weekly_value, 2), + "monthly_value": round(monthly_value, 2), + "subscription_monthly": subscription_monthly, + "plan": plan, + "roi_multiplier": round(roi_multiplier, 2), + "weekly_pct_used": weekly_pct_used, + "cache_hit_pct": cache_hit_pct, + "prices_stale": prices_stale, + "by_model": by_model, + "unmatched_models": unmatched, + "price_models_available": len(prices), + } + + +def calculate_model_value_comparison( + db_path: Path | str, + subscription_monthly: float = 20.0, + weekly_pct_used: float = 0.0, + session_pct_used: float = 0.0, + period: str = "weekly", # "weekly" or "session" +) -> dict: + """ + Score each model: positive = gave more value than its fair share of sub. + + For each model actually used: + - actual_value = what those tokens would cost on OpenRouter + - fair_share = (model's weighted tokens / total weighted tokens) * subscription_cost_for_period + - score = actual_value - fair_share + positive = model punches above its weight (good deal) + negative = model is expensive for its token share (bad deal) + """ + prices = _price_cache.fetch() + if not prices: + return {"error": "No OpenRouter prices available", "models": []} + + # Determine time window and usage % + now = time.time() + if period == "session": + since = now - (5 * 3600) # 5-hour session window + pct_used = session_pct_used + else: + since = now - (7 * 24 * 3600) # 7-day weekly window + pct_used = weekly_pct_used + + usage_by_model, total_weighted = get_usage_from_analytics(db_path, since) + if not usage_by_model: + return {"models": [], "summary": {}} + + # Period subscription cost + weekly_sub_cost = subscription_monthly / 4.0 + if pct_used > 0: + period_sub_cost = weekly_sub_cost * (pct_used / 100.0) + else: + period_sub_cost = weekly_sub_cost + + # Map to prices + priced = _map_usage_to_prices(usage_by_model, prices) + + # Per-model scoring + models = [] + total_actual_value = 0.0 + + for model, usage in usage_by_model.items(): + detail = priced.get(model, {}) + actual_value = detail.get("cost", 0.0) + pt = usage.get("prompt_tokens", 0) + ct = usage.get("completion_tokens", 0) + model_raw = pt + ct + w_pt = usage.get("weighted_prompt", 0) + w_ct = usage.get("weighted_completion", 0) + model_weighted = w_pt + w_ct + + # Fair share of subscription based on WEIGHTED token proportion + # (subscription quota is weighted; actual value uses raw tokens at OpenRouter prices) + fair_share = (model_weighted / total_weighted * period_sub_cost) if total_weighted > 0 else 0 + score = actual_value - fair_share + score_pct = (score / fair_share * 100) if fair_share > 0 else 0 + + total_actual_value += actual_value + + models.append({ + "model": model, + "requests": usage.get("requests", 0), + "prompt_tokens": pt, + "completion_tokens": ct, + "total_tokens": int(model_raw), + "weighted_tokens": int(model_weighted), + "pct_of_total_tokens": round((model_weighted / total_weighted * 100), 2) if total_weighted > 0 else 0, + "actual_value": round(actual_value, 2), + "fair_share": round(fair_share, 2), + "score": round(score, 2), + "score_pct": round(score_pct, 1), + "prompt_per_mt": round(detail.get("prompt_per_mt", 0), 6), + "completion_per_mt": round(detail.get("completion_per_mt", 0), 6), + }) + + # Sort by score descending (best value first) + models.sort(key=lambda x: x["score"], reverse=True) + + # Summary + net_score = total_actual_value - period_sub_cost + + # Compute total raw tokens across all models for the summary + total_raw = sum(m.get("prompt_tokens", 0) + m.get("completion_tokens", 0) for m in models) + + return { + "period": period, + "subscription_monthly": subscription_monthly, + "period_sub_cost": round(period_sub_cost, 2), + "total_actual_value": round(total_actual_value, 2), + "total_raw_tokens": int(total_raw), + "total_weighted_tokens": int(total_weighted), + "net_score": round(net_score, 2), + "models": models, + "prices_stale": len(prices) < 10, + "price_models_available": len(prices), + } + + +def get_cached_roi() -> dict: + """Return last calculated ROI, or minimal default.""" + return _price_cache.prices # placeholder; real caching is in config diff --git a/guanaco/router/router.py b/guanaco/router/router.py index 9282f20..4494349 100644 --- a/guanaco/router/router.py +++ b/guanaco/router/router.py @@ -469,11 +469,17 @@ def create_router(client: OllamaClient, analytics=None, config=None, account_poo """List available models by querying Ollama Cloud dynamically.""" try: models = await client.list_models() + # Fetch real usage levels from ollama.com library pages + model_names = [m.get("name", m.get("model", "")) for m in models] + usage_levels = await client.fetch_usage_levels(model_names) + data = [] for m in models: name = m.get("name", m.get("model", "")) display_name = name.replace("-cloud", "") if name.endswith("-cloud") else name details = m.get("details", {}) + level = usage_levels.get(name, 0) + multiplier = level * 0.25 if level else client._get_model_multiplier(name) data.append({ "id": display_name, "object": "model", @@ -483,6 +489,8 @@ def create_router(client: OllamaClient, analytics=None, config=None, account_poo "root": display_name, "parent": None, "capabilities": client._get_model_capabilities(name), + "usage_multiplier": multiplier, + "usage_level": level, # 1-4, 0 = unknown "details": { "parameter_size": details.get("parameter_size", ""), "quantization": details.get("quantization_level", ""), @@ -552,10 +560,45 @@ def create_router(client: OllamaClient, analytics=None, config=None, account_poo payload_fb["model"] = fallback_model if body.stream: if _config.fallback.stream_fallback: - fallback_payload = dict(payload) - fallback_payload["model"] = fallback_model + fallback_payload2 = dict(payload) + fallback_payload2["model"] = fallback_model + + async def _quota_fallback_stream(): + acc_content = [] + fb_start = time.time() + fb_first = None + try: + async for fb_chunk in await _call_fallback_provider(fallback_payload2, _config.fallback, stream=True): + yield fb_chunk + txt = _extract_sse_content(fb_chunk) + if txt: + if fb_first is None: + fb_first = time.time() + acc_content.append(txt) + finally: + if _analytics: + _hist_kw2 = dict(_hist) + if acc_content and _config and _config.history.enabled and _config.history.save_output: + out_text = "".join(acc_content) + if len(out_text) > _config.history.max_content_size: + out_text = out_text[:_config.history.max_content_size] + "\n...[truncated]" + _hist_kw2["output_text"] = out_text + fb_chars = len("".join(acc_content)) + fb_tokens = max(1, fb_chars // 4) if fb_chars else 0 + fb_elapsed = time.time() - fb_start + _analytics.log_llm( + model=_normalize_model_name(fallback_model), + prompt_tokens=0, + completion_tokens=fb_tokens, + total_duration_seconds=fb_elapsed, + provider=_config.fallback.name, + fallback_for=_normalize_model_name(resolved_model), + fallback_reason=f"Quota full (session={_config.usage.last_session_pct or 0:.0f}%, weekly={_config.usage.last_weekly_pct or 0:.0f}%)", + **_hist_kw2, + ) + return StreamingResponse( - await _call_fallback_provider(fallback_payload, _config.fallback, stream=True), + _quota_fallback_stream(), media_type="text/event-stream", ) # Can't stream from fallback — do non-streaming @@ -578,9 +621,16 @@ def create_router(client: OllamaClient, analytics=None, config=None, account_poo fallback_resp["_oct_fallback_provider"] = _config.fallback.name fallback_resp["_oct_original_model"] = _normalize_model_name(resolved_model) fallback_resp["_oct_quota_redirect"] = True + # Extract usage from fallback response when available + fb_usage = fallback_resp.get("usage", {}) + fb_prompt = fb_usage.get("prompt_tokens", 0) + fb_completion = fb_usage.get("completion_tokens", 0) if _analytics: _analytics.log_llm( model=_normalize_model_name(fallback_model), + prompt_tokens=fb_prompt, + completion_tokens=fb_completion, + total_tokens=fb_usage.get("total_tokens", fb_prompt + fb_completion), total_duration_seconds=time.time() - start, provider=_config.fallback.name, fallback_for=_normalize_model_name(resolved_model), @@ -755,9 +805,16 @@ def create_router(client: OllamaClient, analytics=None, config=None, account_poo fallback_resp = await _call_fallback_provider(fallback_payload, _config.fallback) elapsed = time.time() - start + # Extract usage from fallback response when available + fb_usage2 = fallback_resp.get("usage", {}) + fb_prompt2 = fb_usage2.get("prompt_tokens", 0) + fb_completion2 = fb_usage2.get("completion_tokens", 0) if _analytics: _analytics.log_llm( model=fallback_model, + prompt_tokens=fb_prompt2, + completion_tokens=fb_completion2, + total_tokens=fb_usage2.get("total_tokens", fb_prompt2 + fb_completion2), total_duration_seconds=elapsed, provider=_config.fallback.name, fallback_for=resolved_model, fallback_reason=f"Ollama error: {_describe_error(ollama_error)}", @@ -1472,13 +1529,20 @@ async def _stream_completion_openai(client, payload, model, analytics, start_tim _hist_kw["output_text"] = output_text if analytics: if used_fallback and fallback_model: + # Fallback providers don't emit __oct_metrics__ — estimate from accumulated content + fb_stream_metrics = dict(stream_metrics) + if not fb_stream_metrics.get("eval_count") and accumulated_content: + fb_chars = len("".join(accumulated_content)) + fb_tokens = max(1, fb_chars // 4) if fb_chars else 0 + fb_stream_metrics["eval_count"] = fb_tokens + fb_stream_metrics.setdefault("elapsed_seconds", elapsed) analytics.log_llm( model=_normalize_model_name(fallback_model), - prompt_tokens=stream_metrics.get("prompt_eval_count", 0), - completion_tokens=stream_metrics.get("eval_count"), - tps=stream_metrics.get("tps"), - ttft_seconds=stream_metrics.get("ttft_seconds"), - total_duration_seconds=stream_metrics.get("elapsed_seconds", elapsed), + prompt_tokens=fb_stream_metrics.get("prompt_eval_count", 0), + completion_tokens=fb_stream_metrics.get("eval_count"), + tps=fb_stream_metrics.get("tps"), + ttft_seconds=fb_stream_metrics.get("ttft_seconds"), + total_duration_seconds=fb_stream_metrics.get("elapsed_seconds", elapsed), provider=config.fallback.name if config else "fallback", fallback_for=_normalize_model_name(model), fallback_reason=f"Ollama error: {original_error}" if original_error else "Ollama stream error", @@ -1530,9 +1594,18 @@ async def _stream_fallback_openai(payload, config, fallback_model, analytics, st output_text = output_text[:config.history.max_content_size] + "\n...[truncated]" _hist_kw["output_text"] = output_text if analytics: - analytics.log_llm(model=_normalize_model_name(fallback_model), total_duration_seconds=elapsed, provider=provider_tag, fallback_for=_normalize_model_name(fallback_for) if fallback_for else None, fallback_reason=fallback_reason, **_hist_kw) - - return StreamingResponse(generate(), media_type="text/event-stream") + # Estimate tokens from accumulated content (fallbacks don't emit __oct_metrics__) + fb_chars = len("".join(accumulated_content)) + fb_tokens = max(1, fb_chars // 4) if fb_chars else 0 + analytics.log_llm( + model=_normalize_model_name(fallback_model), + completion_tokens=fb_tokens, + total_duration_seconds=elapsed, + provider=provider_tag, + fallback_for=_normalize_model_name(fallback_for) if fallback_for else None, + fallback_reason=fallback_reason, + **_hist_kw, + ) async def _stream_completion_anthropic(client, payload, model, max_tokens, analytics, start_time, history_kwargs=None, config=None): diff --git a/install.sh b/install.sh index 650ce40..2a0af80 100755 --- a/install.sh +++ b/install.sh @@ -188,6 +188,26 @@ echo "" prompt OLLAMA_API_KEY "Enter your Ollama API key" "" +if [ -n "$OLLAMA_API_KEY" ]; then + info "Validating API key with Ollama Cloud..." + VALIDATE_RESPONSE=$(curl -s -w "\n%{http_code}" "https://api.ollama.com/v1/models" \ + -H "Authorization: Bearer ${OLLAMA_API_KEY}" \ + -H "Accept: application/json" \ + --max-time 10 2>/dev/null || echo "timeout") + HTTP_CODE=$(echo "$VALIDATE_RESPONSE" | tail -1) + if [ "$HTTP_CODE" = "200" ]; then + success "API key validated (models endpoint responds OK)" + elif [ "$HTTP_CODE" = "401" ]; then + warn "API key returned 401 Unauthorized from Ollama Cloud" + warn "Key may be invalid or expired. Guanaco will still install but inference will fail." + warn "Fix with: guanaco setup (after install)" + elif [ "$HTTP_CODE" = "timeout" ]; then + warn "Could not reach Ollama Cloud (timeout). Skipping validation." + else + warn "API key validation returned HTTP $HTTP_CODE — key may not work" + fi +fi + if [ -z "$OLLAMA_API_KEY" ]; then echo "" warn "No API key provided. You can set it later with: guanaco setup" @@ -418,6 +438,8 @@ WorkingDirectory=${INSTALL_DIR} ExecStart=${VENV_DIR}/bin/python -m uvicorn guanaco.app:create_app --factory --host ${BIND_HOST} --port ${PORT} --log-level info Restart=on-failure RestartSec=5 +Environment=GUANACO_CONFIG_DIR=${CONFIG_DIR} +WorkingDirectory=${INSTALL_DIR}/repo [Install] WantedBy=multi-user.target diff --git a/pyproject.toml b/pyproject.toml index d88528c..b0812ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,8 +3,8 @@ requires = ["setuptools>=68.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "guanaco" -version = "0.4.2" +name = "guanaco-llm-proxy" +version = "0.4.3" description = "OpenAI-compatible LLM proxy that maximizes Ollama Cloud subscriptions — search/scrape API emulation, usage tracking, fallback provider support, and a web dashboard" readme = "README.md" license = {text = "MIT"} From aadb45d026f6d31cdae809a72a868e1f002f87d3 Mon Sep 17 00:00:00 2001 From: Evan Rice Date: Wed, 10 Jun 2026 01:16:50 +0000 Subject: [PATCH 2/4] hotfix(v0.5.0): correct version strings to 0.5.0 The squash-merge from develop included all v0.5.0 feature commits but excluded the version-bump commit (b4ed4fa), which was local-only and never pushed to origin/develop before the PR merge. This left main reporting v0.4.3 despite having all v0.5.0 code. Fixes: __init__.py, app.py, pyproject.toml all now report 0.5.0. Update-safe: only version string changes, zero functional diff. --- guanaco/__init__.py | 2 +- guanaco/app.py | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/guanaco/__init__.py b/guanaco/__init__.py index 5bf932f..640a122 100644 --- a/guanaco/__init__.py +++ b/guanaco/__init__.py @@ -3,7 +3,7 @@ # Single source of truth for version. # importlib.metadata can return stale values after git-pull without re-pip-install, # so we always use the hardcoded fallback and only override if metadata matches. -__version__ = "0.4.3" +__version__ = "0.5.0" try: from importlib.metadata import version as _version diff --git a/guanaco/app.py b/guanaco/app.py index 0efa761..3b7fddf 100644 --- a/guanaco/app.py +++ b/guanaco/app.py @@ -14,7 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware from guanaco.config import load_config, get_config, AppConfig, get_base_url, get_tailscale_ip from guanaco.client import OllamaClient from guanaco.accounts import AccountPool -__version__ = "0.4.3" +__version__ = "0.5.0" from guanaco.router.router import create_router as create_llm_router from guanaco.search.providers import ALL_PROVIDERS from guanaco.dashboard import create_dashboard_router diff --git a/pyproject.toml b/pyproject.toml index b0812ed..bcefec3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "guanaco-llm-proxy" -version = "0.4.3" +version = "0.5.0" description = "OpenAI-compatible LLM proxy that maximizes Ollama Cloud subscriptions — search/scrape API emulation, usage tracking, fallback provider support, and a web dashboard" readme = "README.md" license = {text = "MIT"} From c0ec3546bd816daa01a5a63359143270473b92a2 Mon Sep 17 00:00:00 2001 From: Evan Rice Date: Wed, 10 Jun 2026 01:24:20 +0000 Subject: [PATCH 3/4] fix(version): use correct package name guanaco-llm-proxy and only override if metadata >= hardcoded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The importlib.metadata fallback was calling _version('guanaco') which matched the stale guanaco-0.4.2.dist-info package left in the venv. Since 0.4.2 >= 0.4.1, it always clobbered the hardcoded __version__. Changes: - Query 'guanaco-llm-proxy' (current package name) instead of 'guanaco' - Compare metadata version against the *hardcoded* value, not a fixed baseline — stale metadata can't downgrade the version anymore Fixes dashboard showing v0.4.3 after git-pull to v0.5.0 without re-pip-install. --- guanaco/__init__.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/guanaco/__init__.py b/guanaco/__init__.py index 640a122..07fbdf9 100644 --- a/guanaco/__init__.py +++ b/guanaco/__init__.py @@ -7,12 +7,15 @@ __version__ = "0.5.0" try: from importlib.metadata import version as _version - _pkg_ver = _version("guanaco") - # Only use pkg version if it parses as a clean semver >= our hardcoded baseline. - # This prevents stale/RC versions like "0.4.0rc1" from overriding the hardcoded value. + _pkg_ver = _version("guanaco-llm-proxy") + # Only override hardcoded if installed metadata is *newer or same* — + # prevents stale metadata from git-pull without re-pip-install from + # reverting the version to an old value. import re _m = re.match(r"^(\d+)\.(\d+)\.(\d+)$", _pkg_ver or "") - if _m and tuple(int(x) for x in _m.groups()) >= (0, 4, 1): - __version__ = _pkg_ver + if _m: + _hardcoded = tuple(int(x) for x in __version__.split(".")) + if tuple(int(x) for x in _m.groups()) >= _hardcoded: + __version__ = _pkg_ver except Exception: pass \ No newline at end of file From 87ff832ea38483bf6f3238699d68192705ff4907 Mon Sep 17 00:00:00 2001 From: Evan Rice Date: Wed, 10 Jun 2026 01:38:02 +0000 Subject: [PATCH 4/4] fix(updater): stash + hard-reset instead of merge-pull; release 0.5.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Apply Update endpoint used 'git pull origin {branch}', which fails with a merge conflict if the working tree has any local modifications. This happened to every install that had leftover version-string edits from a prior partial update, leaving the old code running silently. Fix: unconditionally stash (including untracked files) and reset to the exact remote commit before reinstalling. The update now succeeds even if the repo is dirty. Bumps all version strings to 0.5.1 so existing 0.4.2 → 0.5.x installs immediately receive the new updater logic. --- CHANGELOG.md | 177 +++++++++++++++++++++++++++++++++ guanaco/__init__.py | 2 +- guanaco/app.py | 2 +- guanaco/dashboard/dashboard.py | 19 +++- pyproject.toml | 2 +- 5 files changed, 194 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..09eb137 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,177 @@ +# Changelog + +All notable changes to Guanaco will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +--- + +## [0.5.1] - 2026-06-10 + +### Fixed +- **Updater now stashes + hard-resets instead of merge-pulling.** Previously if the working tree had any local modifications (e.g. leftover version-string edits from a prior partial update), `git pull` would abort with a merge conflict and the update silently failed, leaving the old code running. The updater now unconditionally stashes any local changes (including untracked files) and resets to `origin/{branch}` before reinstalling. This makes the **Apply Update** button work reliably on every install, even if the repo is dirty. + +### Changed +- Bumped version to 0.5.1 to ensure existing 0.4.2 → 0.5.x update paths hit the new updater logic immediately. + +--- + +## [0.5.0] - 2026-06-10 + +### Major Release — Usage Tracking, ROI Dashboard, and Multi-Account Infrastructure + +This release represents a significant milestone: Guanaco now tracks every token accurately, displays real cost analytics via a web dashboard, rotates multiple Ollama Cloud accounts, and scrapes live usage tiers from ollama.com instead of guessing. + +--- + +### New Features + +#### Token Estimation & Accurate Usage Tracking +- **skimtoken estimation fallback** (`analytics.py`): When the upstream API (OpenRouter, Ollama Cloud) omits `usage` data in the response, Guanaco now falls back to `skimtoken` for token estimation instead of logging zero tokens. Estimates are ~15% accurate — dramatically better than silently losing usage data. +- **Proper total_tokens calculation**: Fixed `total_tokens = prompt_tokens + completion_tokens` in analytics logging. Previously some code paths double-counted or omitted totals. +- **`fallback_reason` audit column**: Added to `request_log` schema. When token estimation is used instead of API-reported usage, the reason is recorded (e.g. `"api_omitted_usage"`, `"stream_missing_usage"`) for later audit. +- **Input cache-read pricing** (`roi.py`): Tracks `input_cache_read` tokens separately from `input_cache_write`, applying the correct Ollama Cloud discount rate (typically 0.25× of input price for cache hits). + +#### ROI Dashboard & Per-Model Analytics +- **Web dashboard** (`dashboard/`): New FastAPI-mounted dashboard at `/dashboard/` showing: + - Total tokens consumed (last 24h, 7d, 30d, all-time) + - Per-model token distribution with visual bars + - Cost estimates in USD using live OpenRouter pricing + - **ROI configuration panel**: Slider for `cache_hit_pct` (default 70%), editable price multipliers per model + - **Per-model value scoring**: Each model gets a "value score" based on (capability / cost) ratio, helping users pick the cheapest model for a given task +- **OpenRouter price fetcher** (`roi.py`): Scrapes current model pricing from `https://openrouter.ai/api/v1/models` with 24h caching. Falls back to hardcoded prices if fetch fails. +- **Cache-hit discount logic**: ROI calculations apply the user-configured `cache_hit_pct` to reduce effective input costs, reflecting real-world Ollama Cloud behavior where repeated prompts are cached. + +#### Real Usage-Level Scraping from ollama.com +- **`_fetch_usage_level_sync()`** (`client.py`): New synchronous HTML scraper that parses `ollama.com/library/{model}` pages to determine actual GPU usage tiers: + - Handles **top-level model badges** (`x-test-model-cost-slot-active`) for unified-tier models + - Handles **per-tag listings** (`x-test-model-tag-cost` + `x-test-model-tag-usage-slot-active`) for models with multiple size variants + - Returns usage level 1-4, which maps to multiplier 0.25×, 0.50×, 0.75×, 1.00× +- **`fetch_usage_levels()`**: Async parallel fetcher with **1-hour global cache**. Fetches all library pages concurrently using `asyncio.gather()` with thread-pool execution for the blocking HTTP requests. +- **Wired into API responses**: Both `/v1/models` (OpenAI-compatible) and `/api/ollama/models` (internal) now return `usage_multiplier` and `usage_level` fields based on scraped live data. +- **Fixes major heuristic errors**: + - `gemma3:4b` and `gemma3:12b`: was 1.00×, now correctly **0.25×** (1 slot) + - `gemma3:27b`: was 1.00×, now correctly **0.50×** (2 slots) + - `ministral-3`: was 0.75×, now correctly **0.25×** (1 slot) + - `qwen3-vl`: stays **0.75×** (3 slots) — heuristic accidentally got this one right + - `deepseek-v4-pro`: stays **1.00×** (4 slots) + +#### Multi-Account Ollama Cloud Rotation +- **`accounts.py`**: New module managing multiple Ollama Cloud accounts: + - Each account has its own API key + session cookie + - Load-balanced request routing based on usage and subscription tier + - Quota-aware selection: least-loaded accounts preferred; new/untested accounts get priority for immediate validation +- **Premium model routing**: Models `kimi-k2.6` and `glm-5.1` restricted to Pro/Max accounts only. Free-tier accounts are skipped for these models. +- **Per-account usage tracking**: Analytics DB records which account handled each request, enabling per-account cost breakdowns. + +#### Model Catalog Expansion +- Added `minimax-m3` (new MiniMax flagship) +- Added `nemotron-3-ultra` (NVIDIA enterprise model) +- Added `kimi-k2.6` with 200k context window support + +#### Web Search / Scrape Emulation +- **Search provider plugins** (`search/providers/`): Modular search backend support: + - Brave Search API + - Cohere RAG API + - Exa (formerly Metaphor) + - Firecrawl (web scraping) + - Jina AI (neural search) + - SearXNG (self-hosted meta-search) + - Serper (Google Search API) + - Tavily (AI-native search) +- **Search router** (`search/base.py`): Unified interface — Guanaco presents a single `/search` endpoint regardless of which provider is configured. + +### Fixes + +#### Config & Install Robustness +- **Missing `UsageConfig` fields**: Added `last_plan`, `redirect_on_full`, `last_session_reset`, `last_weekly_reset`, `last_checked` to prevent `AttributeError` crashes on configs from v0.4.2 and earlier. +- **Config migration layer**: `load_config()` now auto-migrates v0.4.2 configs to v0.4.3+ schema on first load. No manual intervention needed. +- **Package rename**: Renamed PyPI package from `guanaco` → `guanaco-llm-proxy` to avoid collision with an existing `guanaco` package on PyPI. +- **Install script fixes** (`install.sh`): + - Ollama API key validation now uses the correct env var name + - Fixed `.env` file write pattern (was writing malformed key=value pairs) + - Fixed `grep` pattern for detecting existing config +- **Startup version sanity check**: Detects repo/venv version mismatch on boot and logs a warning. Prevents confusing "why is `/health` showing the old version?" issues. +- **systemd service**: Fixed `WorkingDirectory` to point at the actual repo checkout. Added `GUANACO_CONFIG_DIR` env var to service file. + +#### Dashboard & Analytics Fixes +- **Removed broken `usage_multiplier` column**: The analytics DB no longer tracks `usage_multiplier` per request (it was always wrong due to heuristic mismatch). Model-level multipliers are now fetched live from ollama.com. +- **Backward compat for `SearchConfig`**: Older installs missing search configuration no longer crash on startup. + +### Infrastructure + +#### CI/CD +- **GitHub Actions CI** (`.github/workflows/ci.yml`): Runs on every push — lint, type-check, unit tests. +- **GitHub Actions Release** (`.github/workflows/release.yml`): Automated PyPI publish on tag push. + +#### Docker +- **`Dockerfile.test`**: Containerized smoke-test environment for CI. +- **`test-local.sh`**: One-command local smoke test — builds Docker image, starts server, hits `/health`, validates version string. + +#### Project Hygiene +- Added `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `LICENSE` (MIT) +- Added `.gitignore` with Python/venv patterns +- Added macOS launch agent plist (`com.guanaco.start.plist`) +- Added systemd service templates (`guanaco.service`, `oct.service`) + +### API Changes + +#### Added Fields +- `/v1/models` response now includes: + - `usage_multiplier` (float): cost multiplier 0.25-1.00 + - `usage_level` (int): raw level 1-4, 0 = unknown +- `/api/ollama/models` response now includes: + - `usage_multiplier` (float) + - `usage_level` (int) + +#### Schema Changes +- `request_log` table: added `fallback_reason TEXT` column +- `request_log` table: removed `usage_multiplier` column (was unreliable) +- New `roi_config` table: stores `cache_hit_pct`, `price_multiplier`, per-model overrides + +### Performance +- **Parallel library scraping**: All ollama.com library pages are fetched concurrently. For a catalog of ~50 models, total scrape time is ~3-5 seconds vs. ~60 seconds sequential. +- **1-hour cache**: Scraped usage levels are cached globally, so the 3-5 second penalty only hits once per hour. +- **ROI price cache**: OpenRouter prices cached for 24 hours. Dashboard loads instantly after first visit. + +### Deprecated / Removed +- **Heuristic `_get_model_multiplier()`**: Still exists as fallback when ollama.com scraping fails, but is no longer the primary source. Returns `0.25` for ≤8B, `0.50` for ≤70B, `0.75` for ≤200B, `1.00` for larger. +- **`usage_multiplier` column in analytics DB**: Dropped. Use `/v1/models` or `/api/ollama/models` to get live multipliers. + +### Known Issues +- **Dev server restart unreliable on isolated instance**: The `uvicorn` process sometimes starts without producing logs. Production (`systemctl restart guanaco.service`) is unaffected. +- **Library scraper depends on ollama.com DOM**: If ollama.com changes their HTML test attributes (`x-test-model-*`), the scraper will fall back to heuristic. Monitor `/api/ollama/models` for sudden multiplier shifts. + +--- + +## [0.4.2] - 2026-05-15 + +### New Features +- Multi-account Ollama Cloud rotation with quota-aware selection +- Premium model routing (`kimi-k2.6`, `glm-5.1` → Pro/Max only) +- Per-account usage tracking + +--- + +## [0.4.1] - 2026-05-01 + +### Fixes +- Rate-limit retry logic for Ollama Cloud 429 responses +- SSE streaming stability improvements + +--- + +## [0.4.0] - 2026-04-20 + +### New Features +- Initial Ollama Cloud proxy support +- OpenAI-compatible `/v1/chat/completions` endpoint +- Token usage tracking with SQLite analytics DB +- Basic web dashboard + +--- + +## [0.3.9] and earlier + +See [GitHub releases](https://github.com/evangit2/guanaco/releases) for earlier versions. diff --git a/guanaco/__init__.py b/guanaco/__init__.py index 07fbdf9..c7e78f9 100644 --- a/guanaco/__init__.py +++ b/guanaco/__init__.py @@ -3,7 +3,7 @@ # Single source of truth for version. # importlib.metadata can return stale values after git-pull without re-pip-install, # so we always use the hardcoded fallback and only override if metadata matches. -__version__ = "0.5.0" +__version__ = "0.5.1" try: from importlib.metadata import version as _version diff --git a/guanaco/app.py b/guanaco/app.py index 3b7fddf..ea554b7 100644 --- a/guanaco/app.py +++ b/guanaco/app.py @@ -14,7 +14,7 @@ from fastapi.middleware.cors import CORSMiddleware from guanaco.config import load_config, get_config, AppConfig, get_base_url, get_tailscale_ip from guanaco.client import OllamaClient from guanaco.accounts import AccountPool -__version__ = "0.5.0" +__version__ = "0.5.1" from guanaco.router.router import create_router as create_llm_router from guanaco.search.providers import ALL_PROVIDERS from guanaco.dashboard import create_dashboard_router diff --git a/guanaco/dashboard/dashboard.py b/guanaco/dashboard/dashboard.py index c5fdaf9..4a4d048 100644 --- a/guanaco/dashboard/dashboard.py +++ b/guanaco/dashboard/dashboard.py @@ -895,7 +895,16 @@ def create_dashboard_router(key_manager: ApiKeyManager, analytics: AnalyticsLogg ) current_branch = branch_result.stdout.strip() or "main" - # Step 2: Git fetch + pull + # Step 2: Git fetch + hard reset — never fail because of local changes. + # We stash any local edits, reset to the exact remote commit, then pull. + # This guarantees the update always succeeds even if the user (or a prior + # partial update) left uncommitted files in the repo. + stash_result = subprocess.run( + ["git", "stash", "push", "-m", "pre-update-stash", "--include-untracked"], + cwd=project_dir, capture_output=True, text=True, timeout=15 + ) + # stash exit 0 = stashed something, exit 1 = nothing to stash — both OK + fetch_result = subprocess.run( ["git", "fetch", "origin", current_branch], cwd=project_dir, capture_output=True, text=True, timeout=30 @@ -903,12 +912,12 @@ def create_dashboard_router(key_manager: ApiKeyManager, analytics: AnalyticsLogg if fetch_result.returncode != 0: return {"status": "error", "step": "fetch", "message": fetch_result.stderr[:200]} - pull_result = subprocess.run( - ["git", "pull", "origin", current_branch], + reset_result = subprocess.run( + ["git", "reset", "--hard", f"origin/{current_branch}"], cwd=project_dir, capture_output=True, text=True, timeout=30 ) - if pull_result.returncode != 0: - return {"status": "error", "step": "pull", "message": pull_result.stderr[:200]} + if reset_result.returncode != 0: + return {"status": "error", "step": "reset", "message": reset_result.stderr[:200]} # Step 3: Reinstall into venv install_dir = Path.home() / ".guanaco" diff --git a/pyproject.toml b/pyproject.toml index bcefec3..02f60a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "guanaco-llm-proxy" -version = "0.5.0" +version = "0.5.1" description = "OpenAI-compatible LLM proxy that maximizes Ollama Cloud subscriptions — search/scrape API emulation, usage tracking, fallback provider support, and a web dashboard" readme = "README.md" license = {text = "MIT"}