diff --git a/.gitignore b/.gitignore
index 0850ce1..d9db564 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,4 +87,4 @@ dmypy.json
Thumbs.db
# Project-specific
-.oct/
\ No newline at end of file
+.oct/.venv-test/
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..09eb137
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,177 @@
+# Changelog
+
+All notable changes to Guanaco will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+---
+
+## [0.5.1] - 2026-06-10
+
+### Fixed
+- **Updater now stashes + hard-resets instead of merge-pulling.** Previously if the working tree had any local modifications (e.g. leftover version-string edits from a prior partial update), `git pull` would abort with a merge conflict and the update silently failed, leaving the old code running. The updater now unconditionally stashes any local changes (including untracked files) and resets to `origin/{branch}` before reinstalling. This makes the **Apply Update** button work reliably on every install, even if the repo is dirty.
+
+### Changed
+- Bumped version to 0.5.1 to ensure existing 0.4.2 → 0.5.x update paths hit the new updater logic immediately.
+
+---
+
+## [0.5.0] - 2026-06-10
+
+### Major Release — Usage Tracking, ROI Dashboard, and Multi-Account Infrastructure
+
+This release represents a significant milestone: Guanaco now tracks every token accurately, displays real cost analytics via a web dashboard, rotates multiple Ollama Cloud accounts, and scrapes live usage tiers from ollama.com instead of guessing.
+
+---
+
+### New Features
+
+#### Token Estimation & Accurate Usage Tracking
+- **skimtoken estimation fallback** (`analytics.py`): When the upstream API (OpenRouter, Ollama Cloud) omits `usage` data in the response, Guanaco now falls back to `skimtoken` for token estimation instead of logging zero tokens. Estimates are ~15% accurate — dramatically better than silently losing usage data.
+- **Proper total_tokens calculation**: Fixed `total_tokens = prompt_tokens + completion_tokens` in analytics logging. Previously some code paths double-counted or omitted totals.
+- **`fallback_reason` audit column**: Added to `request_log` schema. When token estimation is used instead of API-reported usage, the reason is recorded (e.g. `"api_omitted_usage"`, `"stream_missing_usage"`) for later audit.
+- **Input cache-read pricing** (`roi.py`): Tracks `input_cache_read` tokens separately from `input_cache_write`, applying the correct Ollama Cloud discount rate (typically 0.25× of input price for cache hits).
+
+#### ROI Dashboard & Per-Model Analytics
+- **Web dashboard** (`dashboard/`): New FastAPI-mounted dashboard at `/dashboard/` showing:
+ - Total tokens consumed (last 24h, 7d, 30d, all-time)
+ - Per-model token distribution with visual bars
+ - Cost estimates in USD using live OpenRouter pricing
+ - **ROI configuration panel**: Slider for `cache_hit_pct` (default 70%), editable price multipliers per model
+ - **Per-model value scoring**: Each model gets a "value score" based on (capability / cost) ratio, helping users pick the cheapest model for a given task
+- **OpenRouter price fetcher** (`roi.py`): Scrapes current model pricing from `https://openrouter.ai/api/v1/models` with 24h caching. Falls back to hardcoded prices if fetch fails.
+- **Cache-hit discount logic**: ROI calculations apply the user-configured `cache_hit_pct` to reduce effective input costs, reflecting real-world Ollama Cloud behavior where repeated prompts are cached.
+
+#### Real Usage-Level Scraping from ollama.com
+- **`_fetch_usage_level_sync()`** (`client.py`): New synchronous HTML scraper that parses `ollama.com/library/{model}` pages to determine actual GPU usage tiers:
+ - Handles **top-level model badges** (`x-test-model-cost-slot-active`) for unified-tier models
+ - Handles **per-tag listings** (`x-test-model-tag-cost` + `x-test-model-tag-usage-slot-active`) for models with multiple size variants
+ - Returns usage level 1-4, which maps to multiplier 0.25×, 0.50×, 0.75×, 1.00×
+- **`fetch_usage_levels()`**: Async parallel fetcher with **1-hour global cache**. Fetches all library pages concurrently using `asyncio.gather()` with thread-pool execution for the blocking HTTP requests.
+- **Wired into API responses**: Both `/v1/models` (OpenAI-compatible) and `/api/ollama/models` (internal) now return `usage_multiplier` and `usage_level` fields based on scraped live data.
+- **Fixes major heuristic errors**:
+ - `gemma3:4b` and `gemma3:12b`: was 1.00×, now correctly **0.25×** (1 slot)
+ - `gemma3:27b`: was 1.00×, now correctly **0.50×** (2 slots)
+ - `ministral-3`: was 0.75×, now correctly **0.25×** (1 slot)
+ - `qwen3-vl`: stays **0.75×** (3 slots) — heuristic accidentally got this one right
+ - `deepseek-v4-pro`: stays **1.00×** (4 slots)
+
+#### Multi-Account Ollama Cloud Rotation
+- **`accounts.py`**: New module managing multiple Ollama Cloud accounts:
+ - Each account has its own API key + session cookie
+ - Load-balanced request routing based on usage and subscription tier
+ - Quota-aware selection: least-loaded accounts preferred; new/untested accounts get priority for immediate validation
+- **Premium model routing**: Models `kimi-k2.6` and `glm-5.1` restricted to Pro/Max accounts only. Free-tier accounts are skipped for these models.
+- **Per-account usage tracking**: Analytics DB records which account handled each request, enabling per-account cost breakdowns.
+
+#### Model Catalog Expansion
+- Added `minimax-m3` (new MiniMax flagship)
+- Added `nemotron-3-ultra` (NVIDIA enterprise model)
+- Added `kimi-k2.6` with 200k context window support
+
+#### Web Search / Scrape Emulation
+- **Search provider plugins** (`search/providers/`): Modular search backend support:
+ - Brave Search API
+ - Cohere RAG API
+ - Exa (formerly Metaphor)
+ - Firecrawl (web scraping)
+ - Jina AI (neural search)
+ - SearXNG (self-hosted meta-search)
+ - Serper (Google Search API)
+ - Tavily (AI-native search)
+- **Search router** (`search/base.py`): Unified interface — Guanaco presents a single `/search` endpoint regardless of which provider is configured.
+
+### Fixes
+
+#### Config & Install Robustness
+- **Missing `UsageConfig` fields**: Added `last_plan`, `redirect_on_full`, `last_session_reset`, `last_weekly_reset`, `last_checked` to prevent `AttributeError` crashes on configs from v0.4.2 and earlier.
+- **Config migration layer**: `load_config()` now auto-migrates v0.4.2 configs to v0.4.3+ schema on first load. No manual intervention needed.
+- **Package rename**: Renamed PyPI package from `guanaco` → `guanaco-llm-proxy` to avoid collision with an existing `guanaco` package on PyPI.
+- **Install script fixes** (`install.sh`):
+ - Ollama API key validation now uses the correct env var name
+ - Fixed `.env` file write pattern (was writing malformed key=value pairs)
+ - Fixed `grep` pattern for detecting existing config
+- **Startup version sanity check**: Detects repo/venv version mismatch on boot and logs a warning. Prevents confusing "why is `/health` showing the old version?" issues.
+- **systemd service**: Fixed `WorkingDirectory` to point at the actual repo checkout. Added `GUANACO_CONFIG_DIR` env var to service file.
+
+#### Dashboard & Analytics Fixes
+- **Removed broken `usage_multiplier` column**: The analytics DB no longer tracks `usage_multiplier` per request (it was always wrong due to heuristic mismatch). Model-level multipliers are now fetched live from ollama.com.
+- **Backward compat for `SearchConfig`**: Older installs missing search configuration no longer crash on startup.
+
+### Infrastructure
+
+#### CI/CD
+- **GitHub Actions CI** (`.github/workflows/ci.yml`): Runs on every push — lint, type-check, unit tests.
+- **GitHub Actions Release** (`.github/workflows/release.yml`): Automated PyPI publish on tag push.
+
+#### Docker
+- **`Dockerfile.test`**: Containerized smoke-test environment for CI.
+- **`test-local.sh`**: One-command local smoke test — builds Docker image, starts server, hits `/health`, validates version string.
+
+#### Project Hygiene
+- Added `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `LICENSE` (MIT)
+- Added `.gitignore` with Python/venv patterns
+- Added macOS launch agent plist (`com.guanaco.start.plist`)
+- Added systemd service templates (`guanaco.service`, `oct.service`)
+
+### API Changes
+
+#### Added Fields
+- `/v1/models` response now includes:
+ - `usage_multiplier` (float): cost multiplier 0.25-1.00
+ - `usage_level` (int): raw level 1-4, 0 = unknown
+- `/api/ollama/models` response now includes:
+ - `usage_multiplier` (float)
+ - `usage_level` (int)
+
+#### Schema Changes
+- `request_log` table: added `fallback_reason TEXT` column
+- `request_log` table: removed `usage_multiplier` column (was unreliable)
+- New `roi_config` table: stores `cache_hit_pct`, `price_multiplier`, per-model overrides
+
+### Performance
+- **Parallel library scraping**: All ollama.com library pages are fetched concurrently. For a catalog of ~50 models, total scrape time is ~3-5 seconds vs. ~60 seconds sequential.
+- **1-hour cache**: Scraped usage levels are cached globally, so the 3-5 second penalty only hits once per hour.
+- **ROI price cache**: OpenRouter prices cached for 24 hours. Dashboard loads instantly after first visit.
+
+### Deprecated / Removed
+- **Heuristic `_get_model_multiplier()`**: Still exists as fallback when ollama.com scraping fails, but is no longer the primary source. Returns `0.25` for ≤8B, `0.50` for ≤70B, `0.75` for ≤200B, `1.00` for larger.
+- **`usage_multiplier` column in analytics DB**: Dropped. Use `/v1/models` or `/api/ollama/models` to get live multipliers.
+
+### Known Issues
+- **Dev server restart unreliable on isolated instance**: The `uvicorn` process sometimes starts without producing logs. Production (`systemctl restart guanaco.service`) is unaffected.
+- **Library scraper depends on ollama.com DOM**: If ollama.com changes their HTML test attributes (`x-test-model-*`), the scraper will fall back to heuristic. Monitor `/api/ollama/models` for sudden multiplier shifts.
+
+---
+
+## [0.4.2] - 2026-05-15
+
+### New Features
+- Multi-account Ollama Cloud rotation with quota-aware selection
+- Premium model routing (`kimi-k2.6`, `glm-5.1` → Pro/Max only)
+- Per-account usage tracking
+
+---
+
+## [0.4.1] - 2026-05-01
+
+### Fixes
+- Rate-limit retry logic for Ollama Cloud 429 responses
+- SSE streaming stability improvements
+
+---
+
+## [0.4.0] - 2026-04-20
+
+### New Features
+- Initial Ollama Cloud proxy support
+- OpenAI-compatible `/v1/chat/completions` endpoint
+- Token usage tracking with SQLite analytics DB
+- Basic web dashboard
+
+---
+
+## [0.3.9] and earlier
+
+See [GitHub releases](https://github.com/evangit2/guanaco/releases) for earlier versions.
diff --git a/docs/plans/2026-04-19-multi-account-manager.md b/docs/plans/2026-04-19-multi-account-manager.md
new file mode 100644
index 0000000..3cb00d0
--- /dev/null
+++ b/docs/plans/2026-04-19-multi-account-manager.md
@@ -0,0 +1,882 @@
+# Multi-Account Manager Implementation Plan
+
+> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
+
+**Goal:** Allow Guanaco to manage multiple Ollama Cloud accounts, rotating requests to the account with the most remaining usage, with a setup wizard in Settings to enter/exit multi-account mode.
+
+**Architecture:** Add an `AccountConfig` model (name + API key + session cookie + cached usage) and an `accounts` list to `AppConfig`. An `AccountManager` class holds per-account `OllamaClient` instances, tracks usage, and selects the best account per request. The router uses the manager instead of a single client. A setup wizard in the Settings tab handles entering/exiting multi-account mode.
+
+**Tech Stack:** Python/FastAPI backend, vanilla JS dashboard, SQLite analytics (account name logged per request)
+
+---
+
+## Current Architecture (what changes)
+
+```
+app.py → OllamaClient(single_key, single_cookie) → router(client, ...)
+ UsageConfig(session_cookie=one) → quota check on single %
+```
+
+## New Architecture
+
+```
+app.py → AccountManager(accounts=[AccountConfig,...]) → router(manager, ...)
+ Each AccountConfig has: name, api_key, session_cookie, usage cache
+ AccountManager.select_account() → picks lowest-usage account
+ Single-account mode: AccountManager with 1 account (backwards compat)
+```
+
+## Config YAML Shape
+
+Single-account (default, same as today):
+```yaml
+ollama_api_key: "key1"
+usage:
+ session_cookie: "cookie1"
+ ...
+```
+
+Multi-account mode:
+```yaml
+ollama_api_key: "key1" # kept for backward compat, becomes "primary" named account
+accounts:
+ - name: "primary"
+ api_key: "key1"
+ session_cookie: "cookie1"
+ last_session_pct: 21.8
+ last_weekly_pct: 64.2
+ last_session_reset: "4 hours"
+ last_weekly_reset: "1 hour"
+ last_plan: "pro"
+ last_checked: 1776639060.0
+ - name: "account2"
+ api_key: "key2"
+ session_cookie: "cookie2"
+ last_session_pct: null
+ last_weekly_pct: null
+ ...
+usage:
+ redirect_on_full: true
+ multi_account_enabled: true # NEW: toggles multi-account mode
+```
+
+When `multi_account_enabled = true`, the `accounts` list is the source of truth.
+When `false` (default), the single `ollama_api_key` + `usage.session_cookie` are used (exactly as today).
+
+## Analytics Changes
+
+- `request_log` gets a new `account_name TEXT` column
+- In History tab, provider shows as `ollama (primary)` or `ollama (account2-3243)`
+- Analytics consolidated across all accounts (no change to aggregation — just new column)
+
+## Router Changes
+
+- `create_router(client, ...)` becomes `create_router(account_manager, ...)`
+- Before each request: `account = manager.select_account()` → returns `(name, OllamaClient)`
+- The OllamaClient for the selected account is used for the actual request
+- `fallback_for` field now also used when account rotates (new reason: "Account rotated: primary quota full")
+- All `log_llm` calls include `account_name=account.name`
+
+## Dashboard Changes
+
+### Settings Tab — Multi-Account Setup
+- New section: "Ollama Accounts"
+- Shows current mode: "Single Account" or "Multi-Account"
+- "Enter Multi-Account Mode" button → wizard:
+ 1. Name your primary account (pre-filled "primary")
+ 2. Enter name for second account
+ 3. Enter API key for second account
+ 4. Both account session cookies can be set in Status tab
+- "Exit Multi-Account Mode" button → reverts to single account (keeps primary key)
+- Account list with remove buttons
+
+### Status Tab — Multi-Account Usage
+- When multi-account is enabled, shows usage bars for EACH account
+- Each account row: name, session/weekly %, progress bars, reset timers
+- "Check All Usage" button checks all accounts in parallel
+
+### History Tab
+- Provider column shows `ollama (account_name)` instead of just `ollama`
+- New "Account" filter dropdown
+
+---
+
+## Implementation Tasks
+
+### Phase 1: Data Model & Config
+
+#### Task 1: Add AccountConfig and multi_account_enabled to config.py
+
+**Objective:** Define the data models for multi-account support.
+
+**Files:**
+- Modify: `guanaco/config.py`
+
+**Step 1: Add AccountConfig model**
+
+Add after the existing `UsageConfig` class:
+
+```python
+class AccountConfig(BaseModel):
+ """A single Ollama Cloud account with its own API key, session cookie, and usage cache."""
+ name: str = "primary"
+ api_key: str = ""
+ session_cookie: str = ""
+ last_session_pct: Optional[float] = None
+ last_weekly_pct: Optional[float] = None
+ last_session_reset: Optional[str] = None
+ last_weekly_reset: Optional[str] = None
+ last_plan: Optional[str] = None
+ last_checked: Optional[float] = None
+```
+
+**Step 2: Add fields to AppConfig**
+
+Add to AppConfig:
+
+```python
+accounts: list[AccountConfig] = [] # Populated when multi_account_enabled=True
+multi_account_enabled: bool = False
+```
+
+**Step 3: Add helper property to AppConfig**
+
+```python
+@property
+def active_accounts(self) -> list[AccountConfig]:
+ """Return accounts list if multi-account enabled, else synthesize single account from legacy fields."""
+ if self.multi_account_enabled and self.accounts:
+ return self.accounts
+ # Single-account mode — synthesize from legacy fields
+ return [AccountConfig(
+ name="primary",
+ api_key=self.ollama_api_key_resolved,
+ session_cookie=self.usage.session_cookie if self.usage else "",
+ last_session_pct=self.usage.last_session_pct if self.usage else None,
+ last_weekly_pct=self.usage.last_weekly_pct if self.usage else None,
+ last_session_reset=self.usage.last_session_reset if self.usage else None,
+ last_weekly_reset=self.usage.last_weekly_reset if self.usage else None,
+ last_plan=self.usage.last_plan if self.usage else None,
+ last_checked=self.usage.last_checked if self.usage else None,
+ )]
+```
+
+**Step 4: Verify the config loads correctly**
+
+Run: `cd ~/projects/guanaco && source venv/bin/activate && python3 -c "from guanaco.config import load_config; c = load_config(); print('accounts:', c.accounts, 'enabled:', c.multi_account_enabled)"`
+
+Expected: `accounts: [] enabled: False`
+
+**Step 5: Commit**
+
+```bash
+git add guanaco/config.py
+git commit -m "feat: add AccountConfig model and multi_account_enabled to AppConfig"
+```
+
+---
+
+#### Task 2: Create AccountManager class
+
+**Objective:** Build the class that manages per-account OllamaClient instances and selects the best account for each request.
+
+**Files:**
+- Create: `guanaco/account_manager.py`
+
+**Step 1: Create the AccountManager**
+
+```python
+"""Multi-account manager for Ollama Cloud — rotates across accounts to maximize usage."""
+
+import logging
+from typing import Optional, Tuple
+
+from guanaco.client import OllamaClient
+from guanaco.config import AccountConfig, AppConfig
+
+log = logging.getLogger("guanaco.accounts")
+
+
+class AccountManager:
+ """Manages multiple Ollama Cloud accounts and selects the best one per request."""
+
+ def __init__(self, config: AppConfig):
+ self._config = config
+ self._clients: dict[str, OllamaClient] = {}
+ self._rebuild_clients()
+
+ def _rebuild_clients(self):
+ """Rebuild OllamaClient instances from config."""
+ self._clients.clear()
+ for acct in self._config.active_accounts:
+ if acct.api_key and acct.api_key not in ("***", "REPLACE_ME", "your_api_key_here"):
+ self._clients[acct.name] = OllamaClient(
+ api_key=acct.api_key,
+ session_cookie=acct.session_cookie,
+ )
+ log.debug("Account client built: %s", acct.name)
+
+ def refresh(self):
+ """Rebuild clients after config change (e.g., account added/removed)."""
+ self._rebuild_clients()
+
+ @property
+ def accounts(self) -> list[AccountConfig]:
+ return self._config.active_accounts
+
+ def get_client(self, account_name: Optional[str] = None) -> Tuple[str, OllamaClient]:
+ """Get the best OllamaClient for a request.
+
+ If account_name is specified, return that account's client.
+ Otherwise, select the account with the lowest usage.
+
+ Returns (account_name, OllamaClient).
+ Raises ValueError if no accounts available.
+ """
+ if account_name and account_name in self._clients:
+ return account_name, self._clients[account_name]
+
+ # Select account with lowest usage
+ best_name = None
+ best_score = float('inf')
+ for acct in self.accounts:
+ if acct.name not in self._clients:
+ continue
+ # Score = max(session%, weekly%). Lower is better. None = unchecked = 0 (best).
+ s = acct.last_session_pct if acct.last_session_pct is not None else 0
+ w = acct.last_weekly_pct if acct.last_weekly_pct is not None else 0
+ score = max(s, w)
+ if score < best_score:
+ best_score = score
+ best_name = acct.name
+
+ if best_name is None:
+ # Fallback: return first available client
+ if self._clients:
+ best_name = next(iter(self._clients))
+ else:
+ raise ValueError("No Ollama accounts configured")
+
+ return best_name, self._clients[best_name]
+
+ def get_all_clients(self) -> dict[str, OllamaClient]:
+ """Return all account clients (for usage checking etc)."""
+ return dict(self._clients)
+
+ def is_quota_full(self, account_name: Optional[str] = None) -> bool:
+ """Check if a specific account (or the selected account) is quota-full."""
+ if not self._config.usage.redirect_on_full:
+ return False
+ # Check the specific account or the best account
+ target = account_name or self.get_client()[0]
+ for acct in self.accounts:
+ if acct.name == target:
+ s = acct.last_session_pct
+ w = acct.last_weekly_pct
+ if s is not None and s >= 99.5:
+ return True
+ if w is not None and w >= 99.5:
+ return True
+ return False
+ return False
+
+ def any_account_available(self) -> bool:
+ """Check if at least one account is not quota-full."""
+ if not self._config.usage.redirect_on_full:
+ return True
+ for acct in self.accounts:
+ s = acct.last_session_pct if acct.last_session_pct is not None else 0
+ w = acct.last_weekly_pct if acct.last_weekly_pct is not None else 0
+ if s < 99.5 and w < 99.5:
+ return True
+ return False
+
+ def update_account_usage(self, account_name: str, session_pct: Optional[float],
+ weekly_pct: Optional[float], session_reset: Optional[str],
+ weekly_reset: Optional[str], plan: Optional[str]):
+ """Update cached usage for an account and persist to config."""
+ for acct in self._config.accounts:
+ if acct.name == account_name:
+ acct.last_session_pct = session_pct
+ acct.last_weekly_pct = weekly_pct
+ acct.last_session_reset = session_reset
+ acct.last_weekly_reset = weekly_reset
+ acct.last_plan = plan
+ import time
+ acct.last_checked = time.time()
+ break
+ # Persist
+ try:
+ from guanaco.config import save_config
+ save_config(self._config)
+ except Exception as e:
+ log.warning("Failed to persist account usage: %s", e)
+
+ def update_session_cookie(self, account_name: str, cookie: str):
+ """Update session cookie for an account."""
+ for acct in self._config.accounts:
+ if acct.name == account_name:
+ acct.session_cookie = cookie
+ break
+ if account_name in self._clients:
+ self._clients[account_name]._session_cookie = cookie
+ try:
+ from guanaco.config import save_config
+ save_config(self._config)
+ except Exception as e:
+ log.warning("Failed to persist session cookie: %s", e)
+```
+
+**Step 2: Test it loads**
+
+Run: `cd ~/projects/guanaco && source venv/bin/activate && python3 -c "from guanaco.account_manager import AccountManager; print('import OK')"`
+
+Expected: `import OK`
+
+**Step 3: Commit**
+
+```bash
+git add guanaco/account_manager.py
+git commit -m "feat: add AccountManager for multi-account rotation"
+```
+
+---
+
+### Phase 2: Analytics Update
+
+#### Task 3: Add account_name column to request_log
+
+**Objective:** Track which account handled each request in analytics.
+
+**Files:**
+- Modify: `guanaco/analytics.py`
+
+**Step 1: Add migration in `_ensure_tables`**
+
+After the `fallback_reason` migration block, add:
+
+```python
+# Migration: add account_name column
+try:
+ conn.execute("ALTER TABLE request_log ADD COLUMN account_name TEXT")
+except sqlite3.OperationalError:
+ pass
+```
+
+**Step 2: Add account_name to log_llm signature and INSERT**
+
+Add `account_name: Optional[str] = None` parameter to `log_llm`.
+
+Add `account_name` to the INSERT column list and VALUES tuple.
+
+**Step 3: Verify migration**
+
+Run: `cd ~/projects/guanaco && source venv/bin/activate && python3 -c "import sqlite3; conn = sqlite3.connect('/home/evan/.guanaco/analytics.db'); cur = conn.cursor(); cur.execute('PRAGMA table_info(request_log)'); cols = [r[1] for r in cur.fetchall()]; print('account_name' in cols)"`
+
+Expected: `True`
+
+**Step 4: Commit**
+
+```bash
+git add guanaco/analytics.py
+git commit -m "feat: add account_name column to request_log for multi-account tracking"
+```
+
+---
+
+### Phase 3: Router Integration
+
+#### Task 4: Switch router from single client to AccountManager
+
+**Objective:** The router uses AccountManager to select the best account per request instead of a single OllamaClient.
+
+**Files:**
+- Modify: `guanaco/router/router.py`
+- Modify: `guanaco/app.py`
+
+This is the biggest task. Key changes:
+
+**Step 1: Update app.py create_app()**
+
+Replace:
+```python
+client = OllamaClient(api_key=resolved_key, session_cookie=config.usage.session_cookie)
+```
+
+With:
+```python
+from guanaco.account_manager import AccountManager
+account_manager = AccountManager(config)
+```
+
+Pass `account_manager` instead of `client` to `create_router` and dashboard.
+
+**Step 2: Update create_router signature**
+
+Change from `create_router(client, ...)` to `create_router(account_manager, ...)`.
+
+Replace `_client = client` with `_manager = account_manager`.
+
+**Step 3: Update each request handler**
+
+In `chat_completions` and the Anthropic endpoint, at the top:
+
+```python
+acct_name, _client = _manager.get_client()
+```
+
+This replaces the single `_client` closure. The rest of the request logic uses `_client` the same way.
+
+**Step 4: Add account_name to all log_llm calls**
+
+Every `_analytics.log_llm(...)` call needs `account_name=acct_name`.
+
+**Step 5: Update _is_quota_full**
+
+Replace:
+```python
+def _is_quota_full(config) -> bool:
+```
+
+With logic that uses `_manager.any_account_available()`. If any account is available, return False. If ALL accounts are full, return True (trigger fallback).
+
+Also, when an account is full but others aren't, the manager auto-selects a different account — no fallback needed. Fallback only triggers when ALL accounts are full.
+
+**Step 6: Update quota redirect section**
+
+The quota-full check at line ~478 should:
+
+1. Get the best account from the manager
+2. If that account is the same as before, use it
+3. If the selected account changed (rotation), use the new account's client
+4. Only fall back to the external fallback if ALL accounts are full
+
+Replace the current quota redirect block with:
+
+```python
+if _is_quota_full(_config):
+ # All accounts full — go to external fallback
+ ...
+else:
+ # Use manager-selected account (may have rotated)
+ acct_name, _client = _manager.get_client()
+```
+
+**Step 7: Verify the test instance starts**
+
+Run: `kill $(lsof -t -i:8888) 2>/dev/null; sleep 1; cd ~/projects/guanaco && source venv/bin/activate && GUANACO_ROUTER_PORT=8888 python -m uvicorn guanaco.app:create_app --factory --host 0.0.0.0 --port 8888 &`
+
+Then: `curl -s http://localhost:8888/health`
+
+Expected: `{"status": "ok", ...}`
+
+**Step 8: Commit**
+
+```bash
+git add guanaco/router/router.py guanaco/app.py
+git commit -m "feat: router uses AccountManager for multi-account request routing"
+```
+
+---
+
+#### Task 5: Update dashboard.py to use AccountManager
+
+**Objective:** Dashboard API endpoints (usage checking, session cookies, config) work with multi-account.
+
+**Files:**
+- Modify: `guanaco/dashboard/dashboard.py`
+
+Key changes:
+
+**Step 1: Accept account_manager parameter**
+
+Update `create_dashboard()` to accept `account_manager` instead of (or in addition to) `client`.
+
+**Step 2: Add multi-account API endpoints**
+
+```python
+@router.get("/api/accounts")
+async def list_accounts(request: Request):
+ """List all configured accounts and their usage."""
+ accounts = account_manager.accounts
+ return {"accounts": [a.model_dump() for a in accounts], "multi_account_enabled": config.multi_account_enabled}
+
+@router.post("/api/accounts")
+async def add_account(request: Request):
+ """Add a new account in multi-account mode."""
+ body = await request.json()
+ name = body.get("name", "")
+ api_key = body.get("api_key", "")
+ if not name or not api_key:
+ return {"error": "Name and API key required"}
+ # Check duplicate names
+ for a in config.accounts:
+ if a.name == name:
+ return {"error": f"Account '{name}' already exists"}
+ config.accounts.append(AccountConfig(name=name, api_key=api_key))
+ config.multi_account_enabled = True
+ save_config(config)
+ account_manager.refresh()
+ return {"ok": True}
+
+@router.delete("/api/accounts/{name}")
+async def remove_account(name: str, request: Request):
+ """Remove an account. If last one, disable multi-account mode."""
+ config.accounts = [a for a in config.accounts if a.name != name]
+ if len(config.accounts) <= 1:
+ config.multi_account_enabled = False
+ if config.accounts:
+ # Move back to single key
+ config.ollama_api_key = config.accounts[0].api_key
+ config.usage.session_cookie = config.accounts[0].session_cookie
+ config.accounts = []
+ save_config(config)
+ account_manager.refresh()
+ return {"ok": True}
+
+@router.post("/api/accounts/enable-multi")
+async def enable_multi_account(request: Request):
+ """Enter multi-account mode. Migrates single key to named account."""
+ body = await request.json()
+ primary_name = body.get("primary_name", "primary")
+ config.accounts = [
+ AccountConfig(
+ name=primary_name,
+ api_key=config.ollama_api_key_resolved,
+ session_cookie=config.usage.session_cookie if config.usage else "",
+ last_session_pct=config.usage.last_session_pct if config.usage else None,
+ last_weekly_pct=config.usage.last_weekly_pct if config.usage else None,
+ last_session_reset=config.usage.last_session_reset if config.usage else None,
+ last_weekly_reset=config.usage.last_weekly_reset if config.usage else None,
+ last_plan=config.usage.last_plan if config.usage else None,
+ last_checked=config.usage.last_checked if config.usage else None,
+ )
+ ]
+ config.multi_account_enabled = True
+ save_config(config)
+ account_manager.refresh()
+ return {"ok": True, "accounts": [a.model_dump() for a in config.accounts]}
+
+@router.post("/api/accounts/disable-multi")
+async def disable_multi_account(request: Request):
+ """Exit multi-account mode. Reverts to single key from first account."""
+ if config.accounts:
+ config.ollama_api_key = config.accounts[0].api_key
+ config.usage.session_cookie = config.accounts[0].session_cookie
+ config.multi_account_enabled = False
+ config.accounts = []
+ save_config(config)
+ account_manager.refresh()
+ return {"ok": True}
+```
+
+**Step 3: Update usage check endpoint**
+
+`POST /dashboard/api/usage/check` should check ALL accounts' usage when multi-account is enabled:
+
+```python
+@router.post("/api/usage/check")
+async def check_usage(request: Request):
+ results = []
+ for acct in account_manager.accounts:
+ client = account_manager.get_all_clients().get(acct.name)
+ if not client:
+ results.append({"name": acct.name, "error": "No client"})
+ continue
+ cookie = acct.session_cookie
+ if not cookie:
+ results.append({"name": acct.name, "error": "No session cookie set"})
+ continue
+ try:
+ usage = await client.get_usage(session_cookie=cookie)
+ # Update account cache
+ account_manager.update_account_usage(
+ acct.name,
+ session_pct=usage.get("session_pct"),
+ weekly_pct=usage.get("weekly_pct"),
+ session_reset=usage.get("session_reset"),
+ weekly_reset=usage.get("weekly_reset"),
+ plan=usage.get("plan"),
+ )
+ results.append({"name": acct.name, **usage})
+ except Exception as e:
+ results.append({"name": acct.name, "error": str(e)})
+ # For backward compat, if single account, return flat response
+ if len(results) == 1:
+ return results[0]
+ return {"accounts": results}
+```
+
+**Step 4: Update session cookie endpoint**
+
+`POST /dashboard/api/usage/session-cookie` needs an `account_name` field:
+
+```python
+@router.post("/api/usage/session-cookie")
+async def set_session_cookie(request: Request):
+ body = await request.json()
+ cookie = body.get("cookie", "")
+ account_name = body.get("account_name", "primary")
+ if config.multi_account_enabled:
+ account_manager.update_session_cookie(account_name, cookie)
+ else:
+ config.usage.session_cookie = cookie
+ # Also update the live client if accessible
+ for client in account_manager.get_all_clients().values():
+ client._session_cookie = cookie
+ save_config(config)
+ return {"ok": True}
+```
+
+**Step 5: Commit**
+
+```bash
+git add guanaco/dashboard/dashboard.py
+git commit -m "feat: dashboard multi-account API endpoints (list, add, remove, enable/disable)"
+```
+
+---
+
+### Phase 4: Dashboard UI
+
+#### Task 6: Settings tab — Multi-account setup UI
+
+**Objective:** Add the setup wizard and account management UI to Settings.
+
+**Files:**
+- Modify: `guanaco/dashboard/templates/dashboard.html`
+
+**Step 1: Add "Ollama Accounts" section to Settings tab**
+
+In the Settings tab, add a new card section for account management. Contains:
+- Current mode badge: "Single Account" or "Multi-Account (N accounts)"
+- Account list (when multi-account enabled): name, API key (masked), remove button
+- "Enter Multi-Account Mode" button (when single) → opens wizard modal
+- "Add Account" button (when multi) → opens add modal
+- "Exit Multi-Account Mode" button (when multi) → confirms and reverts
+
+**Step 2: Add wizard modal**
+
+The wizard has steps:
+1. "Name your primary account" — input with "primary" pre-filled
+2. "Add second account" — name + API key inputs
+3. "Setup complete" — shows both accounts, notes that session cookies go in Status tab
+
+**Step 3: JS functions**
+
+```javascript
+function loadAccountConfig() {
+ fetch('/dashboard/api/accounts').then(r => r.json()).then(data => {
+ // Render account list and mode badge
+ });
+}
+
+function enterMultiAccountMode() {
+ // Show wizard modal
+}
+
+function addAccount() {
+ // Show add-account modal
+}
+
+function removeAccount(name) {
+ // Confirm, then DELETE /dashboard/api/accounts/{name}
+}
+
+function exitMultiAccountMode() {
+ // Confirm, then POST /dashboard/api/accounts/disable-multi
+}
+```
+
+**Step 4: Hook into showTab()**
+
+When Settings tab is shown, call `loadAccountConfig()`.
+
+**Step 5: Commit**
+
+```bash
+git add guanaco/dashboard/templates/dashboard.html
+git commit -m "feat: Settings tab multi-account setup wizard UI"
+```
+
+---
+
+#### Task 7: Status tab — Multi-account usage display
+
+**Objective:** Show per-account usage bars when multi-account is enabled.
+
+**Files:**
+- Modify: `guanaco/dashboard/templates/dashboard.html`
+
+**Step 1: Update usage check JS**
+
+When multi-account is enabled, `checkUsage()` should handle the `accounts` array response and render a row per account.
+
+Each row: account name, session % bar, weekly % bar, reset timers, "Check" button.
+
+**Step 2: Update session cookie section**
+
+When multi-account, show a dropdown to select which account's cookie to set, plus the cookie input and save button.
+
+**Step 3: Commit**
+
+```bash
+git add guanaco/dashboard/templates/dashboard.html
+git commit -m "feat: Status tab multi-account usage display with per-account bars"
+```
+
+---
+
+#### Task 8: History tab — Account name in provider column
+
+**Objective:** Show which account handled each request in the History list and modal.
+
+**Files:**
+- Modify: `guanaco/dashboard/templates/dashboard.html`
+
+**Step 1: Update list rendering**
+
+Change the provider display from `🏭 ${r.provider || 'ollama'}` to:
+
+```javascript
+let providerDisplay = r.provider || 'ollama';
+if (providerDisplay === 'ollama' && r.account_name) {
+ providerDisplay = `ollama (${escapeHtml(r.account_name)})`;
+}
+```
+
+**Step 2: Update modal**
+
+In the detail modal's metadata grid, add:
+
+```html
+
Account: ${data.account_name || '—'}
+```
+
+**Step 3: Add account filter dropdown**
+
+Add a new filter dropdown in the History tab header for filtering by account name.
+
+**Step 4: Commit**
+
+```bash
+git add guanaco/dashboard/templates/dashboard.html
+git commit -m "feat: History tab shows account name in provider column, account filter"
+```
+
+---
+
+### Phase 5: Config Persistence
+
+#### Task 9: Add save_config function and ensure accounts persist
+
+**Objective:** Ensure the `accounts` list and `multi_account_enabled` field survive config.yaml round-trips.
+
+**Files:**
+- Modify: `guanaco/config.py`
+
+**Step 1: Verify save_config exists**
+
+Check that `save_config` writes all Pydantic model fields including `accounts` and `multi_account_enabled` to `config.yaml`. If it doesn't exist, add it.
+
+**Step 2: Test round-trip**
+
+```python
+from guanaco.config import load_config, save_config
+c = load_config()
+c.multi_account_enabled = True
+c.accounts = [AccountConfig(name="test", api_key="key123")]
+save_config(c)
+c2 = load_config()
+assert c2.multi_account_enabled == True
+assert len(c2.accounts) == 1
+assert c2.accounts[0].name == "test"
+```
+
+**Step 3: Commit**
+
+```bash
+git add guanaco/config.py
+git commit -m "feat: config persistence for accounts and multi_account_enabled"
+```
+
+---
+
+### Phase 6: Testing & Polish
+
+#### Task 10: End-to-end test on port 8888
+
+**Objective:** Verify the full multi-account flow works.
+
+**Step 1: Start test instance**
+
+```bash
+cd ~/projects/guanaco && source venv/bin/activate && GUANACO_ROUTER_PORT=8888 python -m uvicorn guanaco.app:create_app --factory --host 0.0.0.0 --port 8888 &
+```
+
+**Step 2: Test single-account mode (default)**
+
+- `curl http://localhost:8888/dashboard/api/accounts` → `{"accounts": [...], "multi_account_enabled": false}`
+- Send a request, verify it works
+- Check analytics: `account_name` should be "primary"
+
+**Step 3: Test entering multi-account mode**
+
+- `curl -X POST http://localhost:8888/dashboard/api/accounts/enable-multi -H 'Content-Type: application/json' -d '{"primary_name":"main"}'`
+- Verify accounts list now has one account named "main"
+
+**Step 4: Test adding second account**
+
+- `curl -X POST http://localhost:8888/dashboard/api/accounts -H 'Content-Type: application/json' -d '{"name":"backup","api_key":"test-key"}'`
+- Verify accounts list has two entries
+
+**Step 5: Test account rotation**
+
+- Send multiple requests, verify the account with lower usage is selected
+- Check analytics for `account_name` values
+
+**Step 6: Test removing account**
+
+- `curl -X DELETE http://localhost:8888/dashboard/api/accounts/backup`
+- Verify single account remains
+
+**Step 7: Test exiting multi-account mode**
+
+- `curl -X POST http://localhost:8888/dashboard/api/accounts/disable-multi`
+- Verify `multi_account_enabled = false` and `accounts = []`
+
+**Step 8: Commit**
+
+```bash
+git commit -m "test: end-to-end multi-account verification"
+```
+
+---
+
+## Key Design Decisions
+
+1. **Backward compatible**: Single-account mode (default) works exactly as today. `multi_account_enabled=false` means `accounts` list is ignored, legacy `ollama_api_key` + `usage.session_cookie` are used.
+
+2. **AccountManager abstracts the client**: The router doesn't need to know about multi-account. It calls `manager.get_client()` and gets back a `(name, OllamaClient)` pair. The manager handles selection.
+
+3. **Usage-based rotation**: The manager picks the account with the lowest `max(session%, weekly%)`. Unchecked accounts (None values) score 0, so they get tried first (then their usage gets populated).
+
+4. **Analytics include account_name**: New column, shown in History as `ollama (account_name)`. Consolidated stats work the same — you can filter by account.
+
+5. **Fallback still works**: If ALL accounts are quota-full, the external fallback triggers. Individual account rotation happens first.
+
+6. **Session cookies per account**: Each account has its own cookie, set via the Status tab. The wizard tells the user to set cookies there after setup.
+
+7. **Wizard flow**: "Enter Multi-Account Mode" → name primary → name + key for second account → done. User can add more later. "Exit" reverts to single key from first account.
+
+## Files Changed Summary
+
+| File | Change |
+|---|---|
+| `guanaco/config.py` | Add `AccountConfig`, `multi_account_enabled`, `active_accounts` property, ensure `save_config` works |
+| `guanaco/account_manager.py` | NEW — AccountManager class |
+| `guanaco/analytics.py` | Add `account_name` column migration + log_llm param |
+| `guanaco/router/router.py` | Use AccountManager instead of single client, pass account_name to log_llm |
+| `guanaco/app.py` | Create AccountManager, pass it to router + dashboard |
+| `guanaco/dashboard/dashboard.py` | Multi-account API endpoints, usage check per account |
+| `guanaco/dashboard/templates/dashboard.html` | Settings wizard, Status per-account bars, History account column |
\ No newline at end of file
diff --git a/guanaco/__init__.py b/guanaco/__init__.py
index b885581..c7e78f9 100644
--- a/guanaco/__init__.py
+++ b/guanaco/__init__.py
@@ -1,7 +1,21 @@
"""guanaco — maximize your Ollama Cloud subscription."""
+# Single source of truth for version.
+# importlib.metadata can return stale values after git-pull without re-pip-install,
+# so we always use the hardcoded fallback and only override if metadata matches.
+__version__ = "0.5.1"
+
try:
from importlib.metadata import version as _version
- __version__ = _version("guanaco")
+ _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:
+ _hardcoded = tuple(int(x) for x in __version__.split("."))
+ if tuple(int(x) for x in _m.groups()) >= _hardcoded:
+ __version__ = _pkg_ver
except Exception:
- __version__ = "0.3.6" # fallback when not installed via pip
\ No newline at end of file
+ pass
\ No newline at end of file
diff --git a/guanaco/accounts.py b/guanaco/accounts.py
new file mode 100644
index 0000000..f909ab1
--- /dev/null
+++ b/guanaco/accounts.py
@@ -0,0 +1,146 @@
+"""Multi-account Ollama key rotation with quota-aware selection."""
+
+import logging
+import time
+from typing import Optional
+
+from guanaco.config import OllamaAccount
+
+logger = logging.getLogger(__name__)
+
+# Models that require a paid Ollama plan (not available on free tier).
+PREMIUM_MODELS = {"kimi-k2.6", "glm-5.1"}
+
+
+def model_requires_premium(model: str) -> bool:
+ """Check if a model requires a paid Ollama plan (pro/max).
+
+ Matches case-insensitively against model name substrings.
+ E.g. 'kimi-k2.6-0915' matches 'k2.6'.
+ """
+ model_lower = model.lower().strip()
+ for pm in PREMIUM_MODELS:
+ if pm in model_lower:
+ return True
+ return False
+
+
+class AccountPool:
+ """Manages a pool of Ollama accounts and selects the best one for each request.
+
+ Selection strategy:
+ 1. Among accounts with usage data, pick the one with the lowest session_pct
+ 2. Among accounts without usage data, round-robin
+ 3. If only one account, always use it
+ """
+
+ def __init__(self, accounts: list[OllamaAccount]):
+ self._accounts: list[OllamaAccount] = accounts
+ self._rr_index: int = 0
+
+ @property
+ def accounts(self) -> list[OllamaAccount]:
+ return self._accounts
+
+ def update_accounts(self, accounts: list[OllamaAccount]) -> None:
+ """Replace the account list (e.g., after config save)."""
+ self._accounts = accounts
+
+ def get_account(self, preferred: Optional[str] = None, model: Optional[str] = None) -> OllamaAccount:
+ """Select the best account for the next request.
+
+ Strategy: prefer accounts with the most available quota.
+ - If model is premium, skip free-tier accounts.
+ - Accounts with no usage data are assumed fresh (0%) — preferred.
+ - Among accounts with usage data, pick lowest session_pct.
+ - Tie-break with round-robin for equal-priority accounts.
+
+ Args:
+ preferred: If set, try this account name first.
+ model: If set, check if model requires premium and filter accordingly.
+
+ Returns:
+ The selected OllamaAccount.
+ """
+ active = [a for a in self._accounts if a.api_key and a.api_key not in ("***", "REPLACE_ME")]
+ if not active:
+ return self._accounts[0] if self._accounts else OllamaAccount(name="ollama")
+
+ # If model requires premium, filter out free-tier accounts
+ premium_needed = model and model_requires_premium(model)
+ if premium_needed:
+ eligible = [a for a in active if a.last_plan and a.last_plan.lower() != "free"]
+ if eligible:
+ active = eligible
+ logger.info(f"Model '{model}' requires premium plan, filtered to {len(eligible)} eligible accounts")
+ else:
+ logger.warning(f"Model '{model}' requires premium but no paid accounts available, trying all")
+
+ if len(active) == 1:
+ return active[0]
+
+ if preferred:
+ for a in active:
+ if a.name == preferred:
+ return a
+
+ # Split by whether we have usage data
+ without_usage = [a for a in active if a.last_session_pct is None]
+ with_usage = [a for a in active if a.last_session_pct is not None]
+
+ # Prefer accounts with no usage data (fresh/unknown quota) over known-heavy ones
+ if without_usage:
+ idx = self._rr_index % len(without_usage)
+ self._rr_index += 1
+ account = without_usage[idx]
+ logger.debug(f"Selected account '{account.name}' (no usage data, round-robin)")
+ return account
+
+ # All have usage data — pick the one with lowest usage
+ best = min(with_usage, key=lambda a: a.last_session_pct or 100)
+ logger.debug(f"Selected account '{best.name}' (session: {best.last_session_pct}%)")
+ return best
+
+ def update_usage(self, account_name: str, session_pct: Optional[float],
+ weekly_pct: Optional[float], plan: Optional[str] = None,
+ session_reset: Optional[str] = None, weekly_reset: Optional[str] = None) -> None:
+ """Update usage data for a specific account."""
+ for a in self._accounts:
+ if a.name == account_name:
+ a.last_session_pct = session_pct
+ a.last_weekly_pct = weekly_pct
+ a.last_plan = plan
+ a.last_session_reset = session_reset
+ a.last_weekly_reset = weekly_reset
+ a.last_checked = time.time()
+ break
+
+ def mark_429(self, account_name: str) -> None:
+ """Mark that an account hit a 429. Temporarily deprioritize it."""
+ for a in self._accounts:
+ if a.name == account_name:
+ if a.last_session_pct is not None:
+ a.last_session_pct = min(a.last_session_pct + 25, 100)
+ else:
+ a.last_session_pct = 75
+ logger.info(f"Account '{account_name}' hit 429, deprioritizing (session est: {a.last_session_pct}%)")
+ break
+
+ def account_names(self) -> list[str]:
+ """List all account names."""
+ return [a.name for a in self._accounts]
+
+ def get_by_name(self, name: str) -> Optional[OllamaAccount]:
+ """Find account by name."""
+ for a in self._accounts:
+ if a.name == name:
+ return a
+ return None
+
+ def name_taken(self, name: str) -> bool:
+ """Check if an account name is already used."""
+ return any(a.name == name for a in self._accounts)
+
+ def is_reserved_name(self, name: str) -> bool:
+ """Check if a name is reserved (case-insensitive)."""
+ return name.lower() in ("ollama", "primary", "default")
\ No newline at end of file
diff --git a/guanaco/analytics.py b/guanaco/analytics.py
index 1e97bcf..b57f8c5 100644
--- a/guanaco/analytics.py
+++ b/guanaco/analytics.py
@@ -15,6 +15,11 @@ import uuid
from pathlib import Path
from typing import Optional
+try:
+ from skimtoken.multilingual_simple import estimate_tokens as _estimate_tokens
+except Exception:
+ _estimate_tokens = None
+
def _default_db_path() -> Path:
from guanaco.config import get_default_config_dir
@@ -72,6 +77,22 @@ class AnalyticsLogger:
conn.execute("ALTER TABLE request_log ADD COLUMN fallback_for TEXT")
except sqlite3.OperationalError:
pass # column already exists
+ # Migration: add caller info and content columns for full history
+ for col in ["source_ip TEXT", "source_port INTEGER", "user_agent TEXT", "input_text TEXT", "output_text TEXT"]:
+ try:
+ conn.execute(f"ALTER TABLE request_log ADD COLUMN {col}")
+ except sqlite3.OperationalError:
+ pass # column already exists
+ # Migration: add fallback_reason column
+ try:
+ conn.execute("ALTER TABLE request_log ADD COLUMN fallback_reason TEXT")
+ except sqlite3.OperationalError:
+ pass
+ # Migration: add account_name column for multi-account rotation
+ try:
+ conn.execute("ALTER TABLE request_log ADD COLUMN account_name TEXT")
+ except sqlite3.OperationalError:
+ pass
conn.execute("""
CREATE TABLE IF NOT EXISTS status_events (
id TEXT PRIMARY KEY,
@@ -130,25 +151,101 @@ class AnalyticsLogger:
provider: Optional[str] = None,
fallback_for: Optional[str] = None,
extra: Optional[dict] = None,
+ # Full history fields (optional, requires opt-in)
+ source_ip: Optional[str] = None,
+ source_port: Optional[int] = None,
+ user_agent: Optional[str] = None,
+ input_text: Optional[str] = None,
+ output_text: Optional[str] = None,
+ fallback_reason: Optional[str] = None,
+ account_name: Optional[str] = None,
) -> str:
"""Log an LLM request. Returns the log entry ID."""
# Normalize model name so glm-5.1:cloud and glm-5.1 are grouped together
model = _normalize_model_name(model)
fallback_for = _normalize_model_name(fallback_for) if fallback_for else fallback_for
+
+ # 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(
"""INSERT INTO request_log
(id, ts, type, model, prompt_tokens, completion_tokens, total_tokens,
tps, prompt_tps, ttft_seconds, total_duration_seconds,
- load_duration_seconds, error, request_id, provider, fallback_for)
- VALUES (?, ?, 'llm', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ load_duration_seconds, error, request_id, provider, fallback_for,
+ source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name)
+ VALUES (?, ?, 'llm', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(entry_id, time.time(), model, prompt_tokens, completion_tokens,
total_tokens, tps, prompt_tps, ttft_seconds, total_duration_seconds,
- load_duration_seconds, error, request_id, provider, fallback_for),
+ load_duration_seconds, error, request_id, provider, fallback_for,
+ source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name),
)
+
+ # Write plaintext log file if configured
+ if input_text or output_text:
+ try:
+ from guanaco.config import get_config
+ _cfg = get_config()
+ if _cfg.history.log_to_files:
+ self._write_log_file(entry_id, model, provider, source_ip, input_text, output_text, error, _cfg.history)
+ except Exception:
+ pass # Don't break the request if log file writing fails
+
return entry_id
+ def _write_log_file(self, entry_id: str, model: str, provider: Optional[str],
+ source_ip: Optional[str], input_text: Optional[str],
+ output_text: Optional[str], error: Optional[str],
+ history_config=None):
+ """Write a plaintext log file for this request."""
+ try:
+ log_dir = history_config.get_log_dir() if history_config else None
+ if not log_dir:
+ return
+ ts = time.strftime("%Y%m%d_%H%M%S", time.localtime())
+ # One file per request: __.log
+ safe_model = model.replace("/", "_").replace(":", "_").replace(" ", "_")
+ filename = f"{ts}_{safe_model}_{entry_id[:8]}.log"
+ filepath = log_dir / filename
+
+ lines = []
+ lines.append(f"=== Guanaco Request Log ===")
+ lines.append(f"ID: {entry_id}")
+ lines.append(f"Time: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime())}")
+ lines.append(f"Model: {model}")
+ lines.append(f"Provider: {provider or 'ollama'}")
+ lines.append(f"Caller: {source_ip or 'unknown'}")
+ if error:
+ lines.append(f"Error: {error}")
+ lines.append(f"")
+ if input_text:
+ lines.append(f"--- INPUT ---")
+ lines.append(input_text)
+ lines.append(f"")
+ if output_text:
+ lines.append(f"--- OUTPUT ---")
+ lines.append(output_text)
+ lines.append(f"")
+ lines.append(f"=== END ===")
+
+ filepath.write_text("\n".join(lines), encoding="utf-8")
+ except Exception:
+ pass # Don't break the request if log file writing fails
+
def log_search(
self,
provider: str,
@@ -413,6 +510,19 @@ class AnalyticsLogger:
fallbacks.append({
"original_model": row[0], "fallback_count": row[1], "last_used": row[2],
})
+
+ # Fallback rate (24h window) - percentage of requests routed to fallback
+ cutoff_24h = time.time() - (24 * 3600)
+ fb_24h = conn.execute(
+ "SELECT COUNT(*) FROM request_log WHERE type='llm' AND fallback_for IS NOT NULL AND ts > ?",
+ (cutoff_24h,)
+ ).fetchone()[0]
+ main_24h = conn.execute(
+ "SELECT COUNT(*) FROM request_log WHERE type='llm' AND (provider='ollama' OR provider IS NULL) AND fallback_for IS NULL AND ts > ?",
+ (cutoff_24h,)
+ ).fetchone()[0]
+ total_24h = fb_24h + main_24h
+ fallback_rate = round((fb_24h / total_24h) * 100, 1) if total_24h > 0 else 0.0
# Recent errors
error_rows = conn.execute(
@@ -457,6 +567,7 @@ class AnalyticsLogger:
"recent_errors": recent_errors,
"status_errors": status_error_count,
"status_warnings": status_warning_count,
+ "fallback_rate": fallback_rate,
"usage": {
"session_pct": usage_row[0] if usage_row else None,
"weekly_pct": usage_row[1] if usage_row else None,
@@ -502,9 +613,165 @@ class AnalyticsLogger:
).fetchall()
return [dict(r) for r in rows]
+ def get_fallback_rate(self, hours: int = 24) -> dict:
+ """Calculate fallback routing rate for the specified time window.
+
+ Returns the percentage of requests that were routed to fallback provider
+ due to main provider failures (timeout, error, quota full).
+ """
+ cutoff = time.time() - (hours * 3600)
+
+ with sqlite3.connect(self.db_path) as conn:
+ fallback_count = conn.execute(
+ "SELECT COUNT(*) FROM request_log WHERE type='llm' AND fallback_for IS NOT NULL AND ts > ?",
+ (cutoff,)
+ ).fetchone()[0]
+ main_count = conn.execute(
+ "SELECT COUNT(*) FROM request_log WHERE type='llm' AND (provider='ollama' OR provider IS NULL) AND fallback_for IS NULL AND ts > ?",
+ (cutoff,)
+ ).fetchone()[0]
+ total = fallback_count + main_count
+ rate = round((fallback_count / total) * 100, 1) if total > 0 else 0.0
+ return {
+ "rate": rate,
+ "fallback_count": fallback_count,
+ "main_count": main_count,
+ "total": total,
+ "hours": hours,
+ }
+
+
+ def get_history(
+ self,
+ limit: int = 100,
+ offset: int = 0,
+ model_filter: Optional[str] = None,
+ provider_filter: Optional[str] = None,
+ has_content: Optional[bool] = None,
+ errors_only: bool = False,
+ include_content: bool = False,
+ ) -> list[dict]:
+ """Get paginated request history with optional filters.
+
+ Args:
+ limit: Max results to return
+ offset: Skip this many results (pagination)
+ model_filter: Filter by model name
+ provider_filter: Filter by provider
+ has_content: Filter to only requests with/without saved content
+ errors_only: Filter to only failed requests (error IS NOT NULL)
+ include_content: Include input_text/output_text in results
+ """
+ query = "SELECT * FROM request_log WHERE type='llm'"
+ params = []
+
+ if model_filter:
+ query += " AND model = ?"
+ params.append(model_filter)
+ if provider_filter:
+ query += " AND provider = ?"
+ params.append(provider_filter)
+ if errors_only:
+ query += " AND error IS NOT NULL AND error != ''"
+ elif has_content is True:
+ query += " AND input_text IS NOT NULL"
+ elif has_content is False:
+ query += " AND input_text IS NULL"
+
+ query += " ORDER BY ts DESC LIMIT ? OFFSET ?"
+ params.extend([limit, offset])
+
+ with sqlite3.connect(self.db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ rows = conn.execute(query, params).fetchall()
+ results = []
+ for row in rows:
+ d = dict(row)
+ # Add has_content flag for badge rendering without needing full text
+ has_input = bool(d.get("input_text"))
+ has_output = bool(d.get("output_text"))
+ d["has_content"] = has_input or has_output
+ # Don't include content unless requested (can be large)
+ if not include_content:
+ d.pop("input_text", None)
+ d.pop("output_text", None)
+ # Format timestamp
+ d["ts_formatted"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(d["ts"]))
+ results.append(d)
+ return results
+
+ def get_request_detail(self, request_id: str) -> Optional[dict]:
+ """Get full details of a single request including content."""
+ with sqlite3.connect(self.db_path) as conn:
+ conn.row_factory = sqlite3.Row
+ row = conn.execute(
+ "SELECT * FROM request_log WHERE id = ?",
+ (request_id,)
+ ).fetchone()
+ if row:
+ d = dict(row)
+ d["ts_formatted"] = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(d["ts"]))
+ return d
+ return None
+
+ def get_history_stats(self) -> dict:
+ """Get stats about history logging."""
+ with sqlite3.connect(self.db_path) as conn:
+ total = conn.execute("SELECT COUNT(*) FROM request_log WHERE type='llm'").fetchone()[0]
+ with_content = conn.execute(
+ "SELECT COUNT(*) FROM request_log WHERE type='llm' AND input_text IS NOT NULL"
+ ).fetchone()[0]
+ oldest = conn.execute(
+ "SELECT MIN(ts) FROM request_log WHERE type='llm'"
+ ).fetchone()[0]
+ newest = conn.execute(
+ "SELECT MAX(ts) FROM request_log WHERE type='llm'"
+ ).fetchone()[0]
+
+ # Storage size estimate
+ content_size = conn.execute(
+ "SELECT COALESCE(SUM(LENGTH(input_text) + LENGTH(output_text)), 0) FROM request_log WHERE input_text IS NOT NULL OR output_text IS NOT NULL"
+ ).fetchone()[0]
+
+ # Error count
+ error_count = conn.execute(
+ "SELECT COUNT(*) FROM request_log WHERE type='llm' AND error IS NOT NULL AND error != ''"
+ ).fetchone()[0]
+
+ return {
+ "total_requests": total,
+ "requests_with_content": with_content,
+ "error_count": error_count,
+ "oldest_ts": oldest,
+ "newest_ts": newest,
+ "content_size_bytes": content_size,
+ "content_size_mb": round(content_size / (1024 * 1024), 2),
+ }
+
def clear(self):
"""Clear all analytics data."""
with sqlite3.connect(self.db_path) as conn:
conn.execute("DELETE FROM request_log")
conn.execute("DELETE FROM status_events")
- conn.execute("DELETE FROM usage_snapshots")
\ No newline at end of file
+ conn.execute("DELETE FROM usage_snapshots")
+
+ def cleanup_old_log_files(self, history_config=None):
+ """Delete log files older than retention_days from the history_logs directory."""
+ if not history_config or not history_config.log_to_files:
+ return 0
+ retention_days = history_config.retention_days
+ if retention_days <= 0:
+ return 0 # 0 means keep forever
+ try:
+ log_dir = history_config.get_log_dir()
+ if not log_dir.exists():
+ return 0
+ cutoff = time.time() - (retention_days * 86400)
+ deleted = 0
+ for f in log_dir.glob("*.log"):
+ if f.stat().st_mtime < cutoff:
+ f.unlink()
+ deleted += 1
+ return deleted
+ except Exception:
+ return 0
\ No newline at end of file
diff --git a/guanaco/app.py b/guanaco/app.py
index 42b5dd9..ea554b7 100644
--- a/guanaco/app.py
+++ b/guanaco/app.py
@@ -13,11 +13,8 @@ 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
-try:
- from importlib.metadata import version as _pkg_version
- __version__ = _pkg_version("guanaco")
-except Exception:
- __version__ = "0.3.6"
+from guanaco.accounts import AccountPool
+__version__ = "0.5.1"
from guanaco.router.router import create_router as create_llm_router
from guanaco.search.providers import ALL_PROVIDERS
from guanaco.dashboard import create_dashboard_router
@@ -36,6 +33,13 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
client = OllamaClient(api_key=resolved_key, session_cookie=config.usage.session_cookie)
+ # Ensure primary account exists in the accounts list
+ if not config.ollama_accounts:
+ config.ollama_accounts = [config.primary_account]
+ elif not any(a.name == "ollama" for a in config.ollama_accounts):
+ config.ollama_accounts.insert(0, config.primary_account)
+ account_pool = AccountPool(config.ollama_accounts)
+
from guanaco.config import get_default_config_dir
key_manager = ApiKeyManager(get_default_config_dir())
analytics = AnalyticsLogger()
@@ -133,7 +137,7 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
return {"status": "ok", "version": __version__}
# ── LLM Router ──
- app.include_router(create_llm_router(client, analytics=analytics, config=config))
+ app.include_router(create_llm_router(client, analytics=analytics, config=config, account_pool=account_pool))
# ── Search Providers ──
for provider_cls in ALL_PROVIDERS:
@@ -263,7 +267,10 @@ def create_app(config: AppConfig | None = None) -> FastAPI:
print(f" [WARN] Firecrawl SDK compat routes not loaded: {e}")
# ── Dashboard ──
- app.include_router(create_dashboard_router(key_manager, analytics, client), prefix="/dashboard")
+ app.include_router(create_dashboard_router(key_manager, analytics, client, account_pool=account_pool), prefix="/dashboard")
+
+ # Store account_pool on app state for dashboard access
+ app.state.account_pool = account_pool
# ── Ollama status & models (top-level API) ──
diff --git a/guanaco/cli.py b/guanaco/cli.py
index d30dea9..a088e3b 100644
--- a/guanaco/cli.py
+++ b/guanaco/cli.py
@@ -159,10 +159,10 @@ def _run_setup():
print("\n📡 LLM Configuration")
print(" Available Ollama Cloud models: qwen3:480b, gpt-oss:120b, deepseek-v3.1, oss120b")
print(" Also: qwen3.5:122b, glm-5.1, minimax-m2.7, llama4:109b, etc.")
- reranker = input("Reranker model [oss120b]: ").strip() or "oss120b"
- scraper = input("Scraper model [qwen3:480b]: ").strip() or "qwen3:480b"
- summary = input("Summary model [qwen3:480b]: ").strip() or "qwen3:480b"
- default_model = input("Default chat model [qwen3:480b]: ").strip() or "qwen3:480b"
+ reranker = input("Reranker model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b"
+ scraper = input("Scraper model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b"
+ summary = input("Summary model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b"
+ default_model = input("Default chat model [nemotron-3-nano:30b]: ").strip() or "nemotron-3-nano:30b"
emulate_anthropic = input("Enable Anthropic /v1/messages emulation? [Y/n]: ").strip().lower() != "n"
emulate_openai = input("Enable OpenAI /v1/chat/completions? [Y/n]: ").strip().lower() != "n"
@@ -379,6 +379,9 @@ def _run_start(args):
config = load_config()
+ # ── Version sanity check: repo vs installed package ──
+ _check_version_sanity()
+
if args.host:
config.router.host = args.host
if args.port:
@@ -802,5 +805,64 @@ def _run_config(args):
print(f" {en} {name} {key_status}")
+def _check_version_sanity():
+ """Warn if the installed package is out of sync with the repo checkout.
+
+ Detects the common footgun where:
+ - install.sh clones to ~/.guanaco/repo and does `pip install -e .`
+ - But later someone edits the repo code without reinstalling
+ - Or installs a different version from PyPI over the editable install
+ """
+ import importlib.util
+ from pathlib import Path
+
+ try:
+ # Where does `guanaco` load from?
+ spec = importlib.util.find_spec("guanaco")
+ if spec is None or spec.origin is None:
+ return # can't determine, skip
+ installed_path = Path(spec.origin).resolve()
+
+ # Check if it's an editable install (points into a repo checkout)
+ is_editable = False
+ repo_root = None
+ if installed_path.parts:
+ # Walk up to find .git
+ for parent in installed_path.parents:
+ if (parent / ".git").is_dir():
+ is_editable = True
+ repo_root = parent
+ break
+
+ # Read __version__ from the installed package
+ from guanaco import __version__ as installed_version
+
+ if repo_root:
+ # Compare with repo __init__.py version
+ repo_init = repo_root / "guanaco" / "__init__.py"
+ if repo_init.exists():
+ repo_version = "unknown"
+ for line in repo_init.read_text().splitlines():
+ if '__version__' in line and '=' in line:
+ repo_version = line.split('=')[1].strip().strip('"').strip("'")
+ break
+ if repo_version != installed_version:
+ print(f"⚠️ VERSION MISMATCH DETECTED")
+ print(f" Installed package: v{installed_version} at {installed_path}")
+ print(f" Repo checkout: v{repo_version} at {repo_root}")
+ print(f" Fix: cd {repo_root} && pip install -e .")
+ print()
+
+ # Also warn if installed from PyPI (site-packages) rather than editable
+ elif "site-packages" in str(installed_path):
+ print(f"⚠️ Installed from PyPI/site-packages, not editable install:")
+ print(f" {installed_path}")
+ print(f" If you're developing, use: pip install -e .")
+ print()
+
+ except Exception:
+ pass # Don't crash startup for a sanity check
+
+
if __name__ == "__main__":
main()
\ No newline at end of file
diff --git a/guanaco/client.py b/guanaco/client.py
index d12169a..ae7b9aa 100644
--- a/guanaco/client.py
+++ b/guanaco/client.py
@@ -5,6 +5,7 @@ from __future__ import annotations
import json
import time
import logging
+import re
from typing import Optional
import httpx
@@ -20,39 +21,51 @@ OLLAMA_FETCH_URL = f"{OLLAMA_BASE}/api/web_fetch"
OLLAMA_USAGE_URL = f"{OLLAMA_BASE}/api/account/usage"
OLLAMA_SETTINGS_URL = f"{OLLAMA_BASE}/api/account/settings"
+# Usage-level cache: maps model name → level (1-4)
+_USAGE_LEVEL_CACHE: dict[str, int] = {}
+_USAGE_LEVEL_CACHE_TIME: float = 0
+_USAGE_LEVEL_CACHE_TTL: float = 3600 # 1 hour
+
# Known cloud models (fallback + display info)
# Names must match /v1/models response (e.g. "gemma4:31b", "qwen3.5:397b")
+# usage_multiplier: relative GPU cost tier (0.25, 0.50, 0.75, 1.00) pulled from
+# ollama.com model pages — used for weighted analytics + visual cost badges.
KNOWN_CLOUD_MODELS = {
- "gemma4": {"sizes": ["31b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"]},
- "gemma3": {"sizes": ["4b", "12b", "27b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"]},
- "qwen3.5": {"sizes": ["397b"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"]},
- "qwen3-vl": {"sizes": ["235b", "235b-instruct"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"]},
- "qwen3-coder": {"sizes": ["480b"], "family": "qwen", "capabilities": ["tools", "cloud"]},
- "qwen3-coder-next": {"sizes": [], "family": "qwen", "capabilities": ["tools", "cloud"]},
- "qwen3-next": {"sizes": ["80b"], "family": "qwen", "capabilities": ["tools", "thinking", "cloud"]},
- "minimax-m2": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]},
- "minimax-m2.7": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]},
- "minimax-m2.5": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]},
- "minimax-m2.1": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"]},
- "glm-5.1": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]},
- "glm-5": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]},
- "glm-4.7": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]},
- "glm-4.6": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"]},
- "gpt-oss": {"sizes": ["20b", "120b"], "family": "gpt-oss", "capabilities": ["tools", "thinking", "cloud"]},
- "deepseek-v3.1": {"sizes": ["671b"], "family": "deepseek", "capabilities": ["thinking", "cloud"]},
- "deepseek-v3.2": {"sizes": [], "family": "deepseek", "capabilities": ["thinking", "cloud"]},
- "devstral-small-2": {"sizes": ["24b"], "family": "devstral", "capabilities": ["tools", "cloud"]},
- "devstral-2": {"sizes": ["123b"], "family": "devstral", "capabilities": ["tools", "cloud"]},
- "nemotron-3-super": {"sizes": [], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"]},
- "nemotron-3-nano": {"sizes": ["30b"], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"]},
- "mistral-large-3": {"sizes": ["675b"], "family": "mistral", "capabilities": ["tools", "thinking", "cloud"]},
- "ministral-3": {"sizes": ["3b", "8b", "14b"], "family": "mistral", "capabilities": ["tools", "cloud"]},
- "kimi-k2.5": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "cloud"]},
- "kimi-k2-thinking": {"sizes": [], "family": "kimi", "capabilities": ["thinking", "cloud"]},
- "kimi-k2": {"sizes": ["1t"], "family": "kimi", "capabilities": ["tools", "thinking", "cloud"]},
- "cogito-2.1": {"sizes": ["671b"], "family": "cogito", "capabilities": ["thinking", "cloud"]},
- "gemini-3-flash-preview": {"sizes": [], "family": "gemini", "capabilities": ["vision", "tools", "thinking", "cloud"]},
- "rnj-1": {"sizes": ["8b"], "family": "rnj", "capabilities": ["tools", "cloud"]},
+ "gemma4": {"sizes": ["31b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "gemma3": {"sizes": ["4b", "12b", "27b"], "family": "gemma", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "qwen3.5": {"sizes": ["397b"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 1.00},
+ "qwen3-vl": {"sizes": ["235b", "235b-instruct"], "family": "qwen", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75},
+ "qwen3-coder": {"sizes": ["480b"], "family": "qwen", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.75},
+ "qwen3-coder-next": {"sizes": [], "family": "qwen", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.75},
+ "qwen3-next": {"sizes": ["80b"], "family": "qwen", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.75},
+ "minimax-m2": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "minimax-m2.7": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "minimax-m2.5": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "minimax-m2.1": {"sizes": [], "family": "minimax", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "glm-5.1": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.75},
+ "glm-5": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.75},
+ "glm-4.7": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "glm-4.6": {"sizes": [], "family": "glm", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "gpt-oss": {"sizes": ["20b", "120b"], "family": "gpt-oss", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "deepseek-v3.1": {"sizes": ["671b"], "family": "deepseek", "capabilities": ["thinking", "cloud"], "usage_multiplier": 1.00},
+ "deepseek-v3.2": {"sizes": [], "family": "deepseek", "capabilities": ["thinking", "cloud"], "usage_multiplier": 1.00},
+ "deepseek-v4-pro": {"sizes": [], "family": "deepseek", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 1.00},
+ "deepseek-v4-flash": {"sizes": [], "family": "deepseek", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "devstral-small-2": {"sizes": ["24b"], "family": "devstral", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.50},
+ "devstral-2": {"sizes": ["123b"], "family": "devstral", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.75},
+ "nemotron-3-super": {"sizes": [], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "nemotron-3-nano": {"sizes": ["30b"], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 0.25},
+ "mistral-large-3": {"sizes": ["675b"], "family": "mistral", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 1.00},
+ "ministral-3": {"sizes": ["3b", "8b", "14b"], "family": "mistral", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.25},
+ "kimi-k2.6": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75, "context_length": 200000},
+ "kimi-k2.5": {"sizes": [], "family": "kimi", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75},
+ "kimi-k2-thinking": {"sizes": [], "family": "kimi", "capabilities": ["thinking", "cloud"], "usage_multiplier": 0.75},
+ "kimi-k2": {"sizes": ["1t"], "family": "kimi", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 1.00},
+ "cogito-2.1": {"sizes": ["671b"], "family": "cogito", "capabilities": ["thinking", "cloud"], "usage_multiplier": 1.00},
+ "gemini-3-flash-preview": {"sizes": [], "family": "gemini", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.50},
+ "rnj-1": {"sizes": ["8b"], "family": "rnj", "capabilities": ["tools", "cloud"], "usage_multiplier": 0.25},
+ "minimax-m3": {"sizes": [], "family": "minimax", "capabilities": ["vision", "tools", "thinking", "cloud"], "usage_multiplier": 0.75},
+ "nemotron-3-ultra": {"sizes": [], "family": "nemotron", "capabilities": ["tools", "thinking", "cloud"], "usage_multiplier": 1.00},
}
@@ -68,10 +81,120 @@ class OllamaClient:
self._models_cache_time: float = 0
self._models_cache_ttl: float = 300.0 # 5 minutes
- async def _get_client(self) -> httpx.AsyncClient:
+ @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.
+
+ When api_key_override is provided and differs from the default key,
+ creates a temporary client that must be closed by the caller.
+ Returns (client, is_temp) tuple so callers know whether to close it.
+ """
+ key = api_key_override or self.api_key
+ use_temp = api_key_override is not None and api_key_override != self.api_key
+
+ if use_temp:
+ # Per-request key override — create a temporary client
+ headers = {"Content-Type": "application/json"}
+ if key and key not in ("***", "REPLACE_ME", "your_api_key_here"):
+ headers["Authorization"] = f"Bearer {key}"
+ return httpx.AsyncClient(timeout=self.timeout, headers=headers)
+
+ # Default behavior — cached singleton client
if self._client is None or self._client.is_closed:
headers = {"Content-Type": "application/json"}
- # Only send Authorization if we have a real API key (not empty, placeholder, or masked)
if self.api_key and self.api_key not in ("***", "REPLACE_ME", "your_api_key_here"):
headers["Authorization"] = f"Bearer {self.api_key}"
self._client = httpx.AsyncClient(
@@ -82,35 +205,46 @@ class OllamaClient:
# ── Search & Fetch ──
- async def search(self, query: str, max_results: int = 10) -> dict:
+ async def search(self, query: str, max_results: int = 10, api_key: Optional[str] = None) -> dict:
"""Search the web using Ollama's web_search API."""
- client = await self._get_client()
- payload = {"query": query, "max_results": max(min(max_results, 10), 1)}
- resp = await client.post(OLLAMA_SEARCH_URL, json=payload)
- resp.raise_for_status()
- return resp.json()
+ client = await self._get_client(api_key_override=api_key)
+ is_temp = api_key is not None and api_key != self.api_key
+ try:
+ payload = {"query": query, "max_results": max(min(max_results, 10), 1)}
+ resp = await client.post(OLLAMA_SEARCH_URL, json=payload)
+ resp.raise_for_status()
+ return resp.json()
+ finally:
+ if is_temp and not client.is_closed:
+ await client.aclose()
- async def fetch(self, url: str) -> dict:
+ async def fetch(self, url: str, api_key: Optional[str] = None) -> dict:
"""Fetch/scrape a URL using Ollama's web_fetch API."""
- client = await self._get_client()
- payload = {"url": url}
- resp = await client.post(OLLAMA_FETCH_URL, json=payload)
- resp.raise_for_status()
- return resp.json()
+ client = await self._get_client(api_key_override=api_key)
+ is_temp = api_key is not None and api_key != self.api_key
+ try:
+ payload = {"url": url}
+ resp = await client.post(OLLAMA_FETCH_URL, json=payload)
+ resp.raise_for_status()
+ return resp.json()
+ finally:
+ if is_temp and not client.is_closed:
+ await client.aclose()
# ── Models ──
- async def list_models(self, force_refresh: bool = False) -> list[dict]:
+ async def list_models(self, force_refresh: bool = False, api_key: Optional[str] = None) -> list[dict]:
"""List available Ollama Cloud models, with caching.
Uses the OpenAI-compatible /v1/models endpoint which returns
model IDs in standard format (e.g. 'gemma4:31b', 'qwen3.5:397b').
"""
now = time.time()
- if not force_refresh and self._models_cache and (now - self._models_cache_time) < self._models_cache_ttl:
+ if not force_refresh and not api_key and self._models_cache and (now - self._models_cache_time) < self._models_cache_ttl:
return self._models_cache
- client = await self._get_client()
+ client = await self._get_client(api_key_override=api_key)
+ is_temp = api_key is not None and api_key != self.api_key
try:
resp = await client.get(OLLAMA_MODELS_URL)
if resp.status_code == 401:
@@ -143,6 +277,9 @@ class OllamaClient:
except Exception as e:
logger.error(f"Error fetching models: {e}")
raise
+ finally:
+ if is_temp and not client.is_closed:
+ await client.aclose()
async def check_model_available(self, model_name: str) -> bool:
"""Check if a specific model is available on Ollama Cloud."""
@@ -152,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 ──
@@ -228,6 +431,11 @@ class OllamaClient:
Weekly usage
30.9% used
... Resets in 3 days
+
+ Per-model breakdown (new feature):
+
+
"""
import re
result = {}
@@ -270,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