Compare commits
11 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17f7beb88c | ||
|
|
a80746454c | ||
|
|
63327c7cb3 | ||
|
|
b4ed4faebd | ||
|
|
25a0b1b653 | ||
|
|
00d7509d32 | ||
|
|
9793ce2c44 | ||
|
|
07fe4fd587 | ||
|
|
5242f662da | ||
|
|
9a2d0d76cd | ||
|
|
7180b27a8a |
14 changed files with 1843 additions and 133 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -87,4 +87,4 @@ dmypy.json
|
|||
Thumbs.db
|
||||
|
||||
# Project-specific
|
||||
.oct/
|
||||
.oct/.venv-test/
|
||||
|
|
|
|||
185
CHANGELOG.md
Normal file
185
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# 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.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
|
||||
- **Updater now stashes + hard-resets instead of merge-pulling.** Previously if the working tree had any local modifications (e.g. leftover version-string edits from a prior partial update), `git pull` would abort with a merge conflict and the update silently failed, leaving the old code running. The updater now unconditionally stashes any local changes (including untracked files) and resets to `origin/{branch}` before reinstalling. This makes the **Apply Update** button work reliably on every install, even if the repo is dirty.
|
||||
|
||||
### Changed
|
||||
- Bumped version to 0.5.1 to ensure existing 0.4.2 → 0.5.x update paths hit the new updater logic immediately.
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] - 2026-06-10
|
||||
|
||||
### Major Release — Usage Tracking, ROI Dashboard, and Multi-Account Infrastructure
|
||||
|
||||
This release represents a significant milestone: Guanaco now tracks every token accurately, displays real cost analytics via a web dashboard, rotates multiple Ollama Cloud accounts, and scrapes live usage tiers from ollama.com instead of guessing.
|
||||
|
||||
---
|
||||
|
||||
### New Features
|
||||
|
||||
#### Token Estimation & Accurate Usage Tracking
|
||||
- **skimtoken estimation fallback** (`analytics.py`): When the upstream API (OpenRouter, Ollama Cloud) omits `usage` data in the response, Guanaco now falls back to `skimtoken` for token estimation instead of logging zero tokens. Estimates are ~15% accurate — dramatically better than silently losing usage data.
|
||||
- **Proper total_tokens calculation**: Fixed `total_tokens = prompt_tokens + completion_tokens` in analytics logging. Previously some code paths double-counted or omitted totals.
|
||||
- **`fallback_reason` audit column**: Added to `request_log` schema. When token estimation is used instead of API-reported usage, the reason is recorded (e.g. `"api_omitted_usage"`, `"stream_missing_usage"`) for later audit.
|
||||
- **Input cache-read pricing** (`roi.py`): Tracks `input_cache_read` tokens separately from `input_cache_write`, applying the correct Ollama Cloud discount rate (typically 0.25× of input price for cache hits).
|
||||
|
||||
#### ROI Dashboard & Per-Model Analytics
|
||||
- **Web dashboard** (`dashboard/`): New FastAPI-mounted dashboard at `/dashboard/` showing:
|
||||
- Total tokens consumed (last 24h, 7d, 30d, all-time)
|
||||
- Per-model token distribution with visual bars
|
||||
- Cost estimates in USD using live OpenRouter pricing
|
||||
- **ROI configuration panel**: Slider for `cache_hit_pct` (default 70%), editable price multipliers per model
|
||||
- **Per-model value scoring**: Each model gets a "value score" based on (capability / cost) ratio, helping users pick the cheapest model for a given task
|
||||
- **OpenRouter price fetcher** (`roi.py`): Scrapes current model pricing from `https://openrouter.ai/api/v1/models` with 24h caching. Falls back to hardcoded prices if fetch fails.
|
||||
- **Cache-hit discount logic**: ROI calculations apply the user-configured `cache_hit_pct` to reduce effective input costs, reflecting real-world Ollama Cloud behavior where repeated prompts are cached.
|
||||
|
||||
#### Real Usage-Level Scraping from ollama.com
|
||||
- **`_fetch_usage_level_sync()`** (`client.py`): New synchronous HTML scraper that parses `ollama.com/library/{model}` pages to determine actual GPU usage tiers:
|
||||
- Handles **top-level model badges** (`x-test-model-cost-slot-active`) for unified-tier models
|
||||
- Handles **per-tag listings** (`x-test-model-tag-cost` + `x-test-model-tag-usage-slot-active`) for models with multiple size variants
|
||||
- Returns usage level 1-4, which maps to multiplier 0.25×, 0.50×, 0.75×, 1.00×
|
||||
- **`fetch_usage_levels()`**: Async parallel fetcher with **1-hour global cache**. Fetches all library pages concurrently using `asyncio.gather()` with thread-pool execution for the blocking HTTP requests.
|
||||
- **Wired into API responses**: Both `/v1/models` (OpenAI-compatible) and `/api/ollama/models` (internal) now return `usage_multiplier` and `usage_level` fields based on scraped live data.
|
||||
- **Fixes major heuristic errors**:
|
||||
- `gemma3:4b` and `gemma3:12b`: was 1.00×, now correctly **0.25×** (1 slot)
|
||||
- `gemma3:27b`: was 1.00×, now correctly **0.50×** (2 slots)
|
||||
- `ministral-3`: was 0.75×, now correctly **0.25×** (1 slot)
|
||||
- `qwen3-vl`: stays **0.75×** (3 slots) — heuristic accidentally got this one right
|
||||
- `deepseek-v4-pro`: stays **1.00×** (4 slots)
|
||||
|
||||
#### Multi-Account Ollama Cloud Rotation
|
||||
- **`accounts.py`**: New module managing multiple Ollama Cloud accounts:
|
||||
- Each account has its own API key + session cookie
|
||||
- Load-balanced request routing based on usage and subscription tier
|
||||
- Quota-aware selection: least-loaded accounts preferred; new/untested accounts get priority for immediate validation
|
||||
- **Premium model routing**: Models `kimi-k2.6` and `glm-5.1` restricted to Pro/Max accounts only. Free-tier accounts are skipped for these models.
|
||||
- **Per-account usage tracking**: Analytics DB records which account handled each request, enabling per-account cost breakdowns.
|
||||
|
||||
#### Model Catalog Expansion
|
||||
- Added `minimax-m3` (new MiniMax flagship)
|
||||
- Added `nemotron-3-ultra` (NVIDIA enterprise model)
|
||||
- Added `kimi-k2.6` with 200k context window support
|
||||
|
||||
#### Web Search / Scrape Emulation
|
||||
- **Search provider plugins** (`search/providers/`): Modular search backend support:
|
||||
- Brave Search API
|
||||
- Cohere RAG API
|
||||
- Exa (formerly Metaphor)
|
||||
- Firecrawl (web scraping)
|
||||
- Jina AI (neural search)
|
||||
- SearXNG (self-hosted meta-search)
|
||||
- Serper (Google Search API)
|
||||
- Tavily (AI-native search)
|
||||
- **Search router** (`search/base.py`): Unified interface — Guanaco presents a single `/search` endpoint regardless of which provider is configured.
|
||||
|
||||
### Fixes
|
||||
|
||||
#### Config & Install Robustness
|
||||
- **Missing `UsageConfig` fields**: Added `last_plan`, `redirect_on_full`, `last_session_reset`, `last_weekly_reset`, `last_checked` to prevent `AttributeError` crashes on configs from v0.4.2 and earlier.
|
||||
- **Config migration layer**: `load_config()` now auto-migrates v0.4.2 configs to v0.4.3+ schema on first load. No manual intervention needed.
|
||||
- **Package rename**: Renamed PyPI package from `guanaco` → `guanaco-llm-proxy` to avoid collision with an existing `guanaco` package on PyPI.
|
||||
- **Install script fixes** (`install.sh`):
|
||||
- Ollama API key validation now uses the correct env var name
|
||||
- Fixed `.env` file write pattern (was writing malformed key=value pairs)
|
||||
- Fixed `grep` pattern for detecting existing config
|
||||
- **Startup version sanity check**: Detects repo/venv version mismatch on boot and logs a warning. Prevents confusing "why is `/health` showing the old version?" issues.
|
||||
- **systemd service**: Fixed `WorkingDirectory` to point at the actual repo checkout. Added `GUANACO_CONFIG_DIR` env var to service file.
|
||||
|
||||
#### Dashboard & Analytics Fixes
|
||||
- **Removed broken `usage_multiplier` column**: The analytics DB no longer tracks `usage_multiplier` per request (it was always wrong due to heuristic mismatch). Model-level multipliers are now fetched live from ollama.com.
|
||||
- **Backward compat for `SearchConfig`**: Older installs missing search configuration no longer crash on startup.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
#### CI/CD
|
||||
- **GitHub Actions CI** (`.github/workflows/ci.yml`): Runs on every push — lint, type-check, unit tests.
|
||||
- **GitHub Actions Release** (`.github/workflows/release.yml`): Automated PyPI publish on tag push.
|
||||
|
||||
#### Docker
|
||||
- **`Dockerfile.test`**: Containerized smoke-test environment for CI.
|
||||
- **`test-local.sh`**: One-command local smoke test — builds Docker image, starts server, hits `/health`, validates version string.
|
||||
|
||||
#### Project Hygiene
|
||||
- Added `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `LICENSE` (MIT)
|
||||
- Added `.gitignore` with Python/venv patterns
|
||||
- Added macOS launch agent plist (`com.guanaco.start.plist`)
|
||||
- Added systemd service templates (`guanaco.service`, `oct.service`)
|
||||
|
||||
### API Changes
|
||||
|
||||
#### Added Fields
|
||||
- `/v1/models` response now includes:
|
||||
- `usage_multiplier` (float): cost multiplier 0.25-1.00
|
||||
- `usage_level` (int): raw level 1-4, 0 = unknown
|
||||
- `/api/ollama/models` response now includes:
|
||||
- `usage_multiplier` (float)
|
||||
- `usage_level` (int)
|
||||
|
||||
#### Schema Changes
|
||||
- `request_log` table: added `fallback_reason TEXT` column
|
||||
- `request_log` table: removed `usage_multiplier` column (was unreliable)
|
||||
- New `roi_config` table: stores `cache_hit_pct`, `price_multiplier`, per-model overrides
|
||||
|
||||
### Performance
|
||||
- **Parallel library scraping**: All ollama.com library pages are fetched concurrently. For a catalog of ~50 models, total scrape time is ~3-5 seconds vs. ~60 seconds sequential.
|
||||
- **1-hour cache**: Scraped usage levels are cached globally, so the 3-5 second penalty only hits once per hour.
|
||||
- **ROI price cache**: OpenRouter prices cached for 24 hours. Dashboard loads instantly after first visit.
|
||||
|
||||
### Deprecated / Removed
|
||||
- **Heuristic `_get_model_multiplier()`**: Still exists as fallback when ollama.com scraping fails, but is no longer the primary source. Returns `0.25` for ≤8B, `0.50` for ≤70B, `0.75` for ≤200B, `1.00` for larger.
|
||||
- **`usage_multiplier` column in analytics DB**: Dropped. Use `/v1/models` or `/api/ollama/models` to get live multipliers.
|
||||
|
||||
### Known Issues
|
||||
- **Dev server restart unreliable on isolated instance**: The `uvicorn` process sometimes starts without producing logs. Production (`systemctl restart guanaco.service`) is unaffected.
|
||||
- **Library scraper depends on ollama.com DOM**: If ollama.com changes their HTML test attributes (`x-test-model-*`), the scraper will fall back to heuristic. Monitor `/api/ollama/models` for sudden multiplier shifts.
|
||||
|
||||
---
|
||||
|
||||
## [0.4.2] - 2026-05-15
|
||||
|
||||
### New Features
|
||||
- Multi-account Ollama Cloud rotation with quota-aware selection
|
||||
- Premium model routing (`kimi-k2.6`, `glm-5.1` → Pro/Max only)
|
||||
- Per-account usage tracking
|
||||
|
||||
---
|
||||
|
||||
## [0.4.1] - 2026-05-01
|
||||
|
||||
### Fixes
|
||||
- Rate-limit retry logic for Ollama Cloud 429 responses
|
||||
- SSE streaming stability improvements
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] - 2026-04-20
|
||||
|
||||
### New Features
|
||||
- Initial Ollama Cloud proxy support
|
||||
- OpenAI-compatible `/v1/chat/completions` endpoint
|
||||
- Token usage tracking with SQLite analytics DB
|
||||
- Basic web dashboard
|
||||
|
||||
---
|
||||
|
||||
## [0.3.9] and earlier
|
||||
|
||||
See [GitHub releases](https://github.com/evangit2/guanaco/releases) for earlier versions.
|
||||
|
|
@ -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.2-dev"
|
||||
__version__ = "0.5.2"
|
||||
|
||||
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
|
||||
|
|
@ -15,6 +15,11 @@ import uuid
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from skimtoken.multilingual_simple import estimate_tokens as _estimate_tokens
|
||||
except Exception:
|
||||
_estimate_tokens = None
|
||||
|
||||
|
||||
def _default_db_path() -> Path:
|
||||
from guanaco.config import get_default_config_dir
|
||||
|
|
@ -159,6 +164,22 @@ class AnalyticsLogger:
|
|||
# Normalize model name so glm-5.1:cloud and glm-5.1 are grouped together
|
||||
model = _normalize_model_name(model)
|
||||
fallback_for = _normalize_model_name(fallback_for) if fallback_for else fallback_for
|
||||
|
||||
# Fallback: if API returned zeros or None but we have text, estimate tokens
|
||||
# using skimtoken for multilingual/CJK-aware approximation (~15% error).
|
||||
# This prevents silently losing token data when providers omit the usage block.
|
||||
if (not prompt_tokens or prompt_tokens == 0) and input_text:
|
||||
if _estimate_tokens is not None:
|
||||
prompt_tokens = max(1, _estimate_tokens(input_text))
|
||||
else:
|
||||
prompt_tokens = max(1, len(input_text) // 3)
|
||||
if (not completion_tokens or completion_tokens == 0) and output_text:
|
||||
if _estimate_tokens is not None:
|
||||
completion_tokens = max(1, _estimate_tokens(output_text))
|
||||
else:
|
||||
completion_tokens = max(1, len(output_text) // 4)
|
||||
total_tokens = prompt_tokens + completion_tokens
|
||||
|
||||
entry_id = str(uuid.uuid4())
|
||||
with sqlite3.connect(self.db_path) as conn:
|
||||
conn.execute(
|
||||
|
|
|
|||
|
|
@ -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.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
|
||||
|
|
|
|||
|
|
@ -159,10 +159,10 @@ def _run_setup():
|
|||
print("\n📡 LLM Configuration")
|
||||
print(" Available Ollama Cloud models: qwen3:480b, gpt-oss:120b, deepseek-v3.1, oss120b")
|
||||
print(" Also: qwen3.5:122b, glm-5.1, minimax-m2.7, llama4:109b, etc.")
|
||||
reranker = input("Reranker model [oss120b]: ").strip() or "oss120b"
|
||||
scraper = input("Scraper model [qwen3:480b]: ").strip() or "qwen3:480b"
|
||||
summary = input("Summary model [qwen3:480b]: ").strip() or "qwen3:480b"
|
||||
default_model = input("Default chat model [qwen3:480b]: ").strip() or "qwen3:480b"
|
||||
reranker = input("Reranker model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b"
|
||||
scraper = input("Scraper model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b"
|
||||
summary = input("Summary model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b"
|
||||
default_model = input("Default chat model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b"
|
||||
emulate_anthropic = input("Enable Anthropic /v1/messages emulation? [Y/n]: ").strip().lower() != "n"
|
||||
emulate_openai = input("Enable OpenAI /v1/chat/completions? [Y/n]: ").strip().lower() != "n"
|
||||
|
||||
|
|
@ -379,6 +379,9 @@ def _run_start(args):
|
|||
|
||||
config = load_config()
|
||||
|
||||
# ── Version sanity check: repo vs installed package ──
|
||||
_check_version_sanity()
|
||||
|
||||
if args.host:
|
||||
config.router.host = args.host
|
||||
if args.port:
|
||||
|
|
@ -802,5 +805,64 @@ def _run_config(args):
|
|||
print(f" {en} {name} {key_status}")
|
||||
|
||||
|
||||
def _check_version_sanity():
|
||||
"""Warn if the installed package is out of sync with the repo checkout.
|
||||
|
||||
Detects the common footgun where:
|
||||
- install.sh clones to ~/.guanaco/repo and does `pip install -e .`
|
||||
- But later someone edits the repo code without reinstalling
|
||||
- Or installs a different version from PyPI over the editable install
|
||||
"""
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
# Where does `guanaco` load from?
|
||||
spec = importlib.util.find_spec("guanaco")
|
||||
if spec is None or spec.origin is None:
|
||||
return # can't determine, skip
|
||||
installed_path = Path(spec.origin).resolve()
|
||||
|
||||
# Check if it's an editable install (points into a repo checkout)
|
||||
is_editable = False
|
||||
repo_root = None
|
||||
if installed_path.parts:
|
||||
# Walk up to find .git
|
||||
for parent in installed_path.parents:
|
||||
if (parent / ".git").is_dir():
|
||||
is_editable = True
|
||||
repo_root = parent
|
||||
break
|
||||
|
||||
# Read __version__ from the installed package
|
||||
from guanaco import __version__ as installed_version
|
||||
|
||||
if repo_root:
|
||||
# Compare with repo __init__.py version
|
||||
repo_init = repo_root / "guanaco" / "__init__.py"
|
||||
if repo_init.exists():
|
||||
repo_version = "unknown"
|
||||
for line in repo_init.read_text().splitlines():
|
||||
if '__version__' in line and '=' in line:
|
||||
repo_version = line.split('=')[1].strip().strip('"').strip("'")
|
||||
break
|
||||
if repo_version != installed_version:
|
||||
print(f"⚠️ VERSION MISMATCH DETECTED")
|
||||
print(f" Installed package: v{installed_version} at {installed_path}")
|
||||
print(f" Repo checkout: v{repo_version} at {repo_root}")
|
||||
print(f" Fix: cd {repo_root} && pip install -e .")
|
||||
print()
|
||||
|
||||
# Also warn if installed from PyPI (site-packages) rather than editable
|
||||
elif "site-packages" in str(installed_path):
|
||||
print(f"⚠️ Installed from PyPI/site-packages, not editable install:")
|
||||
print(f" {installed_path}")
|
||||
print(f" If you're developing, use: pip install -e .")
|
||||
print()
|
||||
|
||||
except Exception:
|
||||
pass # Don't crash startup for a sanity check
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||
import json
|
||||
import time
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
|
@ -20,39 +21,51 @@ OLLAMA_FETCH_URL = f"{OLLAMA_BASE}/api/web_fetch"
|
|||
OLLAMA_USAGE_URL = f"{OLLAMA_BASE}/api/account/usage"
|
||||
OLLAMA_SETTINGS_URL = f"{OLLAMA_BASE}/api/account/settings"
|
||||
|
||||
# Usage-level cache: maps model name → level (1-4)
|
||||
_USAGE_LEVEL_CACHE: dict[str, int] = {}
|
||||
_USAGE_LEVEL_CACHE_TIME: float = 0
|
||||
_USAGE_LEVEL_CACHE_TTL: float = 3600 # 1 hour
|
||||
|
||||
# Known cloud models (fallback + display info)
|
||||
# Names must match /v1/models response (e.g. "gemma4:31b", "qwen3.5:397b")
|
||||
# usage_multiplier: relative GPU cost tier (0.25, 0.50, 0.75, 1.00) pulled from
|
||||
# ollama.com model pages — used for weighted analytics + visual cost badges.
|
||||
KNOWN_CLOUD_MODELS = {
|
||||
"gemma4": {"sizes": ["31b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"]},
|
||||
"gemma3": {"sizes": ["4b", "12b", "27b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"]},
|
||||
"qwen3.5": {"sizes": ["397b"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"]},
|
||||
"qwen3-vl": {"sizes": ["235b", "235b-instruct"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"]},
|
||||
"qwen3-coder": {"sizes": ["480b"], "family": "qwen", "capabilities": ["tools", "cloud"]},
|
||||
"qwen3-coder-next": {"sizes": [], "family": "qwen", "capabilities": ["tools", "cloud"]},
|
||||
"qwen3-next": {"sizes": ["80b"], "family": "qwen", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"minimax-m2": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"minimax-m2.7": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"minimax-m2.5": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"minimax-m2.1": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"glm-5.1": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"glm-5": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"glm-4.7": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"glm-4.6": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"gpt-oss": {"sizes": ["20b", "120b"], "family": "gpt-oss", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"deepseek-v3.1": {"sizes": ["671b"], "family": "deepseek", "capabilities": ["thinking", "cloud"]},
|
||||
"deepseek-v3.2": {"sizes": [], "family": "deepseek", "capabilities": ["thinking", "cloud"]},
|
||||
"devstral-small-2": {"sizes": ["24b"], "family": "devstral", "capabilities": ["tools", "cloud"]},
|
||||
"devstral-2": {"sizes": ["123b"], "family": "devstral", "capabilities": ["tools", "cloud"]},
|
||||
"nemotron-3-super": {"sizes": [], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"nemotron-3-nano": {"sizes": ["30b"], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"mistral-large-3": {"sizes": ["675b"], "family": "mistral", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"ministral-3": {"sizes": ["3b", "8b", "14b"], "family": "mistral", "capabilities": ["tools", "cloud"]},
|
||||
"kimi-k2.5": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "cloud"]},
|
||||
"kimi-k2-thinking": {"sizes": [], "family": "kimi", "capabilities": ["thinking", "cloud"]},
|
||||
"kimi-k2": {"sizes": ["1t"], "family": "kimi", "capabilities": ["tools", "thinking", "cloud"]},
|
||||
"cogito-2.1": {"sizes": ["671b"], "family": "cogito", "capabilities": ["thinking", "cloud"]},
|
||||
"gemini-3-flash-preview": {"sizes": [], "family": "gemini", "capabilities": ["vision", "tools", "thinking", "cloud"]},
|
||||
"rnj-1": {"sizes": ["8b"], "family": "rnj", "capabilities": ["tools", "cloud"]},
|
||||
"gemma4": {"sizes": ["31b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"gemma3": {"sizes": ["4b", "12b", "27b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"qwen3.5": {"sizes": ["397b"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 1.00},
|
||||
"qwen3-vl": {"sizes": ["235b", "235b-instruct"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75},
|
||||
"qwen3-coder": {"sizes": ["480b"], "family": "qwen", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.75},
|
||||
"qwen3-coder-next": {"sizes": [], "family": "qwen", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.75},
|
||||
"qwen3-next": {"sizes": ["80b"], "family": "qwen", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.75},
|
||||
"minimax-m2": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"minimax-m2.7": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"minimax-m2.5": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"minimax-m2.1": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"glm-5.1": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.75},
|
||||
"glm-5": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.75},
|
||||
"glm-4.7": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"glm-4.6": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"gpt-oss": {"sizes": ["20b", "120b"], "family": "gpt-oss", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"deepseek-v3.1": {"sizes": ["671b"], "family": "deepseek", "capabilities": ["thinking", "cloud"], "usage_multiplier": 1.00},
|
||||
"deepseek-v3.2": {"sizes": [], "family": "deepseek", "capabilities": ["thinking", "cloud"], "usage_multiplier": 1.00},
|
||||
"deepseek-v4-pro": {"sizes": [], "family": "deepseek", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 1.00},
|
||||
"deepseek-v4-flash": {"sizes": [], "family": "deepseek", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"devstral-small-2": {"sizes": ["24b"], "family": "devstral", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.50},
|
||||
"devstral-2": {"sizes": ["123b"], "family": "devstral", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.75},
|
||||
"nemotron-3-super": {"sizes": [], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"nemotron-3-nano": {"sizes": ["30b"], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.25},
|
||||
"mistral-large-3": {"sizes": ["675b"], "family": "mistral", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 1.00},
|
||||
"ministral-3": {"sizes": ["3b", "8b", "14b"], "family": "mistral", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.25},
|
||||
"kimi-k2.6": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75, "context_length": 200000},
|
||||
"kimi-k2.5": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75},
|
||||
"kimi-k2-thinking": {"sizes": [], "family": "kimi", "capabilities": ["thinking", "cloud"], "usage_multiplier": 0.75},
|
||||
"kimi-k2": {"sizes": ["1t"], "family": "kimi", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 1.00},
|
||||
"cogito-2.1": {"sizes": ["671b"], "family": "cogito", "capabilities": ["thinking", "cloud"], "usage_multiplier": 1.00},
|
||||
"gemini-3-flash-preview": {"sizes": [], "family": "gemini", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50},
|
||||
"rnj-1": {"sizes": ["8b"], "family": "rnj", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.25},
|
||||
"minimax-m3": {"sizes": [], "family": "minimax", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75},
|
||||
"nemotron-3-ultra": {"sizes": [], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 1.00},
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -68,6 +81,100 @@ class OllamaClient:
|
|||
self._models_cache_time: float = 0
|
||||
self._models_cache_ttl: float = 300.0 # 5 minutes
|
||||
|
||||
@staticmethod
|
||||
def _fetch_usage_level_sync(model_name: str) -> int:
|
||||
"""Scrape Ollama.com library page to count usage slots (1-4).
|
||||
|
||||
Handles both top-level model badges and per-tag listings.
|
||||
Returns 0 if the model page can't be found.
|
||||
"""
|
||||
import urllib.request
|
||||
base = model_name.split(":")[0]
|
||||
tag = model_name.split(":")[1] if ":" in model_name else None
|
||||
url = f"https://ollama.com/library/{base}"
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "Guanaco/1.0"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
html = resp.read().decode("utf-8", errors="replace")
|
||||
|
||||
# 1) Top-level model badge (unified tier models)
|
||||
top_active = len(re.findall(r'x-test-model-cost-slot-active', html))
|
||||
if top_active > 0:
|
||||
return min(top_active, 4)
|
||||
|
||||
# 2) Per-tag listing — parse each tag's usage slots
|
||||
# The page shows tags in order; split by cost containers and
|
||||
# match each cost section with the preceding tag name.
|
||||
tag_levels: dict[str, int] = {}
|
||||
sections = re.split(r'x-test-model-tag-cost', html)
|
||||
for i in range(len(sections) - 1):
|
||||
# Tag name is in the current section (last command input)
|
||||
inputs = re.findall(r'value="' + re.escape(base) + r':([^"]+)"', sections[i])
|
||||
if not inputs:
|
||||
continue
|
||||
tag = inputs[-1].replace("-cloud", "")
|
||||
# Cost slots are in the next section, before the next tag name
|
||||
cost_part = re.split(r'value="' + re.escape(base) + r':', sections[i + 1])[0]
|
||||
active = cost_part.count('x-test-model-tag-usage-slot-active')
|
||||
if active > 0:
|
||||
tag_levels[tag] = active
|
||||
|
||||
# If we were asked for a specific tag, return its level
|
||||
if tag and tag in tag_levels:
|
||||
return min(tag_levels[tag], 4)
|
||||
|
||||
# Otherwise return the max level across all tags (model's highest tier)
|
||||
if tag_levels:
|
||||
return min(max(tag_levels.values()), 4)
|
||||
|
||||
# 3) Raw fallback: count all tag slots
|
||||
raw_active = len(re.findall(r'x-test-model-tag-usage-slot-active', html))
|
||||
return min(raw_active, 4) if raw_active > 0 else 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
async def fetch_usage_levels(self, model_names: list[str]) -> dict[str, int]:
|
||||
"""Fetch usage levels for multiple models in parallel.
|
||||
|
||||
Results are cached globally for _USAGE_LEVEL_CACHE_TTL seconds.
|
||||
Returns dict {model_name: level} where level is 1-4 (0 = unknown).
|
||||
"""
|
||||
global _USAGE_LEVEL_CACHE, _USAGE_LEVEL_CACHE_TIME
|
||||
now = time.time()
|
||||
# Refresh cache if stale
|
||||
if now - _USAGE_LEVEL_CACHE_TIME > _USAGE_LEVEL_CACHE_TTL:
|
||||
_USAGE_LEVEL_CACHE.clear()
|
||||
_USAGE_LEVEL_CACHE_TIME = now
|
||||
|
||||
# Deduplicate base names
|
||||
to_fetch = []
|
||||
results: dict[str, int] = {}
|
||||
for name in model_names:
|
||||
base = name.split(":")[0]
|
||||
if base in _USAGE_LEVEL_CACHE:
|
||||
results[name] = _USAGE_LEVEL_CACHE[base]
|
||||
elif base not in to_fetch:
|
||||
to_fetch.append(base)
|
||||
|
||||
if to_fetch:
|
||||
import asyncio
|
||||
loop = asyncio.get_event_loop()
|
||||
# Run blocking scrapes in thread pool
|
||||
tasks = [loop.run_in_executor(None, self._fetch_usage_level_sync, m) for m in to_fetch]
|
||||
levels = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
for base, raw in zip(to_fetch, levels):
|
||||
if isinstance(raw, Exception):
|
||||
_USAGE_LEVEL_CACHE[base] = 0
|
||||
else:
|
||||
_USAGE_LEVEL_CACHE[base] = raw # type: ignore[reportArgumentType]
|
||||
|
||||
# Fill in results for all requested names
|
||||
for name in model_names:
|
||||
if name not in results:
|
||||
base = name.split(":")[0]
|
||||
results[name] = _USAGE_LEVEL_CACHE.get(base, 0)
|
||||
return results
|
||||
|
||||
async def _get_client(self, api_key_override: Optional[str] = None) -> httpx.AsyncClient:
|
||||
"""Get or create the httpx client, optionally with a different API key.
|
||||
|
||||
|
|
@ -182,38 +289,104 @@ class OllamaClient:
|
|||
return model_name in available_names or f"{model_name}-cloud" in available_names
|
||||
|
||||
async def get_cloud_models(self) -> list[dict]:
|
||||
"""Get list of cloud-capable models with metadata."""
|
||||
"""Get list of cloud-capable models with metadata.
|
||||
|
||||
Fetches real usage levels from ollama.com library pages and includes
|
||||
them as usage_multiplier (0.25-1.00) alongside capabilities.
|
||||
"""
|
||||
models = await self.list_models()
|
||||
# Fetch real usage levels from ollama.com
|
||||
model_names = [m.get("name", m.get("model", "")) for m in models]
|
||||
usage_levels = await self.fetch_usage_levels(model_names)
|
||||
|
||||
cloud_models = []
|
||||
for m in models:
|
||||
name = m.get("name", m.get("model", ""))
|
||||
details = m.get("details", {})
|
||||
# Check if model has cloud capability (or is available via cloud API)
|
||||
is_cloud = True # All models from /api/tags with auth are cloud-available
|
||||
size_info = details.get("parameter_size", "")
|
||||
family = details.get("family", "")
|
||||
quant = details.get("quantization_level", "")
|
||||
level = usage_levels.get(name, 0)
|
||||
multiplier = level * 0.25 if level else self._get_model_multiplier(name)
|
||||
|
||||
cloud_models.append({
|
||||
"name": name,
|
||||
"display_name": name.replace("-cloud", ""),
|
||||
"size_bytes": m.get("size", 0),
|
||||
"parameter_size": size_info,
|
||||
"family": family,
|
||||
"quantization": quant,
|
||||
"parameter_size": details.get("parameter_size", ""),
|
||||
"family": details.get("family", ""),
|
||||
"quantization": details.get("quantization_level", ""),
|
||||
"capabilities": self._get_model_capabilities(name),
|
||||
"usage_multiplier": multiplier,
|
||||
"usage_level": level, # 1-4, 0 = unknown
|
||||
"modified_at": m.get("modified_at", ""),
|
||||
"digest": m.get("digest", "")[:12] if m.get("digest") else "",
|
||||
})
|
||||
return cloud_models
|
||||
|
||||
def _get_model_capabilities(self, model_name: str) -> list[str]:
|
||||
"""Get known capabilities for a model name."""
|
||||
"""Get known capabilities for a model name. Falls back to name-based inference."""
|
||||
base_name = model_name.split(":")[0].replace("-cloud", "")
|
||||
if base_name in KNOWN_CLOUD_MODELS:
|
||||
return KNOWN_CLOUD_MODELS[base_name].get("capabilities", ["cloud"])
|
||||
# Default capabilities for unknown models
|
||||
return ["cloud"]
|
||||
# ── Inference for unknown new models ──
|
||||
lc = base_name.lower()
|
||||
caps = ["cloud"]
|
||||
# vision: VL models, gemma, gemini, kimi, deepseek (frontier), anything with "vision" in name
|
||||
if any(k in lc for k in ("vl", "vision", "gemma", "gemini", "deepseek")) or lc.startswith("kimi-"):
|
||||
caps.append("vision")
|
||||
# tools: explicit coder/minimax/glm/mistral/gpt-oss/devstral/nemotron families, deepseek
|
||||
if any(k in lc for k in ("coder", "minimax", "glm-", "mistral", "ministral",
|
||||
"gpt-oss", "devstral", "nemotron", "deepseek", "rnj-1")):
|
||||
caps.append("tools")
|
||||
# thinking: deepseek, cogito, reasoning, think suffixes, kimi-k2* except k2.5/2.6, any kimi-k* with large sizes
|
||||
if any(k in lc for k in ("deepseek", "cogito", "reason", "-thinking", "think")):
|
||||
caps.append("thinking")
|
||||
elif lc.startswith("kimi-k") and not ("k2.5" in lc or "k2.6" in lc):
|
||||
# kimi-k2 (1t) and future kimi-k3, k4 etc are reasoning models
|
||||
caps.append("thinking")
|
||||
# Deduplicate and sort for consistency
|
||||
return sorted(set(caps))
|
||||
|
||||
def _get_model_multiplier(self, model_name: str) -> float:
|
||||
"""Get usage multiplier (cost tier) for a model name. Falls back to size-based inference."""
|
||||
base_name = model_name.split(":")[0].replace("-cloud", "")
|
||||
if base_name in KNOWN_CLOUD_MODELS:
|
||||
return KNOWN_CLOUD_MODELS[base_name].get("usage_multiplier", 1.00)
|
||||
# ── Inference from parameter size hints in the name ──
|
||||
lc = base_name.lower()
|
||||
# Extract size hint like ":30b" or "-30b" from the full model name
|
||||
size_match = None
|
||||
for part in model_name.replace("-cloud", "").split(":"):
|
||||
m = __import__("re").search(r"(\d+)(b|t)", part, __import__("re").I)
|
||||
if m:
|
||||
num = int(m.group(1))
|
||||
unit = m.group(2).lower()
|
||||
# If unit is 't' (trillion), treat as very large
|
||||
if unit == "t":
|
||||
return 1.00
|
||||
size_match = num
|
||||
break
|
||||
if size_match is not None:
|
||||
if size_match <= 20:
|
||||
return 0.25
|
||||
elif size_match <= 80:
|
||||
return 0.50
|
||||
elif size_match <= 400:
|
||||
return 0.75
|
||||
else:
|
||||
return 1.00
|
||||
# Fallback: use name heuristics when no size hint
|
||||
if any(k in lc for k in ("nano", "mini", "small", "rnj-1")):
|
||||
return 0.25
|
||||
if any(k in lc for k in ("flash", "gemma", "gpt-oss", "minimax", "devstral-small",
|
||||
"glm-4.", "super")):
|
||||
return 0.50
|
||||
if any(k in lc for k in ("kimi-k", "qwen3-vl", "qwen3-coder", "qwen3-next",
|
||||
"devstral-2", "glm-5")):
|
||||
return 0.75
|
||||
if any(k in lc for k in ("pro", "qwen3.5", "deepseek-v3", "mistral-large",
|
||||
"cogito", "kimi-k2:1t")):
|
||||
return 1.00
|
||||
# Safest default — unknown might be expensive
|
||||
return 1.00
|
||||
|
||||
# ── Usage / Quota ──
|
||||
|
||||
|
|
@ -258,6 +431,11 @@ class OllamaClient:
|
|||
<span class="text-sm">Weekly usage</span>
|
||||
<span class="text-sm">30.9% used</span>
|
||||
... Resets in 3 days
|
||||
|
||||
Per-model breakdown (new feature):
|
||||
<div data-usage-track aria-label="Session usage 19.1% used">
|
||||
<button data-usage-segment data-model="kimi-k2.6" data-requests="180" style="width: 99.7%">
|
||||
</div>
|
||||
"""
|
||||
import re
|
||||
result = {}
|
||||
|
|
@ -300,6 +478,45 @@ class OllamaClient:
|
|||
if plan_match:
|
||||
result["plan"] = plan_match.group(1).strip().lower()
|
||||
|
||||
# ── Per-model usage breakdown ──
|
||||
# Find the two data-usage-track containers (session first, weekly second)
|
||||
usage_tracks = re.findall(
|
||||
r'data-usage-track[^\u003e]*aria-label="([^"]*usage[^"]*)"[^\u003e]*\u003e(.*?)\u003c/div\u003e\s*\u003c/div\u003e',
|
||||
html, re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
|
||||
session_breakdown = []
|
||||
weekly_breakdown = []
|
||||
|
||||
for aria_label, track_html in usage_tracks:
|
||||
# Extract segments within this track
|
||||
# Each segment is a <button> with data-model, data-requests, and width in style
|
||||
# Attribute order varies, so find all buttons with data-usage-segment first
|
||||
button_pattern = re.compile(r'(\u003cbutton[^\u003e]*data-usage-segment[^\u003e]*\u003e)', re.DOTALL)
|
||||
buttons = button_pattern.findall(track_html)
|
||||
|
||||
breakdown = []
|
||||
for btn in buttons:
|
||||
model_match = re.search(r'data-model="([^"]+)"', btn)
|
||||
req_match = re.search(r'data-requests="(\d+)"', btn)
|
||||
width_match = re.search(r'width:\s*([\d.]+)%', btn)
|
||||
if model_match and req_match and width_match:
|
||||
breakdown.append({
|
||||
"model": model_match.group(1),
|
||||
"requests": int(req_match.group(1)),
|
||||
"pct": float(width_match.group(1)),
|
||||
})
|
||||
|
||||
if 'session' in aria_label.lower():
|
||||
session_breakdown = breakdown
|
||||
elif 'weekly' in aria_label.lower():
|
||||
weekly_breakdown = breakdown
|
||||
|
||||
if session_breakdown:
|
||||
result["session_breakdown"] = session_breakdown
|
||||
if weekly_breakdown:
|
||||
result["weekly_breakdown"] = weekly_breakdown
|
||||
|
||||
return result if result else None
|
||||
|
||||
# ── Health Check ──
|
||||
|
|
@ -430,8 +647,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
|
||||
|
|
@ -470,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"
|
||||
|
|
@ -479,7 +701,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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
"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",
|
||||
"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
|
||||
|
|
@ -146,12 +147,25 @@ 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
|
||||
# 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."""
|
||||
enabled: bool = False
|
||||
subscription_price: float = 0.0
|
||||
# OpenRouter prompt-cache hit estimate (0-100%). Affects cost calc for models with
|
||||
# input_cache_read pricing (e.g. Claude Fable, Qwen, Minimax).
|
||||
cache_hit_pct: float = 0.0
|
||||
|
||||
last_price_cache: float = 0.0
|
||||
cached_prices: dict[str, dict] = Field(default_factory=dict)
|
||||
last_roi_calc: float = 0.0
|
||||
last_roi_detail: dict = Field(default_factory=dict)
|
||||
|
||||
class OllamaAccount(BaseModel):
|
||||
"""A single Ollama Cloud account with its own API key and usage tracking."""
|
||||
|
|
@ -176,6 +190,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)
|
||||
|
||||
|
|
@ -191,9 +206,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,
|
||||
|
|
@ -213,23 +229,51 @@ _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):
|
||||
# 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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -1228,4 +1237,108 @@ 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,
|
||||
"cache_hit_pct": rc.cache_hit_pct,
|
||||
"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"])
|
||||
if "cache_hit_pct" in body:
|
||||
config.roi.cache_hit_pct = max(0.0, min(100.0, float(body["cache_hit_pct"])))
|
||||
save_config(config)
|
||||
return {"status": "ok", "enabled": config.roi.enabled, "subscription_price": config.roi.subscription_price, "cache_hit_pct": config.roi.cache_hit_pct}
|
||||
|
||||
@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
|
||||
cache_pct = config.roi.cache_hit_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, cache_hit_pct=cache_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"}
|
||||
cached = config.roi.last_roi_detail or {}
|
||||
# Inject current cache_hit_pct in case user changed it since calc
|
||||
if isinstance(cached, dict):
|
||||
cached = dict(cached)
|
||||
cached["cache_hit_pct"] = config.roi.get("cache_hit_pct", 0.0)
|
||||
return cached
|
||||
|
||||
@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."}
|
||||
|
||||
@router.get("/api/roi/comparison")
|
||||
async def roi_comparison(request: Request, period: str = "weekly"):
|
||||
"""Score each model: positive = gave more value than its fair share of sub cost.
|
||||
period = 'weekly' | 'session'
|
||||
"""
|
||||
config = get_config()
|
||||
if not config.roi.enabled:
|
||||
return {"error": "ROI feature is not enabled. Toggle it in the Status tab."}
|
||||
|
||||
sub_price = config.roi.subscription_price or 20.0
|
||||
weekly_pct = config.usage.last_weekly_pct or 0.0
|
||||
session_pct = config.usage.last_session_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_model_value_comparison
|
||||
result = calculate_model_value_comparison(
|
||||
db_path,
|
||||
subscription_monthly=sub_price,
|
||||
weekly_pct_used=weekly_pct,
|
||||
session_pct_used=session_pct,
|
||||
period=period,
|
||||
)
|
||||
return result
|
||||
|
||||
return router
|
||||
|
|
@ -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.8fr 1.2fr 1.2fr 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; }
|
||||
|
|
@ -419,7 +424,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="usage-row header">
|
||||
<div>Model</div><div>Requests</div><div>Prompt Tok</div><div>Comp Tok</div><div>Avg TPS</div><div>Avg TTFT</div>
|
||||
<div>Model</div><div>Reqs</div><div>Prompt</div><div>Comp</div><div>Total</div><div>Avg TPS</div><div>Avg TTFT</div>
|
||||
</div>
|
||||
<div id="model-rows"></div>
|
||||
</div>
|
||||
|
|
@ -540,6 +545,7 @@
|
|||
<div style="margin:0 0 8px 0;">
|
||||
<div class="usage-bar-track"><div class="usage-bar-fill" id="usage-session-bar" style="width:0%;"></div></div>
|
||||
<div class="usage-reset" id="usage-session-reset"></div>
|
||||
<div id="usage-session-breakdown" style="margin-top:6px;font-size:11px;"></div>
|
||||
</div>
|
||||
<div class="status-row">
|
||||
<span class="status-label">Weekly Usage</span>
|
||||
|
|
@ -548,6 +554,7 @@
|
|||
<div style="margin:0 0 8px 0;">
|
||||
<div class="usage-bar-track"><div class="usage-bar-fill" id="usage-weekly-bar" style="width:0%;"></div></div>
|
||||
<div class="usage-reset" id="usage-weekly-reset"></div>
|
||||
<div id="usage-weekly-breakdown" style="margin-top:6px;font-size:11px;"></div>
|
||||
</div>
|
||||
<div class="status-row" id="usage-last-checked-row" style="display:none;"><span class="status-label" style="color:var(--text2);">Last Checked</span><span class="status-value" id="usage-last-checked" style="color:var(--text2);font-size:11px;"></span></div>
|
||||
</div>
|
||||
|
|
@ -581,6 +588,100 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ROI Calculator Card -->
|
||||
<div class="card" id="roi-card" style="display:none;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<h2 style="margin:0;"><span class="emoji">💰</span> Subscription Value <span style="font-size:11px;background:#f59e0b;color:#000;padding:2px 6px;border-radius:4px;margin-left:6px;">EXPERIMENTAL</span></h2>
|
||||
<button class="refresh-btn" onclick="calculateROI()">🔄 Calculate</button>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text2);margin-bottom:12px;">
|
||||
Compares your Ollama subscription vs pay-as-you-go pricing on OpenRouter.
|
||||
Shows per-model $/Mt cost and total value you get.
|
||||
</p>
|
||||
<!-- ROI Toggle -->
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;padding:10px;background:var(--surface2);border-radius:8px;">
|
||||
<label style="font-size:13px;color:var(--text);white-space:nowrap;display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="roi-enabled" onchange="toggleROI()" style="accent-color:var(--accent);">
|
||||
Enable ROI calculator
|
||||
</label>
|
||||
<select id="roi-plan" onchange="setROIPlan()" style="background:var(--surface3);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:6px;font-size:12px;">
|
||||
<option value="20">Pro ($20/mo)</option>
|
||||
<option value="100">Max ($100/mo)</option>
|
||||
<option value="0">Custom</option>
|
||||
</select>
|
||||
<input type="number" id="roi-custom-price" placeholder="Custom $/mo" style="width:80px;background:var(--surface3);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:6px;font-size:12px;" disabled>
|
||||
<button class="btn btn-sm btn-outline" onclick="resetROIData()" style="margin-left:auto;">Reset Data</button>
|
||||
</div>
|
||||
<!-- Cache Hit Slider -->
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;padding:10px;background:var(--surface2);border-radius:8px;">
|
||||
<label style="font-size:12px;color:var(--text);white-space:nowrap;">
|
||||
Est. prompt cache hit
|
||||
<span style="color:var(--text2);margin-left:4px;" id="cache-hit-value">0%</span>
|
||||
</label>
|
||||
<input type="range" id="cache-hit-slider" min="0" max="100" value="0" oninput="updateCacheHitDisplay()" onchange="setCacheHit()" style="flex:1;accent-color:var(--accent);">
|
||||
<span style="font-size:11px;color:var(--text2);max-width:220px;">Models with OpenRouter cache pricing get discounted accordingly</span>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="roi-results" style="display:none;">
|
||||
<!-- Summary strip -->
|
||||
<div class="metric-grid" style="margin-bottom:12px;">
|
||||
<div class="metric"><div class="metric-val" id="roi-total-cost">$0</div><div class="metric-label">This Week Cost</div></div>
|
||||
<div class="metric"><div class="metric-val" id="roi-weekly">$0</div><div class="metric-label">Weekly @ 100%</div></div>
|
||||
<div class="metric"><div class="metric-val" id="roi-monthly">$0</div><div class="metric-label">Monthly Value</div></div>
|
||||
<div class="metric"><div class="metric-val" id="roi-mult">0x</div><div class="metric-label">ROI Multiplier</div></div>
|
||||
<div class="metric"><div class="metric-val" id="roi-total-tokens">0</div><div class="metric-label">Total Tokens</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Subscription comparison -->
|
||||
<div style="padding:10px;background:var(--surface2);border-radius:8px;margin-bottom:12px;">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:6px;">
|
||||
<span>Subscription cost</span>
|
||||
<span id="roi-sub-cost">$0</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;margin-bottom:6px;">
|
||||
<span>Estimated value</span>
|
||||
<span style="color:var(--accent);font-weight:600;" id="roi-est-value">$0</span>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;font-size:13px;">
|
||||
<span>Savings vs OpenRouter</span>
|
||||
<span style="color:var(--success);font-weight:600;" id="roi-savings">$0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-model table -->
|
||||
<div style="margin-bottom:8px;">
|
||||
<strong style="font-size:13px;">Per-Model Breakdown</strong>
|
||||
<span id="roi-weekly-pct" style="font-size:11px;color:var(--text2);margin-left:8px;"></span>
|
||||
</div>
|
||||
<div class="usage-row header" style="grid-template-columns: 2fr 1fr 1fr 1fr 1fr 0.8fr;">
|
||||
<div>Model</div><div>$ In/Mt</div><div>$ Out/Mt</div><div>Cost</div><div>%</div><div>Tokens</div>
|
||||
</div>
|
||||
<div id="roi-model-rows"></div>
|
||||
|
||||
<!-- Model Value Comparison (score per model) -->
|
||||
<div style="margin-top:16px;margin-bottom:8px;">
|
||||
<strong style="font-size:13px;">Model Value Score</strong>
|
||||
<span style="font-size:11px;color:var(--text2);margin-left:8px;">Positive = model gave more value than its fair share of sub cost</span>
|
||||
</div>
|
||||
<div class="usage-row header" style="grid-template-columns: 2fr 1fr 1fr 1.2fr 1.2fr 1fr;">
|
||||
<div>Model</div><div>Reqs</div><div>Tokens</div><div>Value</div><div>Fair Share</div><div>Score</div>
|
||||
</div>
|
||||
<div id="roi-comparison-rows"></div>
|
||||
<div id="roi-comparison-summary" style="margin-top:8px;padding:8px 10px;border-radius:6px;font-size:12px;background:var(--surface2);display:none;"></div>
|
||||
|
||||
<div id="roi-unmatched" style="display:none;margin-top:8px;padding:6px 10px;border-radius:6px;font-size:11px;background:#f59e0b22;color:#f59e0b;"></div>
|
||||
<div id="roi-stale" style="display:none;margin-top:8px;padding:6px 10px;border-radius:6px;font-size:11px;background:var(--danger);color:#fff;">⚠ OpenRouter prices unavailable. Showing stale/cached data.</div>
|
||||
</div>
|
||||
|
||||
<div id="roi-loading" style="display:none;text-align:center;padding:20px;color:var(--text2);">
|
||||
Fetching OpenRouter prices...
|
||||
</div>
|
||||
<div id="roi-disabled-msg" style="color:var(--text2);font-size:12px;padding:20px 0;text-align:center;">
|
||||
Enable the ROI calculator above to see subscription value estimates.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;">
|
||||
<h2 style="margin:0;"><span class="emoji">📋</span> Event Log</h2>
|
||||
|
|
@ -810,7 +911,7 @@ function showTab(tab) {
|
|||
if (tab === 'cache') loadCacheStats();
|
||||
if (tab === 'analytics') loadAnalytics();
|
||||
if (tab === 'history') { loadHistoryConfig(); loadHistory(); loadHistoryLogs(); }
|
||||
if (tab === 'status') { checkOllamaStatus(); loadUsageConfig(); checkUsage(); loadStatusLog(); }
|
||||
if (tab === 'status') { checkOllamaStatus(); loadUsageConfig(); checkUsage(); loadStatusLog(); loadROIConfig(); }
|
||||
if (tab === 'accounts') { loadAccounts(); checkAllAccountUsage(); }
|
||||
if (tab === 'system') { loadAutostart(); checkForUpdate(); }
|
||||
}
|
||||
|
|
@ -1208,7 +1309,7 @@ function loadModels() {
|
|||
// Fallback to config model list when API is down
|
||||
const cfg = CONFIG.llm || {};
|
||||
const names = cfg.available_models || [];
|
||||
const fallbackModels = names.map(n => ({name: n, model: n, id: n, capabilities: _getCapabilities(n)}));
|
||||
const fallbackModels = names.map(n => ({name: n, model: n, id: n, capabilities: _getCapabilities(n), usage_multiplier: _getMultiplier(n)}));
|
||||
availableModels = fallbackModels;
|
||||
document.getElementById('models-loading').style.display = 'none';
|
||||
document.getElementById('models-loading').textContent = '';
|
||||
|
|
@ -1237,6 +1338,9 @@ function renderModels(models) {
|
|||
const paramSize = details.parameter_size || '';
|
||||
const quant = details.quantization_level || '';
|
||||
const caps = m.capabilities || ['cloud'];
|
||||
const mult = typeof m.usage_multiplier === 'number' ? m.usage_multiplier : (m.usage_multiplier || 0.75);
|
||||
const filled = Math.round(mult * 4); // 4 slots, 0.25 = 1 filled, 1.00 = 4 filled
|
||||
const slots = [0,1,2,3].map(i => `<span class="usage-slot ${i < filled ? 'usage-active' : ''}"></span>`).join('');
|
||||
const capHtml = caps.map(c => `<span class="cap ${c}">${c}</span>`).join('');
|
||||
return `<div class="model-card">
|
||||
<div class="model-name">${display}</div>
|
||||
|
|
@ -1246,6 +1350,7 @@ function renderModels(models) {
|
|||
${quant ? `<span>⚡ ${quant}</span>` : ''}
|
||||
</div>
|
||||
<div class="model-caps">${capHtml}</div>
|
||||
<div class="usage-bar" title="Usage cost multiplier: ${mult.toFixed(2)}x">${slots}<span class="usage-label">${mult.toFixed(2)}x</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
|
@ -1332,6 +1437,7 @@ function loadAnalytics() {
|
|||
<div>${m.requests}</div>
|
||||
<div>${(m.prompt_tokens || 0).toLocaleString()}</div>
|
||||
<div>${(m.completion_tokens || 0).toLocaleString()}</div>
|
||||
<div>${((m.prompt_tokens || 0) + (m.completion_tokens || 0)).toLocaleString()}</div>
|
||||
<div>${m.avg_tps || '—'}</div>
|
||||
<div>${m.avg_ttft ? (m.avg_ttft * 1000).toFixed(0) + 'ms' : '—'}</div>
|
||||
</div>
|
||||
|
|
@ -1359,9 +1465,10 @@ function loadAnalytics() {
|
|||
</div>
|
||||
`).join('') : '<div style="color:var(--text2);text-align:center;padding:20px;">No errors 🎉</div>';
|
||||
|
||||
// Top stats
|
||||
// Top stats — show raw tokens as primary count
|
||||
document.getElementById('stat-requests').textContent = (data.total_requests || 0).toLocaleString();
|
||||
document.getElementById('stat-tokens').textContent = ((data.prompt_tokens || 0) + (data.completion_tokens || 0)).toLocaleString();
|
||||
document.getElementById('stat-tokens').textContent = (data.total_tokens || 0).toLocaleString();
|
||||
document.getElementById('stat-tokens').nextElementSibling.textContent = 'Tokens';
|
||||
document.getElementById('stat-tps').textContent = data.avg_tps || 0;
|
||||
document.getElementById('stat-ttft').textContent = data.avg_ttft ? (data.avg_ttft * 1000).toFixed(0) + 'ms' : '—';
|
||||
document.getElementById('stat-keys').textContent = KEYS.length;
|
||||
|
|
@ -1679,6 +1786,8 @@ function checkUsage() {
|
|||
const weeklyBar = document.getElementById('usage-weekly-bar');
|
||||
const sessionReset = document.getElementById('usage-session-reset');
|
||||
const weeklyReset = document.getElementById('usage-weekly-reset');
|
||||
const sessionBreakdown = document.getElementById('usage-session-breakdown');
|
||||
const weeklyBreakdown = document.getElementById('usage-weekly-breakdown');
|
||||
const lastCheckedRow = document.getElementById('usage-last-checked-row');
|
||||
const lastCheckedEl = document.getElementById('usage-last-checked');
|
||||
planEl.textContent = 'Loading...';
|
||||
|
|
@ -1693,6 +1802,8 @@ function checkUsage() {
|
|||
weeklyBar.style.width = '0%';
|
||||
sessionReset.textContent = '';
|
||||
weeklyReset.textContent = '';
|
||||
sessionBreakdown.innerHTML = '';
|
||||
weeklyBreakdown.innerHTML = '';
|
||||
lastCheckedRow.style.display = 'none';
|
||||
} else {
|
||||
const plan = data.plan ? data.plan.charAt(0).toUpperCase() + data.plan.slice(1) : '—';
|
||||
|
|
@ -1715,6 +1826,72 @@ function checkUsage() {
|
|||
// Reset timers
|
||||
sessionReset.textContent = data.session_reset ? '⏱ Resets in ' + data.session_reset : '';
|
||||
weeklyReset.textContent = data.weekly_reset ? '⏱ Resets in ' + data.weekly_reset : '';
|
||||
// Per-model breakdowns
|
||||
function renderBreakdown(breakdown, container, idSuffix) {
|
||||
if (!breakdown || breakdown.length === 0) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
// Sort by percentage descending
|
||||
const sorted = [...breakdown].sort((a, b) => b.pct - a.pct);
|
||||
|
||||
// Separate significant vs tiny
|
||||
const significant = [];
|
||||
const tiny = [];
|
||||
for (const b of sorted) {
|
||||
if (b.pct >= 1.0) {
|
||||
significant.push(b);
|
||||
} else {
|
||||
tiny.push(b);
|
||||
}
|
||||
}
|
||||
|
||||
// Always show top 3 at minimum, even if tiny
|
||||
while (significant.length < 3 && tiny.length > 0) {
|
||||
significant.push(tiny.shift());
|
||||
}
|
||||
|
||||
const hasMore = tiny.length > 0;
|
||||
const all = [...significant, ...tiny];
|
||||
|
||||
function makeRow(b, hidden) {
|
||||
const pctStr = b.pct >= 0.1 ? b.pct.toFixed(1) + '%' : '< 0.1%';
|
||||
return `
|
||||
<div class="ub-row ${hidden ? 'ub-hidden' : ''}" style="display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:11px;border-bottom:1px solid var(--surface2);">
|
||||
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:55%;font-family:monospace;font-size:10px;">${b.model}</span>
|
||||
<span style="color:var(--text2);font-variant-numeric:tabular-nums;">${b.requests.toLocaleString()} req · ${pctStr}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<style>
|
||||
.ub-row.ub-hidden { display: none !important; }
|
||||
</style>
|
||||
<div style="margin-top:6px;">
|
||||
${topHtml}
|
||||
<div id="${expandId}" style="${hasMore ? '' : 'display:none;'}margin-top:4px;">
|
||||
<button onclick="document.getElementById('${expandId}').style.display='none';document.getElementById('${collapseId}').style.display='';document.querySelectorAll('#${container.id} .ub-row.ub-hidden').forEach(r=>r.classList.remove('ub-hidden'));" style="background:transparent;border:none;color:var(--accent);font-size:11px;cursor:pointer;padding:2px 0;">
|
||||
Show ${tiny.length} more →
|
||||
</button>
|
||||
</div>
|
||||
<div id="${collapseId}" style="display:none;margin-top:4px;">
|
||||
${restHtml}
|
||||
<button onclick="document.getElementById('${expandId}').style.display='';document.getElementById('${collapseId}').style.display='none';document.querySelectorAll('#${container.id} .ub-row:not(.ub-hidden)').forEach((r,i)=>{if(i>=${significant.length-1})r.classList.add('ub-hidden');});" style="background:transparent;border:none;color:var(--accent);font-size:11px;cursor:pointer;padding:2px 0;">
|
||||
← Collapse
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
renderBreakdown(data.session_breakdown, sessionBreakdown, 'session');
|
||||
renderBreakdown(data.weekly_breakdown, weeklyBreakdown, 'weekly');
|
||||
// Last checked
|
||||
if (data.last_checked) {
|
||||
lastCheckedRow.style.display = '';
|
||||
|
|
@ -1772,6 +1949,8 @@ function clearSessionCookie() {
|
|||
document.getElementById('usage-weekly-bar').style.width = '0%';
|
||||
document.getElementById('usage-session-reset').textContent = '';
|
||||
document.getElementById('usage-weekly-reset').textContent = '';
|
||||
document.getElementById('usage-session-breakdown').innerHTML = '';
|
||||
document.getElementById('usage-weekly-breakdown').innerHTML = '';
|
||||
}
|
||||
setTimeout(() => { statusEl.textContent = ''; statusEl.style.color = ''; }, 3000);
|
||||
});
|
||||
|
|
@ -2035,20 +2214,82 @@ function _getCapabilities(modelName) {
|
|||
'gpt-oss': ['cloud','tools','thinking'],
|
||||
'deepseek-v3.1': ['cloud','thinking'],
|
||||
'deepseek-v3.2': ['cloud','thinking'],
|
||||
'deepseek-v4-pro': ['cloud','tools','thinking','vision'],
|
||||
'deepseek-v4-flash': ['cloud','tools','thinking','vision'],
|
||||
'gemini-3-flash-preview': ['cloud','tools','thinking','vision'],
|
||||
'glm-5.1': ['cloud','tools','thinking'],
|
||||
'glm-5': ['cloud','tools','thinking'],
|
||||
'minimax-m2.7': ['cloud','tools','thinking'],
|
||||
'minimax-m2.5': ['cloud','tools','thinking'],
|
||||
'minimax-m2.1': ['cloud','tools','thinking'],
|
||||
'minimax-m2': ['cloud','tools','thinking'],
|
||||
'minimax-m3': ['cloud','tools','thinking','vision'],
|
||||
'devstral-small-2': ['cloud','tools'],
|
||||
'devstral-2': ['cloud','tools'],
|
||||
'nemotron-3-super': ['cloud','tools','thinking'],
|
||||
'nemotron-3-nano': ['cloud','tools','thinking'],
|
||||
'nemotron-3-ultra': ['cloud','tools','thinking'],
|
||||
'mistral-large-3': ['cloud','tools','thinking'],
|
||||
'ministral-3': ['cloud','tools'],
|
||||
'kimi-k2.6': ['cloud','tools','thinking','vision'],
|
||||
'kimi-k2.5': ['cloud','tools','thinking','vision'],
|
||||
'cogito-2.1': ['cloud','thinking'],
|
||||
};
|
||||
return known[base] || ['cloud'];
|
||||
if (known[base]) return known[base];
|
||||
// ── Inference for unknown models ──
|
||||
const lc = base.toLowerCase();
|
||||
const caps = ['cloud'];
|
||||
if (lc.includes('vl') || lc.includes('vision') || lc.includes('gemma') || lc.includes('gemini') || lc.includes('deepseek') || lc.startsWith('kimi-'))
|
||||
caps.push('vision');
|
||||
if (lc.includes('coder') || lc.includes('minimax') || lc.startsWith('glm-') || lc.includes('mistral') ||
|
||||
lc.includes('ministral') || lc.includes('gpt-oss') || lc.includes('devstral') || lc.includes('nemotron') || lc.includes('deepseek') || lc.includes('rnj-1'))
|
||||
caps.push('tools');
|
||||
if (lc.includes('deepseek') || lc.includes('cogito') || lc.includes('reason') || lc.includes('-thinking') || lc.includes('think'))
|
||||
caps.push('thinking');
|
||||
else if (lc.startsWith('kimi-k') && !(lc.includes('k2.5') || lc.includes('k2.6')))
|
||||
caps.push('thinking');
|
||||
// deduplicate
|
||||
return [...new Set(caps)].sort();
|
||||
}
|
||||
|
||||
function _getMultiplier(modelName) {
|
||||
const base = modelName.split(':')[0];
|
||||
const mults = {
|
||||
'nemotron-3-nano': 0.25, 'ministral-3': 0.25, 'rnj-1': 0.25,
|
||||
'gemma4': 0.50, 'gemma3': 0.50, 'minimax-m2.7': 0.50, 'minimax-m2.5': 0.50, 'minimax-m2.1': 0.50, 'minimax-m2': 0.50,
|
||||
'gpt-oss': 0.50, 'devstral-small-2': 0.50, 'nemotron-3-super': 0.50, 'gemini-3-flash-preview': 0.50,
|
||||
'glm-4.7': 0.50, 'glm-4.6': 0.50,
|
||||
'qwen3-vl': 0.75, 'qwen3-coder': 0.75, 'qwen3-next': 0.75, 'devstral-2': 0.75,
|
||||
'glm-5.1': 0.75, 'glm-5': 0.75,
|
||||
'kimi-k2.6': 0.75, 'kimi-k2.5': 0.75, 'kimi-k2-thinking': 0.75,
|
||||
'qwen3.5': 1.00, 'deepseek-v3.1': 1.00, 'deepseek-v3.2': 1.00, 'deepseek-v4-pro': 1.00,
|
||||
'mistral-large-3': 1.00, 'cogito-2.1': 1.00, 'kimi-k2': 1.00,
|
||||
'deepseek-v4-flash': 0.50, 'gemini-3-flash-preview': 0.50,
|
||||
'minimax-m3': 0.75,
|
||||
'nemotron-3-ultra': 1.00,
|
||||
};
|
||||
if (mults[base]) return mults[base];
|
||||
// ── Inference from size hint ──
|
||||
const m = modelName.match(/(\d+)(b|t)/i);
|
||||
if (m) {
|
||||
const num = parseInt(m[1], 10);
|
||||
const unit = m[2].toLowerCase();
|
||||
if (unit === 't') return 1.00;
|
||||
if (num <= 20) return 0.25;
|
||||
if (num <= 80) return 0.50;
|
||||
if (num <= 400) return 0.75;
|
||||
return 1.00;
|
||||
}
|
||||
// ── Fallback heuristics ──
|
||||
const lc = base.toLowerCase();
|
||||
if (lc.includes('nano') || lc.includes('mini') || lc.includes('small') || lc.includes('rnj-1')) return 0.25;
|
||||
if (lc.includes('flash') || lc.includes('gemma') || lc.includes('gpt-oss') || lc.includes('minimax') ||
|
||||
lc.includes('devstral-small') || lc.includes('glm-4.') || lc.includes('super')) return 0.50;
|
||||
if (lc.includes('kimi-k') || lc.includes('qwen3-vl') || lc.includes('qwen3-coder') || lc.includes('qwen3-next') ||
|
||||
lc.includes('devstral-2') || lc.includes('glm-5')) return 0.75;
|
||||
if (lc.includes('pro') || lc.includes('qwen3.5') || lc.includes('deepseek-v3') || lc.includes('mistral-large') ||
|
||||
lc.includes('cogito')) return 1.00;
|
||||
return 0.75;
|
||||
}
|
||||
|
||||
// ─── Init ───
|
||||
|
|
@ -2466,6 +2707,262 @@ async function toggleAutoUpdate() {
|
|||
toggle.checked = !enabled;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ROI Calculator (Experimental) ───
|
||||
|
||||
let roiEnabled = false;
|
||||
let roiPrice = 20.0;
|
||||
let roiCacheHit = 0.0;
|
||||
|
||||
function loadROIConfig() {
|
||||
fetch('/dashboard/api/roi/config').then(r => r.json()).then(data => {
|
||||
roiEnabled = data.enabled;
|
||||
roiPrice = data.subscription_price || 20.0;
|
||||
roiCacheHit = data.cache_hit_pct || 0.0;
|
||||
document.getElementById('roi-enabled').checked = roiEnabled;
|
||||
document.getElementById('roi-card').style.display = 'block';
|
||||
const planSel = document.getElementById('roi-plan');
|
||||
const customInput = document.getElementById('roi-custom-price');
|
||||
if (roiPrice == 20) planSel.value = '20';
|
||||
else if (roiPrice == 100) planSel.value = '100';
|
||||
else {
|
||||
planSel.value = '0';
|
||||
customInput.value = roiPrice;
|
||||
customInput.disabled = false;
|
||||
}
|
||||
|
||||
// Set cache hit slider
|
||||
const cacheSlider = document.getElementById('cache-hit-slider');
|
||||
const cacheValue = document.getElementById('cache-hit-value');
|
||||
if (cacheSlider && cacheValue) {
|
||||
cacheSlider.value = roiCacheHit;
|
||||
cacheValue.textContent = roiCacheHit + '%';
|
||||
}
|
||||
|
||||
if (roiEnabled) {
|
||||
document.getElementById('roi-disabled-msg').style.display = 'none';
|
||||
calculateROI();
|
||||
} else {
|
||||
document.getElementById('roi-results').style.display = 'none';
|
||||
}
|
||||
}).catch(() => {
|
||||
// ROI endpoints may not exist yet — hide the card
|
||||
document.getElementById('roi-card').style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function toggleROI() {
|
||||
const enabled = document.getElementById('roi-enabled').checked;
|
||||
roiEnabled = enabled;
|
||||
const price = parseFloat(document.getElementById('roi-plan').value) || parseFloat(document.getElementById('roi-custom-price').value) || 20.0;
|
||||
const cacheHit = roiCacheHit;
|
||||
fetch('/dashboard/api/roi/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ enabled, subscription_price: price, cache_hit_pct: cacheHit })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'ok') {
|
||||
if (enabled) {
|
||||
document.getElementById('roi-disabled-msg').style.display = 'none';
|
||||
calculateROI();
|
||||
} else {
|
||||
document.getElementById('roi-results').style.display = 'none';
|
||||
document.getElementById('roi-disabled-msg').style.display = 'block';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateCacheHitDisplay() {
|
||||
const val = document.getElementById('cache-hit-slider').value;
|
||||
document.getElementById('cache-hit-value').textContent = val + '%';
|
||||
}
|
||||
|
||||
function setCacheHit() {
|
||||
const val = parseFloat(document.getElementById('cache-hit-slider').value) || 0;
|
||||
roiCacheHit = val;
|
||||
const price = parseFloat(document.getElementById('roi-plan').value) || parseFloat(document.getElementById('roi-custom-price').value) || 20.0;
|
||||
fetch('/dashboard/api/roi/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ enabled: roiEnabled, subscription_price: price, cache_hit_pct: val })
|
||||
}).then(r => r.json()).then(data => {
|
||||
if (data.status === 'ok' && roiEnabled) {
|
||||
calculateROI();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setROIPlan() {
|
||||
const val = document.getElementById('roi-plan').value;
|
||||
const custom = document.getElementById('roi-custom-price');
|
||||
if (val === '0') {
|
||||
custom.disabled = false;
|
||||
custom.focus();
|
||||
} else {
|
||||
custom.disabled = true;
|
||||
custom.value = '';
|
||||
roiPrice = parseFloat(val);
|
||||
}
|
||||
if (roiEnabled) {
|
||||
fetch('/dashboard/api/roi/config', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ enabled: true, subscription_price: roiPrice })
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function calculateROI() {
|
||||
if (!roiEnabled) return;
|
||||
const loading = document.getElementById('roi-loading');
|
||||
const results = document.getElementById('roi-results');
|
||||
loading.style.display = 'block';
|
||||
results.style.display = 'none';
|
||||
try {
|
||||
const resp = await fetch('/dashboard/api/roi/calculate');
|
||||
const data = await resp.json();
|
||||
loading.style.display = 'none';
|
||||
if (data.error) {
|
||||
document.getElementById('roi-model-rows').innerHTML = '<div style="color:var(--text2);text-align:center;padding:12px;">' + data.error + '</div>';
|
||||
results.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
renderROIResults(data);
|
||||
loadROIComparison();
|
||||
results.style.display = 'block';
|
||||
} catch (e) {
|
||||
loading.style.display = 'none';
|
||||
document.getElementById('roi-model-rows').innerHTML = '<div style="color:var(--danger);text-align:center;padding:12px;">Failed to calculate ROI</div>';
|
||||
results.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function renderROIResults(data) {
|
||||
document.getElementById('roi-total-cost').textContent = '$' + (data.total_cost || 0).toLocaleString(undefined, {maximumFractionDigits: 2});
|
||||
document.getElementById('roi-weekly').textContent = '$' + (data.weekly_value || 0).toLocaleString(undefined, {maximumFractionDigits: 2});
|
||||
const monthly = data.monthly_value || 0;
|
||||
document.getElementById('roi-monthly').textContent = '$' + monthly.toLocaleString(undefined, {maximumFractionDigits: 2});
|
||||
document.getElementById('roi-sub-cost').textContent = '$' + (data.subscription_monthly || 0).toLocaleString(undefined, {maximumFractionDigits: 0});
|
||||
document.getElementById('roi-est-value').textContent = '$' + monthly.toLocaleString(undefined, {maximumFractionDigits: 2});
|
||||
const savings = Math.max(0, monthly - (data.subscription_monthly || 0));
|
||||
document.getElementById('roi-savings').textContent = '$' + savings.toLocaleString(undefined, {maximumFractionDigits: 2});
|
||||
const mult = data.roi_multiplier || 0;
|
||||
document.getElementById('roi-mult').textContent = mult.toFixed(1) + 'x';
|
||||
document.getElementById('roi-mult').style.color = mult > 1 ? 'var(--success)' : (mult > 0.5 ? '#facc15' : 'var(--danger)');
|
||||
// Use total_raw_tokens if available (new calc), otherwise sum from by_model (cached)
|
||||
let rawTotal = data.total_raw_tokens || 0;
|
||||
if (!rawTotal && data.by_model) {
|
||||
rawTotal = data.by_model.reduce((s, m) => s + (m.prompt_tokens || 0) + (m.completion_tokens || 0), 0);
|
||||
}
|
||||
document.getElementById('roi-total-tokens').textContent = rawTotal.toLocaleString();
|
||||
|
||||
const rows = document.getElementById('roi-model-rows');
|
||||
if (data.by_model && data.by_model.length > 0) {
|
||||
rows.innerHTML = data.by_model.map(m => {
|
||||
const pt = m.prompt_tokens || 0;
|
||||
const ct = m.completion_tokens || 0;
|
||||
const totalToks = pt + ct;
|
||||
return '<div class="usage-row" style="grid-template-columns: 2fr 1fr 1fr 1fr 1fr 0.8fr; padding: 8px 14px; margin-bottom: 4px; font-size: 12px;">' +
|
||||
'<div>' + m.model + '</div>' +
|
||||
'<div>$' + (m.prompt_per_mt || 0).toFixed(4) + '</div>' +
|
||||
'<div>$' + (m.completion_per_mt || 0).toFixed(4) + '</div>' +
|
||||
'<div>$' + (m.cost || 0).toFixed(2) + '</div>' +
|
||||
'<div>' + (m.pct_of_total || 0).toFixed(1) + '%</div>' +
|
||||
'<div>' + totalToks.toLocaleString() + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} else {
|
||||
rows.innerHTML = '<div style="color:var(--text2);text-align:center;padding:12px;">No usage data yet this week</div>';
|
||||
}
|
||||
|
||||
// Weekly usage %
|
||||
const weeklyPct = data.weekly_pct_used || 0;
|
||||
document.getElementById('roi-weekly-pct').textContent = weeklyPct > 0 ? '· Based on ' + weeklyPct.toFixed(1) + '% of weekly quota used' : '';
|
||||
|
||||
// Unmatched models warning
|
||||
const unmatchedEl = document.getElementById('roi-unmatched');
|
||||
if (data.unmatched_models && data.unmatched_models.length > 0) {
|
||||
unmatchedEl.textContent = '⚠ Could not price: ' + data.unmatched_models.join(', ');
|
||||
unmatchedEl.style.display = 'block';
|
||||
} else {
|
||||
unmatchedEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// Stale prices warning
|
||||
document.getElementById('roi-stale').style.display = data.prices_stale ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ─── Model Value Comparison ───
|
||||
|
||||
async function loadROIComparison() {
|
||||
if (!roiEnabled) return;
|
||||
try {
|
||||
const resp = await fetch('/dashboard/api/roi/comparison?period=weekly');
|
||||
const data = await resp.json();
|
||||
renderROIComparison(data);
|
||||
} catch (e) {
|
||||
document.getElementById('roi-comparison-rows').innerHTML = '<div style="color:var(--danger);text-align:center;padding:12px;">Failed to load comparison</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderROIComparison(data) {
|
||||
const rows = document.getElementById('roi-comparison-rows');
|
||||
const summary = document.getElementById('roi-comparison-summary');
|
||||
|
||||
if (data.error) {
|
||||
rows.innerHTML = '<div style="color:var(--text2);text-align:center;padding:12px;">' + data.error + '</div>';
|
||||
summary.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!data.models || data.models.length === 0) {
|
||||
rows.innerHTML = '<div style="color:var(--text2);text-align:center;padding:12px;">No usage data for this period</div>';
|
||||
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 '<div class="usage-row" style="grid-template-columns: 2fr 1fr 1fr 1.2fr 1.2fr 1fr; padding: 8px 14px; margin-bottom: 4px; font-size: 12px;">' +
|
||||
'<div style="font-family:monospace;">' + m.model + '</div>' +
|
||||
'<div>' + (m.requests || 0).toLocaleString() + '</div>' +
|
||||
'<div>' + (m.total_tokens || 0).toLocaleString() + '</div>' +
|
||||
'<div>$' + (m.actual_value || 0).toFixed(2) + '</div>' +
|
||||
'<div>$' + (m.fair_share || 0).toFixed(2) + '</div>' +
|
||||
'<div style="color:' + scoreColor + ';font-weight:600;">' + scoreSign + score.toFixed(2) + '</div>' +
|
||||
'</div>';
|
||||
}).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 =
|
||||
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
|
||||
'<span><strong>Period cost:</strong> $' + (data.period_sub_cost || 0).toFixed(2) + ' · <strong>Total value:</strong> $' + (data.total_actual_value || 0).toFixed(2) + '</span>' +
|
||||
'<span style="color:' + netColor + ';font-weight:600;font-size:13px;">Net: ' + netSign + net.toFixed(2) + '</span>' +
|
||||
'</div>';
|
||||
summary.style.display = 'block';
|
||||
}
|
||||
|
||||
function loadLastROI() {
|
||||
fetch('/dashboard/api/roi/last').then(r => r.json()).then(data => {
|
||||
if (!data.error && (data.by_model || []).length > 0) {
|
||||
renderROIResults(data);
|
||||
document.getElementById('roi-results').style.display = 'block';
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function resetROIData() {
|
||||
fetch('/dashboard/api/roi/reset', {method: 'POST'}).then(() => {
|
||||
document.getElementById('roi-results').style.display = 'none';
|
||||
if (roiEnabled) calculateROI();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
473
guanaco/roi.py
Normal file
473
guanaco/roi.py
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
"""
|
||||
OpenRouter price-based subscription value calculator.
|
||||
|
||||
This module:
|
||||
1. Fetches live model prices from OpenRouter's API
|
||||
2. Maps Ollama Cloud model names to OpenRouter model IDs
|
||||
3. Calculates "what would this usage have cost on OpenRouter?"
|
||||
4. Compares against subscription price to show value multiplier
|
||||
|
||||
Prices are cached for 1 hour to avoid rate limits.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── OpenRouter API ──
|
||||
OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models"
|
||||
OPENROUTER_CACHE_TTL = 3600 # 1 hour
|
||||
|
||||
# Model family mappings: ollama_name_fragment -> openrouter_id_fragment
|
||||
# These are used when exact match fails
|
||||
FAMILY_MAP = {
|
||||
"gemma": ("google/gemma", "google/gemma"),
|
||||
"gemma3": ("google/gemma", "google/gemma"),
|
||||
"gemma4": ("google/gemma", "google/gemma"),
|
||||
"qwen": ("qwen/qwen", "qwen/qwen"),
|
||||
"qwen3": ("qwen/qwen3", "qwen/qwen"),
|
||||
"qwen3.5": ("qwen/qwen3.5", "qwen/qwen"),
|
||||
"qwen3-vl": ("qwen/qwen3-vl", "qwen/qwen3-vl"),
|
||||
"qwen3-coder": ("qwen/qwen3-coder", "qwen/qwen"),
|
||||
"qwen3-next": ("qwen/qwen3-next", "qwen/qwen"),
|
||||
"deepseek": ("deepseek/deepseek", "deepseek/deepseek"),
|
||||
"deepseek-v3": ("deepseek/deepseek", "deepseek/deepseek-v3"),
|
||||
"deepseek-v4": ("deepseek/deepseek", "deepseek/deepseek-v4"),
|
||||
"gpt-oss": ("openai/gpt-oss", "openai/gpt"),
|
||||
"minimax": ("minimax/minimax", "minimax/minimax"),
|
||||
"glm": ("zhipu/glm", "zhipu/glm"),
|
||||
"glm-5": ("zhipu/glm-5", "zhipu/glm"),
|
||||
"kimi": ("moonshot/kimi", "moonshot/kimi"),
|
||||
"kimi-k2": ("moonshot/kimi", "moonshot/kimi"),
|
||||
"devstral": ("mistral/devstral", "mistral/devstral"),
|
||||
"mistral": ("mistral/mistral", "mistral/mistral"),
|
||||
"ministral": ("mistral/ministral", "mistral/ministral"),
|
||||
"nemotron": ("nvidia/nemotron", "nvidia/nemotron"),
|
||||
"cogito": ("cogito/cogito", "cogito/"),
|
||||
"gemini": ("google/gemini", "google/gemini"),
|
||||
"rnj": ("", ""),
|
||||
}
|
||||
|
||||
|
||||
def _normalized(name: str) -> str:
|
||||
"""Strip provider prefix, ~leaderboard prefix, :cloud/:local suffixes, and lower-case."""
|
||||
base = name.split(":")[0].lower()
|
||||
# Strip ~ prefix (leaderboard indicator on OpenRouter)
|
||||
if base.startswith("~"):
|
||||
base = base[1:]
|
||||
# Strip provider/ prefix (e.g. moonshotai/kimi-k2.6 → kimi-k2.6)
|
||||
if "/" in base:
|
||||
base = base.split("/", 1)[1]
|
||||
if base.endswith("-cloud"):
|
||||
base = base[:-6]
|
||||
return base
|
||||
|
||||
|
||||
def _model_size(name: str) -> int:
|
||||
"""Extract parameter size in billions from model name, 0 if unknown."""
|
||||
import re
|
||||
m = re.search(r"(\d+)(b|t)", name, re.I)
|
||||
if not m:
|
||||
return 0
|
||||
n = int(m.group(1))
|
||||
unit = m.group(2).lower()
|
||||
return n * 1000 if unit == "t" else n
|
||||
|
||||
|
||||
class PriceCache:
|
||||
"""Holds cached OpenRouter prices in memory with TTL."""
|
||||
def __init__(self):
|
||||
self.prices: dict[str, dict] = {}
|
||||
self.fetched_at: float = 0
|
||||
|
||||
def is_fresh(self) -> bool:
|
||||
return self.prices and (time.time() - self.fetched_at) < OPENROUTER_CACHE_TTL
|
||||
|
||||
def fetch(self) -> dict[str, dict]:
|
||||
if self.is_fresh():
|
||||
logger.debug("Using cached OpenRouter prices")
|
||||
return self.prices
|
||||
|
||||
prices = {}
|
||||
try:
|
||||
logger.info("Fetching OpenRouter model prices...")
|
||||
r = httpx.get(OPENROUTER_MODELS_URL, timeout=30)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
for model in data.get("data", []):
|
||||
model_id = model.get("id", "")
|
||||
pricing = model.get("pricing", {})
|
||||
prompt = float(pricing.get("prompt", 0) or 0)
|
||||
completion = float(pricing.get("completion", 0) or 0)
|
||||
cache_read = float(pricing.get("input_cache_read", 0) or 0)
|
||||
if prompt > 0 or completion > 0:
|
||||
# Convert from per-token ($/token) to per-million-tokens ($/Mt)
|
||||
entry = {
|
||||
"prompt": prompt * 1_000_000,
|
||||
"completion": completion * 1_000_000,
|
||||
}
|
||||
if cache_read > 0:
|
||||
entry["input_cache_read"] = cache_read * 1_000_000
|
||||
prices[model_id] = entry
|
||||
self.prices = prices
|
||||
self.fetched_at = time.time()
|
||||
logger.info(f"Fetched {len(prices)} OpenRouter price entries")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch OpenRouter prices: {e}")
|
||||
return self.prices
|
||||
|
||||
|
||||
# Singleton cache
|
||||
_price_cache = PriceCache()
|
||||
|
||||
|
||||
def _find_best_price(prices: dict, ollama_name: str) -> dict:
|
||||
"""
|
||||
Given OpenRouter prices dict {model_id: {prompt, completion}} and an
|
||||
Ollama model name, return best matching price dict.
|
||||
"""
|
||||
norm = _normalized(ollama_name)
|
||||
size = _model_size(ollama_name)
|
||||
|
||||
# 1. Exact normalized match (handles provider-prefixed OR IDs like moonshotai/kimi-k2.6)
|
||||
for orouter_id, price_info in prices.items():
|
||||
if _normalized(orouter_id) == norm:
|
||||
return price_info
|
||||
|
||||
# 2. Family prefix match — use raw orouter_id so provider/ prefix matches
|
||||
best_family_price = None
|
||||
best_family_score = -9999
|
||||
for orouter_id, price_info in prices.items():
|
||||
for frag, (family_exact, family_prefix) in FAMILY_MAP.items():
|
||||
if frag in norm and family_prefix and family_prefix in orouter_id:
|
||||
# Score by size closeness
|
||||
o_size = _model_size(orouter_id)
|
||||
score = -(abs(o_size - size)) # higher = closer size
|
||||
if score > best_family_score:
|
||||
best_family_score = score
|
||||
best_family_price = price_info
|
||||
if best_family_price:
|
||||
return best_family_price
|
||||
|
||||
# 3. Same parameter size match
|
||||
if size > 0:
|
||||
for orouter_id, price_info in prices.items():
|
||||
if _model_size(orouter_id) == size:
|
||||
return price_info
|
||||
|
||||
# 4. Size-window fallback
|
||||
candidates = []
|
||||
for orouter_id, price_info in prices.items():
|
||||
o_size = _model_size(orouter_id)
|
||||
if o_size == 0:
|
||||
continue
|
||||
window = max(20, size * 0.5)
|
||||
if abs(o_size - size) <= window:
|
||||
candidates.append(price_info)
|
||||
if candidates:
|
||||
candidates.sort(key=lambda p: p["completion"] + p["prompt"])
|
||||
return candidates[len(candidates) // 2]
|
||||
|
||||
# 5. Global average
|
||||
all_prices = [p for p in prices.values() if p["prompt"] > 0 or p["completion"] > 0]
|
||||
if all_prices:
|
||||
avg_prompt = sum(p["prompt"] for p in all_prices) / len(all_prices)
|
||||
avg_comp = sum(p["completion"] for p in all_prices) / len(all_prices)
|
||||
return {"prompt": avg_prompt, "completion": avg_comp}
|
||||
|
||||
return {"prompt": 0.0, "completion": 0.0}
|
||||
|
||||
|
||||
def _map_usage_to_prices(usage_by_model: dict, prices: dict, cache_hit_pct: float = 0.0) -> dict:
|
||||
"""
|
||||
Map usage to prices, optionally applying prompt-cache hit discount.
|
||||
|
||||
For models with input_cache_read pricing (e.g. Claude Fable, Qwen, Minimax):
|
||||
- uncached_prompt = prompt_tokens * (1 - cache_hit_pct)
|
||||
- cached_prompt = prompt_tokens * cache_hit_pct
|
||||
- prompt cost = uncached_prompt * prompt_price + cached_prompt * cache_read_price
|
||||
"""
|
||||
result = {}
|
||||
cache_rate = max(0.0, min(100.0, cache_hit_pct)) / 100.0
|
||||
for model, usage in usage_by_model.items():
|
||||
price = _find_best_price(prices, model)
|
||||
pt = usage.get("prompt_tokens", 0)
|
||||
ct = usage.get("completion_tokens", 0)
|
||||
|
||||
# Apply cache discount if model supports it
|
||||
if "input_cache_read" in price and cache_rate > 0:
|
||||
uncached_pt = pt * (1 - cache_rate)
|
||||
cached_pt = pt * cache_rate
|
||||
prompt_cost = (uncached_pt / 1_000_000 * price["prompt"]) + (cached_pt / 1_000_000 * price["input_cache_read"])
|
||||
# Store effective prompt price for display
|
||||
effective_prompt = prompt_cost / (pt / 1_000_000) if pt > 0 else price["prompt"]
|
||||
else:
|
||||
prompt_cost = (pt / 1_000_000) * price["prompt"]
|
||||
effective_prompt = price["prompt"]
|
||||
|
||||
comp_cost = (ct / 1_000_000) * price["completion"]
|
||||
cost = prompt_cost + comp_cost
|
||||
|
||||
result[model] = {
|
||||
"prompt_tokens": pt,
|
||||
"completion_tokens": ct,
|
||||
"prompt_per_mt": effective_prompt,
|
||||
"completion_per_mt": price["completion"],
|
||||
"cost": cost,
|
||||
"matched_price_model": _find_best_price.__module__,
|
||||
"cache_applied": "input_cache_read" in price and cache_rate > 0,
|
||||
"cache_read_per_mt": price.get("input_cache_read"),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def get_usage_from_analytics(db_path: Path | str, since: float = 0) -> tuple[dict, float]:
|
||||
usage_by_model = {}
|
||||
total_weighted = 0.0
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
rows = conn.execute(
|
||||
"""SELECT model,
|
||||
IFNULL(SUM(prompt_tokens),0),
|
||||
IFNULL(SUM(completion_tokens),0),
|
||||
IFNULL(SUM(prompt_tokens * IFNULL(usage_multiplier,1.0)),0),
|
||||
IFNULL(SUM(completion_tokens * IFNULL(usage_multiplier,1.0)),0),
|
||||
COUNT(*)
|
||||
FROM request_log WHERE type='llm' AND ts > ? GROUP BY model""",
|
||||
(since,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
model, pt, ct, w_pt, w_ct, req_count = row
|
||||
usage_by_model[model] = {
|
||||
"prompt_tokens": pt,
|
||||
"completion_tokens": ct,
|
||||
"weighted_prompt": w_pt,
|
||||
"weighted_completion": w_ct,
|
||||
"requests": req_count,
|
||||
}
|
||||
total_weighted += (w_pt + w_ct)
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read analytics DB: {e}")
|
||||
return usage_by_model, total_weighted
|
||||
|
||||
|
||||
def _get_ollama_week_start() -> float:
|
||||
"""Return Unix timestamp of the most recent Sunday at 20:00 UTC.
|
||||
|
||||
Ollama resets its weekly quota every Sunday at 8 PM UTC.
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
days_since_sunday = now.weekday() + 1 if now.weekday() != 6 else 0
|
||||
sunday = now - timedelta(days=days_since_sunday)
|
||||
reset = sunday.replace(hour=20, minute=0, second=0, microsecond=0)
|
||||
if now < reset:
|
||||
reset -= timedelta(days=7)
|
||||
return reset.timestamp()
|
||||
|
||||
|
||||
def calculate_roi(
|
||||
db_path: Path | str,
|
||||
subscription_monthly: float = 20.0,
|
||||
weekly_pct_used: float = 0.0,
|
||||
cache_hit_pct: float = 0.0,
|
||||
) -> dict:
|
||||
"""
|
||||
Calculate subscription value vs OpenRouter pay-as-you-go.
|
||||
|
||||
Args:
|
||||
db_path: path to analytics SQLite DB
|
||||
subscription_monthly: monthly sub cost (20 Pro, 100 Max)
|
||||
weekly_pct_used: % of weekly quota consumed (from usage check)
|
||||
cache_hit_pct: estimated % of prompt tokens hitting cache (0-100).
|
||||
Used for models with input_cache_read pricing on OpenRouter.
|
||||
|
||||
Returns dict with:
|
||||
total_cost, total_prompt_tokens, total_completion_tokens,
|
||||
total_weighted_tokens, weekly_value, monthly_value,
|
||||
subscription_monthly, plan ("pro"|"max"), roi_multiplier,
|
||||
weekly_pct_used, cache_hit_pct, prices_stale,
|
||||
by_model[] with prompt_tokens, completion_tokens, prompt_per_mt, completion_per_mt, cost,
|
||||
unmatched_models[] names with no price match
|
||||
"""
|
||||
# 1. Fetch prices
|
||||
prices = _price_cache.fetch()
|
||||
prices_stale = not prices or len(prices) < 10
|
||||
plan = "pro" if subscription_monthly <= 25 else "max"
|
||||
|
||||
# 2. Get usage
|
||||
since = _get_ollama_week_start()
|
||||
usage_by_model, total_weighted = get_usage_from_analytics(db_path, since)
|
||||
|
||||
# 3. Map usage to prices (with cache hit estimation)
|
||||
priced = _map_usage_to_prices(usage_by_model, prices, cache_hit_pct)
|
||||
|
||||
total_cost = sum(m["cost"] for m in priced.values())
|
||||
total_prompt = sum(m["prompt_tokens"] for m in priced.values())
|
||||
total_comp = sum(m["completion_tokens"] for m in priced.values())
|
||||
|
||||
# 4. Extrapolate to 100% weekly
|
||||
if weekly_pct_used > 0:
|
||||
weekly_value = total_cost / (weekly_pct_used / 100.0)
|
||||
else:
|
||||
weekly_value = total_cost
|
||||
|
||||
monthly_value = weekly_value * 4
|
||||
roi_multiplier = (monthly_value / subscription_monthly) if subscription_monthly > 0 else 0
|
||||
|
||||
unmatched = [m for m in usage_by_model if priced.get(m, {}).get("cost", 0) == 0]
|
||||
|
||||
# Per-model breakdown
|
||||
by_model = []
|
||||
for model, detail in priced.items():
|
||||
pt = detail["prompt_tokens"]
|
||||
ct = detail["completion_tokens"]
|
||||
by_model.append({
|
||||
"model": model,
|
||||
"prompt_tokens": pt,
|
||||
"completion_tokens": ct,
|
||||
"prompt_per_mt": round(detail["prompt_per_mt"], 6),
|
||||
"completion_per_mt": round(detail["completion_per_mt"], 6),
|
||||
"cost": round(detail["cost"], 4),
|
||||
"pct_of_total": round((detail["cost"] / total_cost * 100), 2) if total_cost > 0 else 0,
|
||||
"cache_applied": detail.get("cache_applied", False),
|
||||
"cache_read_per_mt": round(detail.get("cache_read_per_mt"), 6) if detail.get("cache_read_per_mt") else None,
|
||||
})
|
||||
by_model.sort(key=lambda x: x["cost"], reverse=True)
|
||||
|
||||
return {
|
||||
"total_cost": round(total_cost, 2),
|
||||
"total_prompt_tokens": int(total_prompt),
|
||||
"total_completion_tokens": int(total_comp),
|
||||
"total_raw_tokens": int(total_prompt + total_comp),
|
||||
"total_weighted_tokens": int(total_weighted),
|
||||
"weekly_value": round(weekly_value, 2),
|
||||
"monthly_value": round(monthly_value, 2),
|
||||
"subscription_monthly": subscription_monthly,
|
||||
"plan": plan,
|
||||
"roi_multiplier": round(roi_multiplier, 2),
|
||||
"weekly_pct_used": weekly_pct_used,
|
||||
"cache_hit_pct": cache_hit_pct,
|
||||
"prices_stale": prices_stale,
|
||||
"by_model": by_model,
|
||||
"unmatched_models": unmatched,
|
||||
"price_models_available": len(prices),
|
||||
}
|
||||
|
||||
|
||||
def calculate_model_value_comparison(
|
||||
db_path: Path | str,
|
||||
subscription_monthly: float = 20.0,
|
||||
weekly_pct_used: float = 0.0,
|
||||
session_pct_used: float = 0.0,
|
||||
period: str = "weekly", # "weekly" or "session"
|
||||
) -> dict:
|
||||
"""
|
||||
Score each model: positive = gave more value than its fair share of sub.
|
||||
|
||||
For each model actually used:
|
||||
- actual_value = what those tokens would cost on OpenRouter
|
||||
- fair_share = (model's weighted tokens / total weighted tokens) * subscription_cost_for_period
|
||||
- score = actual_value - fair_share
|
||||
positive = model punches above its weight (good deal)
|
||||
negative = model is expensive for its token share (bad deal)
|
||||
"""
|
||||
prices = _price_cache.fetch()
|
||||
if not prices:
|
||||
return {"error": "No OpenRouter prices available", "models": []}
|
||||
|
||||
# Determine time window and usage %
|
||||
now = time.time()
|
||||
if period == "session":
|
||||
since = now - (5 * 3600) # 5-hour session window
|
||||
pct_used = session_pct_used
|
||||
else:
|
||||
since = now - (7 * 24 * 3600) # 7-day weekly window
|
||||
pct_used = weekly_pct_used
|
||||
|
||||
usage_by_model, total_weighted = get_usage_from_analytics(db_path, since)
|
||||
if not usage_by_model:
|
||||
return {"models": [], "summary": {}}
|
||||
|
||||
# Period subscription cost
|
||||
weekly_sub_cost = subscription_monthly / 4.0
|
||||
if pct_used > 0:
|
||||
period_sub_cost = weekly_sub_cost * (pct_used / 100.0)
|
||||
else:
|
||||
period_sub_cost = weekly_sub_cost
|
||||
|
||||
# Map to prices
|
||||
priced = _map_usage_to_prices(usage_by_model, prices)
|
||||
|
||||
# Per-model scoring
|
||||
models = []
|
||||
total_actual_value = 0.0
|
||||
|
||||
for model, usage in usage_by_model.items():
|
||||
detail = priced.get(model, {})
|
||||
actual_value = detail.get("cost", 0.0)
|
||||
pt = usage.get("prompt_tokens", 0)
|
||||
ct = usage.get("completion_tokens", 0)
|
||||
model_raw = pt + ct
|
||||
w_pt = usage.get("weighted_prompt", 0)
|
||||
w_ct = usage.get("weighted_completion", 0)
|
||||
model_weighted = w_pt + w_ct
|
||||
|
||||
# Fair share of subscription based on WEIGHTED token proportion
|
||||
# (subscription quota is weighted; actual value uses raw tokens at OpenRouter prices)
|
||||
fair_share = (model_weighted / total_weighted * period_sub_cost) if total_weighted > 0 else 0
|
||||
score = actual_value - fair_share
|
||||
score_pct = (score / fair_share * 100) if fair_share > 0 else 0
|
||||
|
||||
total_actual_value += actual_value
|
||||
|
||||
models.append({
|
||||
"model": model,
|
||||
"requests": usage.get("requests", 0),
|
||||
"prompt_tokens": pt,
|
||||
"completion_tokens": ct,
|
||||
"total_tokens": int(model_raw),
|
||||
"weighted_tokens": int(model_weighted),
|
||||
"pct_of_total_tokens": round((model_weighted / total_weighted * 100), 2) if total_weighted > 0 else 0,
|
||||
"actual_value": round(actual_value, 2),
|
||||
"fair_share": round(fair_share, 2),
|
||||
"score": round(score, 2),
|
||||
"score_pct": round(score_pct, 1),
|
||||
"prompt_per_mt": round(detail.get("prompt_per_mt", 0), 6),
|
||||
"completion_per_mt": round(detail.get("completion_per_mt", 0), 6),
|
||||
})
|
||||
|
||||
# Sort by score descending (best value first)
|
||||
models.sort(key=lambda x: x["score"], reverse=True)
|
||||
|
||||
# Summary
|
||||
net_score = total_actual_value - period_sub_cost
|
||||
|
||||
# Compute total raw tokens across all models for the summary
|
||||
total_raw = sum(m.get("prompt_tokens", 0) + m.get("completion_tokens", 0) for m in models)
|
||||
|
||||
return {
|
||||
"period": period,
|
||||
"subscription_monthly": subscription_monthly,
|
||||
"period_sub_cost": round(period_sub_cost, 2),
|
||||
"total_actual_value": round(total_actual_value, 2),
|
||||
"total_raw_tokens": int(total_raw),
|
||||
"total_weighted_tokens": int(total_weighted),
|
||||
"net_score": round(net_score, 2),
|
||||
"models": models,
|
||||
"prices_stale": len(prices) < 10,
|
||||
"price_models_available": len(prices),
|
||||
}
|
||||
|
||||
|
||||
def get_cached_roi() -> dict:
|
||||
"""Return last calculated ROI, or minimal default."""
|
||||
return _price_cache.prices # placeholder; real caching is in config
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -469,11 +464,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 +484,8 @@ def create_router(client: OllamaClient, analytics=None, config=None, account_poo
|
|||
"root": display_name,
|
||||
"parent": None,
|
||||
"capabilities": client._get_model_capabilities(name),
|
||||
"usage_multiplier": multiplier,
|
||||
"usage_level": level, # 1-4, 0 = unknown
|
||||
"details": {
|
||||
"parameter_size": details.get("parameter_size", ""),
|
||||
"quantization": details.get("quantization_level", ""),
|
||||
|
|
@ -552,10 +555,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 +616,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 +800,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 +1524,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 +1589,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):
|
||||
|
|
|
|||
22
install.sh
22
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
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ requires = ["setuptools>=68.0", "wheel"]
|
|||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "guanaco"
|
||||
version = "0.4.2"
|
||||
name = "guanaco-llm-proxy"
|
||||
version = "0.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"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue