Compare commits

...
Sign in to create a new pull request.

11 commits

Author SHA1 Message Date
Evan Rice
17f7beb88c fix(router,client): token estimation always enabled; Ollama native eval fallback
- _history_kwargs unconditionally populates input_text/output_text for
cost tracking, regardless of save_history toggle.
- OllamaClient falls back to prompt_eval_count/eval_count before tiktoken.
- Release 0.5.2.
2026-06-10 02:07:24 +00:00
Evan Rice
a80746454c fix(updater): stash + hard-reset instead of merge-pull; release 0.5.1
The Apply Update endpoint used 'git pull origin {branch}', which fails
with a merge conflict if the working tree has any local modifications.
This happened to every install that had leftover version-string edits from
a prior partial update, leaving the old code running silently.

Fix: unconditionally stash (including untracked files) and reset to the
exact remote commit before reinstalling. The update now succeeds even
if the repo is dirty.

Bumps all version strings to 0.5.1 so existing 0.4.2 → 0.5.x installs
immediately receive the new updater logic.
2026-06-10 01:38:02 +00:00
Evan Rice
63327c7cb3 hotfix(v0.5.0): sync version strings + fix importlib metadata fallback
- __init__.py, app.py, pyproject.toml: bump to 0.5.0
- __init__.py: query guanaco-llm-proxy (not guanaco) and only override
  hardcoded if metadata version >= hardcoded version

Prevents stale guanaco-0.4.2.dist-info from clobbering the version.
Fixes apply_update showing wrong version after git-pull.
2026-06-10 01:25:06 +00:00
Evan Rice
b4ed4faebd chore: bump version to 0.5.0 and add comprehensive changelog
CHANGELOG covers:
- Token estimation fallback + skimtoken + fallback_reason audit
- ROI dashboard + OpenRouter price fetcher + cache-hit discount
- Real usage-level scraping from ollama.com (per-tag + top-level)
- Multi-account Ollama Cloud rotation + premium model routing
- Search provider plugins (8 backends)
- Config migration, install robustness, package rename
- Schema changes (usage_multiplier column removed, fallback_reason added)
- CI/CD, Docker, project hygiene improvements
- API changes (/v1/models + /api/ollama/models new fields)
- Known issues and deprecations
2026-06-10 01:01:21 +00:00
Evan Rice
25a0b1b653 feat(usage-levels): scrape real per-tag usage tiers from ollama.com library pages
- Add _fetch_usage_level_sync() to parse ollama.com/library/{model} HTML
- Handle both top-level model badges (x-test-model-cost-slot-active)
  and per-tag listings (x-test-model-tag-cost + x-test-model-tag-usage-slot-active)
- Add async fetch_usage_levels() with 1h global cache + parallel fetching
- Wire usage_multiplier into /v1/models and /api/ollama/models responses
- Replace heuristic _get_model_multiplier fallback with real scraped levels
- Fixes gemma3:4b/12b showing 1.00x instead of 0.25x, qwen3-vl 0.25x→0.75x, etc.
2026-06-10 00:57:12 +00:00
Evan Rice
00d7509d32 feat(v0.4.3): token estimation fallback, ROI dashboard, per-model usage breakdown
Token tracking:
- analytics.py: fall back to skimtoken estimation when API omits usage
  (~15% error, better than zero). Proper total_tokens = prompt + completion.
  Removed broken usage_multiplier column.
- router.py: unchanged — already passes usage → analytics correctly

Dashboard / ROI:
- roi.py: OpenRouter price fetcher with cache-hit discount logic
- dashboard.py: expose ROI config with cache_hit_pct slider
- dashboard.html: cost breakdown UI, per-model value scoring

Models:
- client.py: added minimax-m3, nemotron-3-ultra, kimi-k2.6 200k context
- client.py: per-model Ollama usage breakdown scraping from /settings

Config:
- config.py: backward compat for installs missing SearchConfig
2026-06-10 00:16:40 +00:00
evangit2
9793ce2c44 fix(config): v0.4.3 backward compat and install robustness
Changes:
- Add missing UsageConfig fields (last_plan, redirect_on_full, last_session_reset, last_weekly_reset, last_checked) to fix AttributeError crashes
- Add config migration layer in load_config() for v0.4.2 to v0.4.3+ configs
- Rename package guanaco to guanaco-llm-proxy to avoid PyPI collision
- Add Ollama API key validation during install.sh setup (fixes: use real key var, fix env file write, fix grep pattern)
- Add startup version sanity check detecting repo/venv mismatch
- Fix systemd service WorkingDirectory to repo checkout
- Add GUANACO_CONFIG_DIR env var to systemd service
2026-05-23 23:35:17 +00:00
evangit2
07fe4fd587 feat: experimental Subscription Value Calculator (ROI) vs OpenRouter
Adds a new experimental feature to the Status tab that compares your
total subscription cost to what you'd pay with OpenRouter's cheapest
provider pricing.

