From 7180b27a8acbf77a0bead22be5ed59d7ae99341b Mon Sep 17 00:00:00 2001 From: evangit2 Date: Fri, 22 May 2026 20:48:24 +0000 Subject: [PATCH 01/11] =?UTF-8?q?v0.4.3=20=E2=80=94=20add=20kimi-k2.6=20vi?= =?UTF-8?q?sion=20model,=20default=20all=20roles=20to=20nemotron-3-nano:30?= =?UTF-8?q?b?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- guanaco/__init__.py | 2 +- guanaco/app.py | 2 +- guanaco/cli.py | 8 ++++---- guanaco/client.py | 7 ++++--- guanaco/config.py | 17 ++++++++++------- guanaco/dashboard/templates/dashboard.html | 2 ++ pyproject.toml | 2 +- 7 files changed, 23 insertions(+), 17 deletions(-) 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/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..cc3f3f0 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" diff --git a/guanaco/client.py b/guanaco/client.py index 4f3660f..5c54bcc 100644 --- a/guanaco/client.py +++ b/guanaco/client.py @@ -47,6 +47,7 @@ KNOWN_CLOUD_MODELS = { "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.6": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "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"]}, @@ -430,8 +431,8 @@ class OllamaClient: # Estimate tokens from character count (4 chars โ‰ˆ 1 token) estimated_content_tokens = max(1, content_chars // 4) if content_chars else 0 estimated_reasoning_tokens = max(1, reasoning_chars // 4) if reasoning_chars else 0 - # Use API-provided completion_tokens if available, otherwise estimated content tokens - final_tokens = completion_tokens or estimated_content_tokens + # Prefer API-provided completion_tokens; otherwise estimate from chars (content + reasoning) + final_tokens = completion_tokens or (estimated_content_tokens + estimated_reasoning_tokens) elapsed = time.time() - start ttft = (first_token_time - start) if first_token_time else None generation_time = (elapsed - ttft) if ttft and elapsed > ttft else elapsed @@ -479,7 +480,7 @@ class OllamaClient: # Estimate tokens and yield [DONE] + metrics anyway estimated_content_tokens = max(1, content_chars // 4) if content_chars else 0 estimated_reasoning_tokens = max(1, reasoning_chars // 4) if reasoning_chars else 0 - final_tokens = completion_tokens or estimated_content_tokens + final_tokens = completion_tokens or (estimated_content_tokens + estimated_reasoning_tokens) elapsed = time.time() - start ttft = (first_token_time - start) if first_token_time else None generation_time = (elapsed - ttft) if ttft and elapsed > ttft else elapsed diff --git a/guanaco/config.py b/guanaco/config.py index c395af4..3909bf8 100644 --- a/guanaco/config.py +++ b/guanaco/config.py @@ -71,17 +71,18 @@ class HistoryConfig(BaseModel): class LLMConfig(BaseModel): """LLM model selection config.""" - reranker_model: str = "gpt-oss:120b" - scraper_model: str = "gemma4:31b" - summary_model: str = "qwen3.5:397b" - default_model: str = "gemma4:31b" + reranker_model: str = "nemotron-3-nano:30b" + scraper_model: str = "nemotron-3-nano:30b" + summary_model: str = "nemotron-3-nano:30b" + default_model: str = "nemotron-3-nano:30b" available_models: list[str] = Field(default_factory=lambda: [ "qwen3.5:397b", "qwen3-coder:480b", "qwen3-vl:235b", "qwen3-next:80b", "gpt-oss:120b", "gpt-oss:20b", "deepseek-v3.1:671b", "deepseek-v3.2", "gemma4:31b", "gemma3:27b", "glm-5.1", "glm-5", "minimax-m2.7", "minimax-m2.5", "minimax-m2.1", "devstral-small-2:24b", "devstral-2:123b", "nemotron-3-super", - "cogito-2.1:671b", "mistral-large-3:675b", "kimi-k2.5", "ministral-3:14b", + "nemotron-3-nano:30b", + "cogito-2.1:671b", "mistral-large-3:675b", "kimi-k2.5", "kimi-k2.6", "ministral-3:14b", ]) emulate_anthropic: bool = True emulate_openai: bool = True @@ -191,9 +192,10 @@ class AppConfig(BaseModel): if acc.name == "ollama": return acc # Auto-create from legacy single-key config, merging usage cookie/data + # Use ollama_api_key_resolved so env-var-only setups get a working key return OllamaAccount( name="ollama", - api_key=self.ollama_api_key, + api_key=self.ollama_api_key_resolved, session_cookie=self.usage.session_cookie if hasattr(self, 'usage') else "", last_session_pct=self.usage.last_session_pct if hasattr(self, 'usage') else None, last_weekly_pct=self.usage.last_weekly_pct if hasattr(self, 'usage') else None, @@ -227,9 +229,10 @@ def load_config(path: Optional[Path] = None) -> AppConfig: # Ensure the primary "ollama" account is always in the accounts list if not any(a.name == "ollama" for a in _config.ollama_accounts): # Create primary from the legacy single-key config + usage data + # Use ollama_api_key_resolved so env-var-only setups get a working key _config.ollama_accounts.insert(0, OllamaAccount( name="ollama", - api_key=_config.ollama_api_key, + api_key=_config.ollama_api_key_resolved, session_cookie=_config.usage.session_cookie if hasattr(_config, 'usage') else "", last_session_pct=_config.usage.last_session_pct if hasattr(_config, 'usage') else None, last_weekly_pct=_config.usage.last_weekly_pct if hasattr(_config, 'usage') else None, diff --git a/guanaco/dashboard/templates/dashboard.html b/guanaco/dashboard/templates/dashboard.html index 18e8a40..56159f7 100644 --- a/guanaco/dashboard/templates/dashboard.html +++ b/guanaco/dashboard/templates/dashboard.html @@ -2045,6 +2045,8 @@ function _getCapabilities(modelName) { 'nemotron-3-super': ['cloud','tools','thinking'], 'mistral-large-3': ['cloud','tools','thinking'], 'ministral-3': ['cloud','tools'], + 'nemotron-3-nano': ['cloud','tools','thinking'], + 'kimi-k2.6': ['cloud','tools','thinking','vision'], 'kimi-k2.5': ['cloud','tools','thinking','vision'], 'cogito-2.1': ['cloud','thinking'], }; diff --git a/pyproject.toml b/pyproject.toml index d88528c..3ab659b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "guanaco" -version = "0.4.2" +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 9a2d0d76cd420541c135e0521bbc0bb50bf3a476 Mon Sep 17 00:00:00 2001 From: evangit2 Date: Sat, 23 May 2026 19:56:44 +0000 Subject: [PATCH 02/11] 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 --- guanaco/analytics.py | 42 +++++++++-- guanaco/client.py | 74 ++++++++++-------- guanaco/config.py | 4 +- guanaco/dashboard/templates/dashboard.html | 55 ++++++++++++-- guanaco/router/router.py | 87 +++++++++++++++++++--- 5 files changed, 207 insertions(+), 55 deletions(-) diff --git a/guanaco/analytics.py b/guanaco/analytics.py index 8fb6212..b4d881a 100644 --- a/guanaco/analytics.py +++ b/guanaco/analytics.py @@ -78,7 +78,12 @@ class AnalyticsLogger: conn.execute(f"ALTER TABLE request_log ADD COLUMN {col}") except sqlite3.OperationalError: pass # column already exists - # Migration: add fallback_reason column + # Migration: add usage_multiplier column for cost-weighted analytics + try: + conn.execute("ALTER TABLE request_log ADD COLUMN usage_multiplier REAL DEFAULT 1.0") + except sqlite3.OperationalError: + pass + try: conn.execute("ALTER TABLE request_log ADD COLUMN fallback_reason TEXT") except sqlite3.OperationalError: @@ -154,11 +159,24 @@ class AnalyticsLogger: output_text: Optional[str] = None, fallback_reason: Optional[str] = None, account_name: Optional[str] = None, + usage_multiplier: float = 1.00, ) -> str: """Log an LLM request. Returns the log entry ID.""" # 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 + # Auto-resolve usage multiplier from model name if not explicitly passed + if usage_multiplier == 1.00: + try: + from guanaco.client import KNOWN_CLOUD_MODELS + base = model.split(":")[0] + if base in KNOWN_CLOUD_MODELS: + usage_multiplier = KNOWN_CLOUD_MODELS[base].get("usage_multiplier", 1.00) + except Exception: + pass + # Auto-compute total_tokens if not explicitly provided + if total_tokens is None or total_tokens == 0: + total_tokens = (prompt_tokens or 0) + (completion_tokens or 0) entry_id = str(uuid.uuid4()) with sqlite3.connect(self.db_path) as conn: conn.execute( @@ -166,12 +184,12 @@ class AnalyticsLogger: (id, ts, type, model, prompt_tokens, completion_tokens, total_tokens, tps, prompt_tps, ttft_seconds, total_duration_seconds, load_duration_seconds, error, request_id, provider, fallback_for, - source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name) - VALUES (?, ?, 'llm', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name, usage_multiplier) + VALUES (?, ?, 'llm', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (entry_id, time.time(), model, prompt_tokens, completion_tokens, total_tokens, tps, prompt_tps, ttft_seconds, total_duration_seconds, load_duration_seconds, error, request_id, provider, fallback_for, - source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name), + source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name, usage_multiplier), ) # Write plaintext log file if configured @@ -336,13 +354,21 @@ class AnalyticsLogger: search_calls = conn.execute("SELECT COUNT(*) FROM request_log WHERE type='search'").fetchone()[0] errors = conn.execute("SELECT COUNT(*) FROM request_log WHERE error IS NOT NULL").fetchone()[0] - # Token totals + # Token totals (raw) row = conn.execute( "SELECT COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), " "COALESCE(SUM(total_tokens),0) FROM request_log WHERE type='llm'" ).fetchone() prompt_tokens, completion_tokens, total_tokens = row + # Weighted token totals (usage multiplier applied) + weighted_row = conn.execute( + "SELECT COALESCE(SUM(prompt_tokens * COALESCE(usage_multiplier,1.0)),0), " + "COALESCE(SUM(completion_tokens * COALESCE(usage_multiplier,1.0)),0), " + "COALESCE(SUM(total_tokens * COALESCE(usage_multiplier,1.0)),0) FROM request_log WHERE type='llm'" + ).fetchone() + weighted_prompt, weighted_completion, weighted_total = weighted_row + # Average TPS โ€” based on most recent rows covering ~10k completion tokens tps_rows = conn.execute( "SELECT tps, COALESCE(completion_tokens,0) FROM request_log " @@ -374,7 +400,7 @@ class AnalyticsLogger: # Per-model stats โ€” TPS/TTFT from most recent 10k completion tokens per model model_rows = conn.execute( """SELECT model, COUNT(*), SUM(prompt_tokens), SUM(completion_tokens), - MAX(ts) + MAX(ts), AVG(COALESCE(usage_multiplier,1.0)) FROM request_log WHERE type='llm' GROUP BY model ORDER BY MAX(ts) DESC""" ).fetchall() models = [] @@ -416,6 +442,7 @@ class AnalyticsLogger: "avg_tps": m_avg_tps, "avg_ttft": m_avg_ttft, "last_used": row[4], + "usage_multiplier": round(row[5] or 1.0, 2), }) # Per-provider stats (for search calls) @@ -536,6 +563,9 @@ class AnalyticsLogger: "prompt_tokens": prompt_tokens or 0, "completion_tokens": completion_tokens or 0, "total_tokens": total_tokens or 0, + "weighted_prompt": round(weighted_prompt or 0, 0), + "weighted_completion": round(weighted_completion or 0, 0), + "weighted_total": round(weighted_total or 0, 0), "avg_tps": avg_tps, "avg_ttft": avg_ttft, "models": models, diff --git a/guanaco/client.py b/guanaco/client.py index 5c54bcc..51b370d 100644 --- a/guanaco/client.py +++ b/guanaco/client.py @@ -22,38 +22,42 @@ OLLAMA_SETTINGS_URL = f"{OLLAMA_BASE}/api/account/settings" # 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.6": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "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}, + "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}, } @@ -203,6 +207,7 @@ class OllamaClient: "family": family, "quantization": quant, "capabilities": self._get_model_capabilities(name), + "usage_multiplier": self._get_model_multiplier(name), "modified_at": m.get("modified_at", ""), "digest": m.get("digest", "")[:12] if m.get("digest") else "", }) @@ -216,6 +221,13 @@ class OllamaClient: # Default capabilities for unknown models return ["cloud"] + def _get_model_multiplier(self, model_name: str) -> float: + """Get usage multiplier (cost tier) for a model name. 0.25, 0.50, 0.75, 1.00.""" + 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) + return 1.00 + # โ”€โ”€ Usage / Quota โ”€โ”€ async def get_usage(self, session_cookie: str = "") -> dict: diff --git a/guanaco/config.py b/guanaco/config.py index 3909bf8..58c2612 100644 --- a/guanaco/config.py +++ b/guanaco/config.py @@ -77,8 +77,8 @@ class LLMConfig(BaseModel): default_model: str = "nemotron-3-nano:30b" available_models: list[str] = Field(default_factory=lambda: [ "qwen3.5:397b", "qwen3-coder:480b", "qwen3-vl:235b", "qwen3-next:80b", - "gpt-oss:120b", "gpt-oss:20b", "deepseek-v3.1:671b", "deepseek-v3.2", - "gemma4:31b", "gemma3:27b", "glm-5.1", "glm-5", + "gpt-oss:120b", "gpt-oss:20b", "deepseek-v3.1:671b", "deepseek-v3.2", "deepseek-v4-pro", "deepseek-v4-flash", + "gemma4:31b", "gemma3:27b", "glm-5.1", "glm-5", "gemini-3-flash-preview", "minimax-m2.7", "minimax-m2.5", "minimax-m2.1", "devstral-small-2:24b", "devstral-2:123b", "nemotron-3-super", "nemotron-3-nano:30b", diff --git a/guanaco/dashboard/templates/dashboard.html b/guanaco/dashboard/templates/dashboard.html index 56159f7..e8a31e6 100644 --- a/guanaco/dashboard/templates/dashboard.html +++ b/guanaco/dashboard/templates/dashboard.html @@ -64,6 +64,11 @@ .cap.vision { background: rgba(6,182,212,0.15); color: var(--cyan); } .cap.tools { background: rgba(124,58,237,0.15); color: var(--accent); } .cap.thinking { background: rgba(245,158,11,0.15); color: var(--orange); } + /* Usage multiplier bars */ + .usage-bar { display: flex; align-items: center; gap: 3px; margin-top: 6px; } + .usage-slot { display: inline-block; height: 3px; width: 10px; border-radius: 2px; background: var(--border); } + .usage-slot.usage-active { background: var(--accent); } + .usage-label { font-size: 10px; color: var(--text2); margin-left: 3px; } .model-select-btn { background: var(--accent); color: white; border: none; padding: 4px 10px; border-radius: 6px; cursor: pointer; font-size: 11px; font-weight: 600; margin-top: 8px; } .model-select-btn:hover { opacity: 0.9; } @@ -102,7 +107,7 @@ .metric .metric-val { font-size: 22px; font-weight: 700; color: var(--accent); } .metric .metric-label { font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; margin-top: 4px; } - .usage-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr 1fr 1fr; padding: 10px 14px; background: var(--surface2); border-radius: 8px; margin-bottom: 6px; font-size: 12px; } + .usage-row { display: grid; grid-template-columns: 2fr 0.7fr 0.6fr 1fr 1fr 1fr 0.8fr 0.8fr; padding: 10px 14px; background: var(--surface2); border-radius: 8px; margin-bottom: 6px; font-size: 12px; } .usage-row.header { font-weight: 600; color: var(--text2); } .usage-bar-track { width: 100%; height: 8px; background: var(--surface2); border-radius: 4px; overflow: hidden; border: 1px solid var(--border); } .usage-bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s ease, background 0.3s ease; } @@ -408,6 +413,12 @@
0
Errors
โ€”
Fallback Rate
+
+
0
Weighted Prompt
+
0
Weighted Comp
+
0
Weighted Total
+
1.0x
Avg Multiplier
+
@@ -419,7 +430,7 @@
-
Model
Requests
Prompt Tok
Comp Tok
Avg TPS
Avg TTFT
+
Model
Reqs
Mul
Prompt
Comp
W-Total
Avg TPS
Avg TTFT
@@ -1208,7 +1219,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 +1248,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 +1260,7 @@ function renderModels(models) { ${quant ? `โšก ${quant}` : ''}
${capHtml}
+
${slots}${mult.toFixed(2)}x
`; }).join(''); } @@ -1308,6 +1323,14 @@ function loadAnalytics() { document.getElementById('an-search').textContent = (data.search_calls || 0).toLocaleString(); document.getElementById('an-ptokens').textContent = (data.prompt_tokens || 0).toLocaleString(); document.getElementById('an-ctokens').textContent = (data.completion_tokens || 0).toLocaleString(); + document.getElementById('an-wptokens').textContent = (data.weighted_prompt || 0).toLocaleString(); + document.getElementById('an-wctokens').textContent = (data.weighted_completion || 0).toLocaleString(); + document.getElementById('an-wtokens').textContent = (data.weighted_total || 0).toLocaleString(); + // Compute average multiplier from raw vs weighted totals (avoid divide by zero) + const rawTotal = (data.prompt_tokens || 0) + (data.completion_tokens || 0); + const wTotal = (data.weighted_total || 0); + const avgMult = rawTotal > 0 ? (wTotal / rawTotal).toFixed(2) + 'x' : '1.0x'; + document.getElementById('an-mult').textContent = avgMult; document.getElementById('an-tps').textContent = data.avg_tps || 0; document.getElementById('an-ttft').textContent = data.avg_ttft ? (data.avg_ttft * 1000).toFixed(0) + 'ms' : 'โ€”'; document.getElementById('an-errors').textContent = (data.errors || 0).toLocaleString(); @@ -1330,8 +1353,10 @@ function loadAnalytics() {
${m.model}
${m.requests}
+
${(m.usage_multiplier || 1.0).toFixed(2)}x
${(m.prompt_tokens || 0).toLocaleString()}
${(m.completion_tokens || 0).toLocaleString()}
+
${Math.round((m.prompt_tokens || 0) * (m.usage_multiplier || 1.0) + (m.completion_tokens || 0) * (m.usage_multiplier || 1.0)).toLocaleString()}
${m.avg_tps || 'โ€”'}
${m.avg_ttft ? (m.avg_ttft * 1000).toFixed(0) + 'ms' : 'โ€”'}
@@ -1359,9 +1384,10 @@ function loadAnalytics() { `).join('') : '
No errors ๐ŸŽ‰
'; - // Top stats + // Top stats โ€” show weighted 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.weighted_total || 0).toLocaleString(); + document.getElementById('stat-tokens').nextElementSibling.textContent = 'Weighted 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; @@ -2035,6 +2061,9 @@ 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'], @@ -2053,6 +2082,22 @@ function _getCapabilities(modelName) { return known[base] || ['cloud']; } +function _getMultiplier(modelName) { + 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, + }; + return mults[modelName.split(':')[0]] || 0.75; +} + // โ”€โ”€โ”€ Init โ”€โ”€โ”€ document.addEventListener('DOMContentLoaded', () => { diff --git a/guanaco/router/router.py b/guanaco/router/router.py index 9282f20..ee1c947 100644 --- a/guanaco/router/router.py +++ b/guanaco/router/router.py @@ -552,10 +552,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 +613,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 +797,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 +1521,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 +1586,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): From 5242f662daf571f052222af1aa89890419c34af5 Mon Sep 17 00:00:00 2001 From: evangit2 Date: Sat, 23 May 2026 21:18:02 +0000 Subject: [PATCH 03/11] 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. --- guanaco/client.py | 60 ++++++++++++++++++++-- guanaco/dashboard/templates/dashboard.html | 40 ++++++++++++++- 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/guanaco/client.py b/guanaco/client.py index 51b370d..443c4a6 100644 --- a/guanaco/client.py +++ b/guanaco/client.py @@ -214,18 +214,70 @@ class OllamaClient: 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. 0.25, 0.50, 0.75, 1.00.""" + """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 โ”€โ”€ diff --git a/guanaco/dashboard/templates/dashboard.html b/guanaco/dashboard/templates/dashboard.html index e8a31e6..8dc328a 100644 --- a/guanaco/dashboard/templates/dashboard.html +++ b/guanaco/dashboard/templates/dashboard.html @@ -2079,10 +2079,25 @@ function _getCapabilities(modelName) { '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, @@ -2095,7 +2110,28 @@ function _getMultiplier(modelName) { '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, }; - return mults[modelName.split(':')[0]] || 0.75; + 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 โ”€โ”€โ”€ From 07fe4fd587179e85dc3c8a14a6d00dd4ce7ac726 Mon Sep 17 00:00:00 2001 From: evangit2 Date: Sat, 23 May 2026 21:32:54 +0000 Subject: [PATCH 04/11] feat: experimental Subscription Value Calculator (ROI) vs OpenRouter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- guanaco/config.py | 17 +- guanaco/dashboard/dashboard.py | 68 +++++ guanaco/dashboard/templates/dashboard.html | 237 +++++++++++++++- guanaco/roi.py | 306 +++++++++++++++++++++ 4 files changed, 622 insertions(+), 6 deletions(-) create mode 100644 guanaco/roi.py diff --git a/guanaco/config.py b/guanaco/config.py index 58c2612..2b81bbf 100644 --- a/guanaco/config.py +++ b/guanaco/config.py @@ -147,12 +147,18 @@ class UsageConfig(BaseModel): check_interval: int = 0 # Auto-check interval in seconds (0 = disabled) last_session_pct: Optional[float] = None # Last known session usage % last_weekly_pct: Optional[float] = None # Last known weekly usage % - last_plan: Optional[str] = None # Last known plan name - last_session_reset: Optional[str] = None # e.g. "Resets in 7 minutes" - last_weekly_reset: Optional[str] = None # e.g. "Resets in 3 days" - last_checked: Optional[float] = None # Unix timestamp of last successful check - redirect_on_full: bool = False # Route all requests to fallback when quota is near limit +class ROIConfig(BaseModel): + """Experimental: subscription value comparison vs OpenRouter pay-as-you-go.""" + enabled: bool = False # Opt-in โ€” must be explicitly enabled + # Subscription tier: free (no value calc โ€” just usage estimate), pro=20, max=100 + subscription_price: float = 0.0 # Monthly subscription cost in USD + # OpenRouter prices are fetched live; this caches them for 1 hour + last_price_cache: float = 0.0 # Unix timestamp of last OpenRouter fetch + cached_prices: dict[str, dict] = Field(default_factory=dict) # model_slug -> {prompt, completion} + # Last calculated ROI summary (for dashboard display) + last_roi_calc: float = 0.0 # Unix timestamp + last_roi_detail: dict = Field(default_factory=dict) # cached summary dict class OllamaAccount(BaseModel): """A single Ollama Cloud account with its own API key and usage tracking.""" @@ -177,6 +183,7 @@ class AppConfig(BaseModel): providers: AllProvidersConfig = Field(default_factory=AllProvidersConfig) cache: CacheConfig = Field(default_factory=CacheConfig) usage: UsageConfig = Field(default_factory=UsageConfig) + roi: ROIConfig = Field(default_factory=ROIConfig) search: SearchConfig = Field(default_factory=SearchConfig) history: HistoryConfig = Field(default_factory=HistoryConfig) diff --git a/guanaco/dashboard/dashboard.py b/guanaco/dashboard/dashboard.py index f00c177..6deec76 100644 --- a/guanaco/dashboard/dashboard.py +++ b/guanaco/dashboard/dashboard.py @@ -1228,4 +1228,72 @@ def create_dashboard_router(key_manager: ApiKeyManager, analytics: AnalyticsLogg except Exception as e: return {"source": "error", "error": str(e)} + # โ”€โ”€ ROI / Subscription Value Calculator (Experimental) โ”€โ”€ + + @router.get("/api/roi/config") + async def get_roi_config(request: Request): + config = get_config() + rc = config.roi + return { + "enabled": rc.enabled, + "subscription_price": rc.subscription_price, + "last_price_cache": rc.last_price_cache, + "last_roi_calc": rc.last_roi_calc, + "price_entries_cached": len(rc.cached_prices), + } + + @router.post("/api/roi/config") + async def set_roi_config(request: Request): + body = await request.json() + config = get_config() + if "enabled" in body: + config.roi.enabled = bool(body["enabled"]) + if "subscription_price" in body: + config.roi.subscription_price = float(body["subscription_price"]) + save_config(config) + return {"status": "ok", "enabled": config.roi.enabled, "subscription_price": config.roi.subscription_price} + + @router.get("/api/roi/calculate") + async def roi_calculate(request: Request): + """Run a fresh ROI calculation using current analytics DB and latest OpenRouter prices.""" + config = get_config() + if not config.roi.enabled: + return {"error": "ROI feature is not enabled. Toggle it in the Status tab."} + + # Determine plan and price from config + sub_price = config.roi.subscription_price or 20.0 + weekly_pct = config.usage.last_weekly_pct or 0.0 + + db_path = analytics.db_path if hasattr(analytics, "db_path") else None + if db_path is None: + return {"error": "Analytics DB path unavailable"} + + from guanaco.roi import calculate_roi + result = calculate_roi(db_path, subscription_monthly=sub_price, weekly_pct_used=weekly_pct) + + # Persist to config + config.roi.last_roi_detail = result + config.roi.last_roi_calc = time.time() + save_config(config) + return result + + @router.get("/api/roi/last") + async def roi_last(request: Request): + """Return the last calculated ROI (cached).""" + config = get_config() + if not config.roi.enabled: + return {"error": "ROI feature is not enabled"} + return config.roi.last_roi_detail or {"error": "No ROI calculated yet. Run Calculate first."} + + @router.post("/api/roi/reset") + async def roi_reset(request: Request): + """Reset ROI data collection by clearing the last calculation and any cached prices.""" + config = get_config() + config.roi.last_roi_detail = {} + config.roi.last_roi_calc = 0.0 + config.roi.cached_prices = {} + config.roi.last_price_cache = 0.0 + save_config(config) + return {"status": "ok", "message": "ROI data reset."} + return router \ No newline at end of file diff --git a/guanaco/dashboard/templates/dashboard.html b/guanaco/dashboard/templates/dashboard.html index 8dc328a..25b1864 100644 --- a/guanaco/dashboard/templates/dashboard.html +++ b/guanaco/dashboard/templates/dashboard.html @@ -592,6 +592,79 @@ + + +

๐Ÿ“‹ Event Log

@@ -821,7 +894,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(); } } @@ -2549,6 +2622,168 @@ async function toggleAutoUpdate() { toggle.checked = !enabled; } } + +// โ”€โ”€โ”€ ROI Calculator (Experimental) โ”€โ”€โ”€ + +let roiEnabled = false; +let roiPrice = 20.0; + +function loadROIConfig() { + fetch('/dashboard/api/roi/config').then(r => r.json()).then(data => { + roiEnabled = data.enabled; + roiPrice = data.subscription_price || 20.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; + } + if (roiEnabled) { + document.getElementById('roi-disabled-msg').style.display = 'none'; + loadLastROI(); + } 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; + fetch('/dashboard/api/roi/config', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ enabled, subscription_price: price }) + }).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 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); + 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)'); + + 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'; +} + +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..71c3f49 --- /dev/null +++ b/guanaco/roi.py @@ -0,0 +1,306 @@ +""" +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 :cloud / :local suffixes and lower-case.""" + base = name.split(":")[0].lower() + 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) + if prompt > 0 or completion > 0: + # Convert from per-token ($/token) to per-million-tokens ($/Mt) + prices[model_id] = { + "prompt": prompt * 1_000_000, + "completion": completion * 1_000_000, + } + 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 (rare) + if norm in prices: + return prices[norm] + + # 2. Family prefix match โ€” find cheapest matching family entry + best_family_price = None + best_family_score = -9999 + for orouter_id, price_info in prices.items(): + orouter_norm = _normalized(orouter_id) + for frag, (family_exact, family_prefix) in FAMILY_MAP.items(): + if frag in norm and family_prefix and family_prefix in orouter_norm: + # 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) -> dict: + result = {} + 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) + prom_mt = pt / 1_000_000 + comp_mt = ct / 1_000_000 + cost = (prom_mt * price["prompt"]) + (comp_mt * price["completion"]) + result[model] = { + "prompt_tokens": pt, + "completion_tokens": ct, + "prompt_per_mt": price["prompt"], + "completion_per_mt": price["completion"], + "cost": cost, + "matched_price_model": _find_best_price.__module__, # marker + } + 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) + FROM request_log WHERE type='llm' AND ts > ? GROUP BY model""", + (since,), + ).fetchall() + for row in rows: + model, pt, ct, w_pt, w_ct = row + usage_by_model[model] = {"prompt_tokens": pt, "completion_tokens": ct} + 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 calculate_roi( + db_path: Path | str, + subscription_monthly: float = 20.0, + weekly_pct_used: 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) + + 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, 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 = time.time() - (7 * 24 * 3600) + usage_by_model, total_weighted = get_usage_from_analytics(db_path, since) + + # 3. Map usage to prices + priced = _map_usage_to_prices(usage_by_model, prices) + + 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, + }) + 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_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, + "prices_stale": prices_stale, + "by_model": by_model, + "unmatched_models": unmatched, + "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 From 9793ce2c4424b71dd573c7f9ed6856be4f7027a7 Mon Sep 17 00:00:00 2001 From: evangit2 <105747602+evangit2@users.noreply.github.com> Date: Sat, 23 May 2026 22:32:45 +0000 Subject: [PATCH 05/11] 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 --- .gitignore | 2 +- guanaco/cli.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++ guanaco/config.py | 39 ++++++++++++++++++++++++++--- install.sh | 22 +++++++++++++++++ pyproject.toml | 2 +- 5 files changed, 122 insertions(+), 5 deletions(-) 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/cli.py b/guanaco/cli.py index cc3f3f0..a088e3b 100644 --- a/guanaco/cli.py +++ b/guanaco/cli.py @@ -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/config.py b/guanaco/config.py index 2b81bbf..21ebe10 100644 --- a/guanaco/config.py +++ b/guanaco/config.py @@ -147,6 +147,12 @@ class UsageConfig(BaseModel): check_interval: int = 0 # Auto-check interval in seconds (0 = disabled) last_session_pct: Optional[float] = None # Last known session usage % last_weekly_pct: Optional[float] = None # Last known weekly usage % + # v0.4.3+ fields โ€” added for multi-account migration + last_plan: Optional[str] = None # Last known plan (free/pro/max) + last_session_reset: Optional[str] = None # Human-readable time until session resets + last_weekly_reset: Optional[str] = None # Human-readable time until weekly resets + last_checked: Optional[float] = None # Unix timestamp of last successful check + redirect_on_full: bool = False # Route to fallback when quota near limit class ROIConfig(BaseModel): """Experimental: subscription value comparison vs OpenRouter pay-as-you-go.""" @@ -222,16 +228,43 @@ _config: Optional[AppConfig] = None def load_config(path: Optional[Path] = None) -> AppConfig: - """Load configuration from YAML file, falling back to defaults.""" + """Load configuration from YAML file, falling back to defaults. + + Includes migration for backward compatibility: + - v0.4.2 configs missing UsageConfig fields get auto-populated with defaults. + """ global _config path = path or get_default_config_path() if path.exists(): with open(path) as f: data = yaml.safe_load(f) or {} - _config = AppConfig(**data) else: - _config = AppConfig() + data = {} + + # โ”€โ”€ Config migration โ”€โ”€ + # v0.4.2 โ†’ v0.4.3+: UsageConfig gained last_plan, redirect_on_full, etc. + usage = data.setdefault("usage", {}) + for field, default in ( + ("last_plan", None), + ("last_session_reset", None), + ("last_weekly_reset", None), + ("last_checked", None), + ("redirect_on_full", False), + ): + if field not in usage: + usage[field] = default + + # v0.4.1 โ†’ v0.4.2+: RouterConfig gained auto_update, allow_prerelease + router = data.setdefault("router", {}) + for field, default in ( + ("auto_update", False), + ("allow_prerelease", False), + ): + if field not in router: + router[field] = default + + _config = AppConfig(**data) # Ensure the primary "ollama" account is always in the accounts list if not any(a.name == "ollama" for a in _config.ollama_accounts): 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 3ab659b..b0812ed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ requires = ["setuptools>=68.0", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "guanaco" +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" From 00d7509d32318b4ef6242511b5725665bbcc51e2 Mon Sep 17 00:00:00 2001 From: Evan Rice Date: Wed, 10 Jun 2026 00:16:40 +0000 Subject: [PATCH 06/11] feat(v0.4.3): token estimation fallback, ROI dashboard, per-model usage breakdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- guanaco/analytics.py | 63 +++--- guanaco/client.py | 48 ++++- guanaco/config.py | 19 +- guanaco/dashboard/dashboard.py | 42 +++- guanaco/dashboard/templates/dashboard.html | 227 ++++++++++++++++++--- guanaco/roi.py | 209 +++++++++++++++++-- 6 files changed, 514 insertions(+), 94 deletions(-) diff --git a/guanaco/analytics.py b/guanaco/analytics.py index b4d881a..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 @@ -78,12 +83,7 @@ class AnalyticsLogger: conn.execute(f"ALTER TABLE request_log ADD COLUMN {col}") except sqlite3.OperationalError: pass # column already exists - # Migration: add usage_multiplier column for cost-weighted analytics - try: - conn.execute("ALTER TABLE request_log ADD COLUMN usage_multiplier REAL DEFAULT 1.0") - except sqlite3.OperationalError: - pass - + # Migration: add fallback_reason column try: conn.execute("ALTER TABLE request_log ADD COLUMN fallback_reason TEXT") except sqlite3.OperationalError: @@ -159,24 +159,27 @@ class AnalyticsLogger: output_text: Optional[str] = None, fallback_reason: Optional[str] = None, account_name: Optional[str] = None, - usage_multiplier: float = 1.00, ) -> str: """Log an LLM request. Returns the log entry ID.""" # 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 - # Auto-resolve usage multiplier from model name if not explicitly passed - if usage_multiplier == 1.00: - try: - from guanaco.client import KNOWN_CLOUD_MODELS - base = model.split(":")[0] - if base in KNOWN_CLOUD_MODELS: - usage_multiplier = KNOWN_CLOUD_MODELS[base].get("usage_multiplier", 1.00) - except Exception: - pass - # Auto-compute total_tokens if not explicitly provided - if total_tokens is None or total_tokens == 0: - total_tokens = (prompt_tokens or 0) + (completion_tokens or 0) + + # 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( @@ -184,12 +187,12 @@ class AnalyticsLogger: (id, ts, type, model, prompt_tokens, completion_tokens, total_tokens, tps, prompt_tps, ttft_seconds, total_duration_seconds, load_duration_seconds, error, request_id, provider, fallback_for, - source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name, usage_multiplier) - VALUES (?, ?, 'llm', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name) + VALUES (?, ?, 'llm', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (entry_id, time.time(), model, prompt_tokens, completion_tokens, total_tokens, tps, prompt_tps, ttft_seconds, total_duration_seconds, load_duration_seconds, error, request_id, provider, fallback_for, - source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name, usage_multiplier), + source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name), ) # Write plaintext log file if configured @@ -354,21 +357,13 @@ class AnalyticsLogger: search_calls = conn.execute("SELECT COUNT(*) FROM request_log WHERE type='search'").fetchone()[0] errors = conn.execute("SELECT COUNT(*) FROM request_log WHERE error IS NOT NULL").fetchone()[0] - # Token totals (raw) + # Token totals row = conn.execute( "SELECT COALESCE(SUM(prompt_tokens),0), COALESCE(SUM(completion_tokens),0), " "COALESCE(SUM(total_tokens),0) FROM request_log WHERE type='llm'" ).fetchone() prompt_tokens, completion_tokens, total_tokens = row - # Weighted token totals (usage multiplier applied) - weighted_row = conn.execute( - "SELECT COALESCE(SUM(prompt_tokens * COALESCE(usage_multiplier,1.0)),0), " - "COALESCE(SUM(completion_tokens * COALESCE(usage_multiplier,1.0)),0), " - "COALESCE(SUM(total_tokens * COALESCE(usage_multiplier,1.0)),0) FROM request_log WHERE type='llm'" - ).fetchone() - weighted_prompt, weighted_completion, weighted_total = weighted_row - # Average TPS โ€” based on most recent rows covering ~10k completion tokens tps_rows = conn.execute( "SELECT tps, COALESCE(completion_tokens,0) FROM request_log " @@ -400,7 +395,7 @@ class AnalyticsLogger: # Per-model stats โ€” TPS/TTFT from most recent 10k completion tokens per model model_rows = conn.execute( """SELECT model, COUNT(*), SUM(prompt_tokens), SUM(completion_tokens), - MAX(ts), AVG(COALESCE(usage_multiplier,1.0)) + MAX(ts) FROM request_log WHERE type='llm' GROUP BY model ORDER BY MAX(ts) DESC""" ).fetchall() models = [] @@ -442,7 +437,6 @@ class AnalyticsLogger: "avg_tps": m_avg_tps, "avg_ttft": m_avg_ttft, "last_used": row[4], - "usage_multiplier": round(row[5] or 1.0, 2), }) # Per-provider stats (for search calls) @@ -563,9 +557,6 @@ class AnalyticsLogger: "prompt_tokens": prompt_tokens or 0, "completion_tokens": completion_tokens or 0, "total_tokens": total_tokens or 0, - "weighted_prompt": round(weighted_prompt or 0, 0), - "weighted_completion": round(weighted_completion or 0, 0), - "weighted_total": round(weighted_total or 0, 0), "avg_tps": avg_tps, "avg_ttft": avg_ttft, "models": models, diff --git a/guanaco/client.py b/guanaco/client.py index 443c4a6..d38f460 100644 --- a/guanaco/client.py +++ b/guanaco/client.py @@ -51,13 +51,15 @@ KNOWN_CLOUD_MODELS = { "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}, + "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}, } @@ -323,6 +325,11 @@ class OllamaClient: Weekly usage 30.9% used ... Resets in 3 days + + Per-model breakdown (new feature): +
+
""" import re result = {} @@ -365,6 +372,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
-
-
0
Weighted Prompt
-
0
Weighted Comp
-
0
Weighted Total
-
1.0x
Avg Multiplier
-
@@ -430,7 +424,7 @@
-
Model
Reqs
Mul
Prompt
Comp
W-Total
Avg TPS
Avg TTFT
+
Model
Reqs
Prompt
Comp
Total
Avg TPS
Avg TTFT
@@ -551,6 +545,7 @@
+
Weekly Usage @@ -559,6 +554,7 @@
+
@@ -616,6 +612,15 @@ + +
+ + + Models with OpenRouter cache pricing get discounted accordingly +
@@ -653,6 +659,17 @@
+ +
+ Model Value Score + Positive = model gave more value than its fair share of sub cost +
+
+
Model
Reqs
Tokens
Value
Fair Share
Score
+
+
+ + @@ -1396,14 +1413,6 @@ function loadAnalytics() { document.getElementById('an-search').textContent = (data.search_calls || 0).toLocaleString(); document.getElementById('an-ptokens').textContent = (data.prompt_tokens || 0).toLocaleString(); document.getElementById('an-ctokens').textContent = (data.completion_tokens || 0).toLocaleString(); - document.getElementById('an-wptokens').textContent = (data.weighted_prompt || 0).toLocaleString(); - document.getElementById('an-wctokens').textContent = (data.weighted_completion || 0).toLocaleString(); - document.getElementById('an-wtokens').textContent = (data.weighted_total || 0).toLocaleString(); - // Compute average multiplier from raw vs weighted totals (avoid divide by zero) - const rawTotal = (data.prompt_tokens || 0) + (data.completion_tokens || 0); - const wTotal = (data.weighted_total || 0); - const avgMult = rawTotal > 0 ? (wTotal / rawTotal).toFixed(2) + 'x' : '1.0x'; - document.getElementById('an-mult').textContent = avgMult; document.getElementById('an-tps').textContent = data.avg_tps || 0; document.getElementById('an-ttft').textContent = data.avg_ttft ? (data.avg_ttft * 1000).toFixed(0) + 'ms' : 'โ€”'; document.getElementById('an-errors').textContent = (data.errors || 0).toLocaleString(); @@ -1426,10 +1435,9 @@ function loadAnalytics() {
${m.model}
${m.requests}
-
${(m.usage_multiplier || 1.0).toFixed(2)}x
${(m.prompt_tokens || 0).toLocaleString()}
${(m.completion_tokens || 0).toLocaleString()}
-
${Math.round((m.prompt_tokens || 0) * (m.usage_multiplier || 1.0) + (m.completion_tokens || 0) * (m.usage_multiplier || 1.0)).toLocaleString()}
+
${((m.prompt_tokens || 0) + (m.completion_tokens || 0)).toLocaleString()}
${m.avg_tps || 'โ€”'}
${m.avg_ttft ? (m.avg_ttft * 1000).toFixed(0) + 'ms' : 'โ€”'}
@@ -1457,10 +1465,10 @@ function loadAnalytics() { `).join('') : '
No errors ๐ŸŽ‰
'; - // Top stats โ€” show weighted tokens as primary count + // Top stats โ€” show raw tokens as primary count document.getElementById('stat-requests').textContent = (data.total_requests || 0).toLocaleString(); - document.getElementById('stat-tokens').textContent = (data.weighted_total || 0).toLocaleString(); - document.getElementById('stat-tokens').nextElementSibling.textContent = 'Weighted Tokens'; + 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; @@ -1778,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...'; @@ -1792,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) : 'โ€”'; @@ -1814,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 = ''; @@ -1871,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); }); @@ -2142,12 +2222,15 @@ function _getCapabilities(modelName) { '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'], - 'nemotron-3-nano': ['cloud','tools','thinking'], 'kimi-k2.6': ['cloud','tools','thinking','vision'], 'kimi-k2.5': ['cloud','tools','thinking','vision'], 'cogito-2.1': ['cloud','thinking'], @@ -2182,6 +2265,8 @@ function _getMultiplier(modelName) { '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 โ”€โ”€ @@ -2627,11 +2712,13 @@ async function toggleAutoUpdate() { 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'); @@ -2643,9 +2730,18 @@ function loadROIConfig() { 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'; - loadLastROI(); + calculateROI(); } else { document.getElementById('roi-results').style.display = 'none'; } @@ -2659,10 +2755,11 @@ 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 }) + body: JSON.stringify({ enabled, subscription_price: price, cache_hit_pct: cacheHit }) }).then(r => r.json()).then(data => { if (data.status === 'ok') { if (enabled) { @@ -2676,6 +2773,26 @@ function toggleROI() { }); } +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'); @@ -2712,6 +2829,7 @@ async function calculateROI() { return; } renderROIResults(data); + loadROIComparison(); results.style.display = 'block'; } catch (e) { loading.style.display = 'none'; @@ -2732,6 +2850,12 @@ function renderROIResults(data) { 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) { @@ -2769,6 +2893,61 @@ function renderROIResults(data) { 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) { diff --git a/guanaco/roi.py b/guanaco/roi.py index 71c3f49..978b170 100644 --- a/guanaco/roi.py +++ b/guanaco/roi.py @@ -57,8 +57,14 @@ FAMILY_MAP = { def _normalized(name: str) -> str: - """Strip :cloud / :local suffixes and lower-case.""" + """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 @@ -100,12 +106,16 @@ class PriceCache: 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) - prices[model_id] = { + 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") @@ -126,17 +136,17 @@ def _find_best_price(prices: dict, ollama_name: str) -> dict: norm = _normalized(ollama_name) size = _model_size(ollama_name) - # 1. Exact normalized match (rare) - if norm in prices: - return prices[norm] + # 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 โ€” find cheapest matching family entry + # 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(): - orouter_norm = _normalized(orouter_id) for frag, (family_exact, family_prefix) in FAMILY_MAP.items(): - if frag in norm and family_prefix and family_prefix in orouter_norm: + 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 @@ -175,22 +185,45 @@ def _find_best_price(prices: dict, ollama_name: str) -> dict: return {"prompt": 0.0, "completion": 0.0} -def _map_usage_to_prices(usage_by_model: dict, prices: dict) -> dict: +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) - prom_mt = pt / 1_000_000 - comp_mt = ct / 1_000_000 - cost = (prom_mt * price["prompt"]) + (comp_mt * price["completion"]) + + # 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": price["prompt"], + "prompt_per_mt": effective_prompt, "completion_per_mt": price["completion"], "cost": cost, - "matched_price_model": _find_best_price.__module__, # marker + "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 @@ -205,13 +238,20 @@ def get_usage_from_analytics(db_path: Path | str, since: float = 0) -> tuple[dic 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) + 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 = row - usage_by_model[model] = {"prompt_tokens": pt, "completion_tokens": ct} + 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: @@ -219,10 +259,26 @@ def get_usage_from_analytics(db_path: Path | str, since: float = 0) -> tuple[dic 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. @@ -231,12 +287,14 @@ def calculate_roi( 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, prices_stale, + 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 """ @@ -246,11 +304,11 @@ def calculate_roi( plan = "pro" if subscription_monthly <= 25 else "max" # 2. Get usage - since = time.time() - (7 * 24 * 3600) + since = _get_ollama_week_start() usage_by_model, total_weighted = get_usage_from_analytics(db_path, since) - # 3. Map usage to prices - priced = _map_usage_to_prices(usage_by_model, prices) + # 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()) @@ -280,6 +338,8 @@ def calculate_roi( "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) @@ -287,6 +347,7 @@ def calculate_roi( "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), @@ -294,6 +355,7 @@ def calculate_roi( "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, @@ -301,6 +363,111 @@ def calculate_roi( } +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 From 25a0b1b6531c4c2228a680fd6a71963573db68bb Mon Sep 17 00:00:00 2001 From: Evan Rice Date: Wed, 10 Jun 2026 00:57:12 +0000 Subject: [PATCH 07/11] feat(usage-levels): scrape real per-tag usage tiers from ollama.com library pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- guanaco/client.py | 126 +++++++++++++++++++++++++++++++++++---- guanaco/router/router.py | 8 +++ 2 files changed, 124 insertions(+), 10 deletions(-) diff --git a/guanaco/client.py b/guanaco/client.py index d38f460..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,6 +21,11 @@ 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 @@ -75,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. @@ -189,27 +289,33 @@ 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": self._get_model_multiplier(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 "", }) diff --git a/guanaco/router/router.py b/guanaco/router/router.py index ee1c947..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", ""), From b4ed4faebd1b3b6130ed286c3a80a11bba7d322e Mon Sep 17 00:00:00 2001 From: Evan Rice Date: Wed, 10 Jun 2026 01:01:21 +0000 Subject: [PATCH 08/11] chore: bump version to 0.5.0 and add comprehensive changelog CHANGELOG covers: - Token estimation fallback + skimtoken + fallback_reason audit - ROI dashboard + OpenRouter price fetcher + cache-hit discount - Real usage-level scraping from ollama.com (per-tag + top-level) - Multi-account Ollama Cloud rotation + premium model routing - Search provider plugins (8 backends) - Config migration, install robustness, package rename - Schema changes (usage_multiplier column removed, fallback_reason added) - CI/CD, Docker, project hygiene improvements - API changes (/v1/models + /api/ollama/models new fields) - Known issues and deprecations --- CHANGELOG.md | 167 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..817b391 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,167 @@ +# 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.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/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 63327c7cb33c031582254cbf7ab0e38fde222353 Mon Sep 17 00:00:00 2001 From: Evan Rice Date: Wed, 10 Jun 2026 01:25:06 +0000 Subject: [PATCH 09/11] hotfix(v0.5.0): sync version strings + fix importlib metadata fallback - __init__.py, app.py, pyproject.toml: bump to 0.5.0 - __init__.py: query guanaco-llm-proxy (not guanaco) and only override hardcoded if metadata version >= hardcoded version Prevents stale guanaco-0.4.2.dist-info from clobbering the version. Fixes apply_update showing wrong version after git-pull. --- guanaco/__init__.py | 15 +++++++++------ guanaco/app.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/guanaco/__init__.py b/guanaco/__init__.py index 5bf932f..07fbdf9 100644 --- a/guanaco/__init__.py +++ b/guanaco/__init__.py @@ -3,16 +3,19 @@ # 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 - _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 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 From a80746454c76ad76399e6c9ff1364d4297aac8d3 Mon Sep 17 00:00:00 2001 From: Evan Rice Date: Wed, 10 Jun 2026 01:38:02 +0000 Subject: [PATCH 10/11] 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 | 10 ++++++++++ guanaco/__init__.py | 2 +- guanaco/app.py | 2 +- guanaco/dashboard/dashboard.py | 19 ++++++++++++++----- pyproject.toml | 2 +- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 817b391..09eb137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [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 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"} From 17f7beb88c4e335373aee28805d9a0b6db7e78ca Mon Sep 17 00:00:00 2001 From: Evan Rice Date: Wed, 10 Jun 2026 02:07:24 +0000 Subject: [PATCH 11/11] fix(router,client): token estimation always enabled; Ollama native eval fallback - _history_kwargs unconditionally populates input_text/output_text for cost tracking, regardless of save_history toggle. - OllamaClient falls back to prompt_eval_count/eval_count before tiktoken. - Release 0.5.2. --- CHANGELOG.md | 8 +++++ guanaco/__init__.py | 2 +- guanaco/app.py | 2 +- guanaco/client.py | 5 ++++ guanaco/router/router.py | 63 ++++++++++++++++++---------------------- pyproject.toml | 2 +- 6 files changed, 45 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 09eb137..3c35681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [0.5.2] - 2026-06-10 + +### Fixed +- **Token estimation no longer silently disabled when history logging is off.** The `_history_kwargs` helper in the router now unconditionally populates `input_text`/`output_text` for cost tracking, regardless of the `save_history` toggle. Previously, disabling history logging accidentally starved the analytics pipeline of token data. +- **Ollama native eval counters now used as fallback for OpenAI-style usage.** If an upstream response lacks `prompt_tokens`/`completion_tokens`, we fall back to `prompt_eval_count` / `eval_count` before falling back to tiktoken estimation. + +--- + ## [0.5.1] - 2026-06-10 ### Fixed diff --git a/guanaco/__init__.py b/guanaco/__init__.py index c7e78f9..ac83174 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.1" +__version__ = "0.5.2" try: from importlib.metadata import version as _version diff --git a/guanaco/app.py b/guanaco/app.py index ea554b7..63b8b25 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.1" +__version__ = "0.5.2" 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/client.py b/guanaco/client.py index ae7b9aa..48ba60f 100644 --- a/guanaco/client.py +++ b/guanaco/client.py @@ -687,6 +687,11 @@ class OllamaClient: prompt_tokens = usage["prompt_tokens"] if usage.get("completion_tokens"): completion_tokens = usage["completion_tokens"] + # Also capture Ollama-native fields if present + if chunk_data.get("prompt_eval_count"): + prompt_tokens = chunk_data["prompt_eval_count"] + if chunk_data.get("eval_count"): + completion_tokens = chunk_data["eval_count"] except (json.JSONDecodeError, KeyError): pass yield f"data: {data}\n\n" diff --git a/guanaco/router/router.py b/guanaco/router/router.py index 4494349..a3fbfec 100644 --- a/guanaco/router/router.py +++ b/guanaco/router/router.py @@ -404,40 +404,35 @@ def create_router(client: OllamaClient, analytics=None, config=None, account_poo kwargs["source_port"] = client_host.port kwargs["user_agent"] = request.headers.get("user-agent", "") - # Only include content if history is enabled - hist = _config.history if _config else None - if hist and hist.enabled: - if messages and hist.save_input: - # Serialize the messages list to a JSON string - try: - if hasattr(messages, '__iter__') and not isinstance(messages, (str, dict)): - # It's a list or iterable of message objects - msgs = [] - for m in messages: - if hasattr(m, 'model_dump'): - msgs.append(m.model_dump(exclude_none=True)) - elif isinstance(m, dict): - msgs.append(m) - else: - msgs.append(str(m)) - elif isinstance(messages, str): - msgs = messages - else: - msgs = str(messages) - if isinstance(msgs, list): - input_str = json.dumps(msgs, ensure_ascii=False) - else: - input_str = str(msgs) - if len(input_str) > hist.max_content_size: - input_str = input_str[:hist.max_content_size] + "\n...[truncated]" - kwargs["input_text"] = input_str - log.debug("History: captured input_text (%d chars)", len(input_str)) - except Exception as e: - log.warning("History: failed to capture input_text: %s", e) - if output_text and hist.save_output: - if len(output_text) > hist.max_content_size: - output_text = output_text[:hist.max_content_size] + "\n...[truncated]" - kwargs["output_text"] = output_text + # Always include content for analytics token estimation, regardless of + # history logging settings. The history config only gates whether content + # is persisted to log files; token estimation in analytics.py needs + # input_text/output_text to estimate when the API omits usage data. + try: + if messages: + if hasattr(messages, '__iter__') and not isinstance(messages, (str, dict)): + msgs = [] + for m in messages: + if hasattr(m, 'model_dump'): + msgs.append(m.model_dump(exclude_none=True)) + elif isinstance(m, dict): + msgs.append(m) + else: + msgs.append(str(m)) + elif isinstance(messages, str): + msgs = messages + else: + msgs = str(messages) + if isinstance(msgs, list): + input_str = json.dumps(msgs, ensure_ascii=False) + else: + input_str = str(msgs) + kwargs["input_text"] = input_str + log.debug("History: captured input_text (%d chars)", len(input_str)) + except Exception as e: + log.warning("History: failed to capture input_text: %s", e) + if output_text: + kwargs["output_text"] = output_text return kwargs diff --git a/pyproject.toml b/pyproject.toml index 02f60a4..c7c99f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "guanaco-llm-proxy" -version = "0.5.1" +version = "0.5.2" 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"}