Backend:
- guanaco/roi.py: Fetches live prices from OpenRouter API, matches
  Ollama model names to OpenRouter model IDs via family inference
  (exact → family prefix → same parameter size → size window →
  global average), multiplies tokens by $/Mt cost.
- Calculates this week's cost, extrapolates to 100% weekly usage,
  then monthly. Shows ROI multiplier (monthly value / subscription).
- /dashboard/api/roi/calculate: fresh calculation
- /dashboard/api/roi/last: cached last result
- /dashboard/api/roi/reset: clear data
- /dashboard/api/roi/config: enable/disable, set price tier

Dashboard (Status tab):
- Toggle to enable/disable the feature
- Pro (0/mo) / Max (00/mo) / Custom price selector
- Shows: this-week cost, weekly @ 100%, monthly value, ROI multiplier
- Per-model table: prompt_tokens, completion_tokens, $/Mt in/out,
  total cost, % of usage
- Warnings for unmatched models and stale prices
- Reset button clears cached data

ROIConfig added to config.py (persisted in config.yaml).
2026-05-23 21:32:54 +00:00
evangit2
5242f662da feat: auto-infer capabilities and multiplier for unknown models
Previously new Ollama Cloud models showed no capability badges and
assumed 1.00x multiplier until manually added to KNOWN_CLOUD_MODELS.
Now both backend (client.py) and frontend (dashboard.html) fall
back to name-based inference for unknown models:

- Capabilities: 'vision' from vl/gemma/gemini/kimi/deepseek, 'tools'
  from coder/minimax/glm/mistral/gpt-oss/devstral/nemotron/deepseek,
  'thinking' from deepseek/cogito/reason/think and kimi-k* families
- Multiplier: extract :XXb|:Xt from model name for size-based tier
  (<=20b=0.25, <=80b=0.50, <=400b=0.75, >400b/1t=1.00), then fall
  back to name heuristics (nano/mini/small=0.25, flash/gemma=0.50,
  pro/mistral-large=1.00)

Models explicitly in KNOWN_CLOUD_MODELS still use exact values.
This means new models like deepseek-v5-pro or kimi-k3 show up
correctly in the dashboard immediately without code changes.
2026-05-23 21:18:02 +00:00
evangit2
9a2d0d76cd feat: cost-weighted analytics (usage_multiplier) + add deepseek-v4 models
- Add usage_multiplier column to analytics DB (auto-resolved from model name)
- Log weighted token totals (prompt*multiplier, completion*multiplier) in summary
- Show cost bars + weighted totals in dashboard analytics tab
- Add per-model multiplier column to analytics table
- Auto-compute total_tokens in analytics if not provided
- Add deepseek-v4-pro (1.0x) and deepseek-v4-flash (0.50x) to KNOWN_CLOUD_MODELS
- Add gemini-3-flash-preview to available_models and dashboard capability maps
- Add new models to config.py available_models list
2026-05-23 19:56:44 +00:00
evangit2
7180b27a8a v0.4.3 — add kimi-k2.6 vision model, default all roles to nemotron-3-nano:30b
- KNOWN_CLOUD_MODELS: add kimi-k2.6 with [vision, tools, thinking, cloud]
- LLMConfig defaults: reranker/scraper/summary/default all set to nemotron-3-nano:30b
- available_models: add nemotron-3-nano:30b and kimi-k2.6
- dashboard.html: add kimi-k2.6 and nemotron-3-nano capability maps
- cli.py setup wizard: update all model prompts/default placeholders
- version bump: 0.4.2 → 0.4.3 in __init__.py, app.py, pyproject.toml

Update-safe: only new model registry entries + default value changes.
Existing configs preserve their saved values (pydantic).
Zero schema or API break.
2026-05-22 20:48:24 +00:00
14 changed files with 1843 additions and 133 deletions

2
.gitignore vendored
View file

@ -87,4 +87,4 @@ dmypy.json
Thumbs.db
# Project-specific
.oct/
.oct/.venv-test/

185
CHANGELOG.md Normal file
View 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.

View file

@ -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

View file

@ -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(

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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,

View file

@ -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

View file

@ -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
View 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

View file

@ -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):

View file

@ -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

View file

@ -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"}