diff --git a/.gitignore b/.gitignore
index d9db564..0850ce1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -87,4 +87,4 @@ dmypy.json
Thumbs.db
# Project-specific
-.oct/.venv-test/
+.oct/
\ No newline at end of file
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index 09eb137..0000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,177 +0,0 @@
-# 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
deleted file mode 100644
index 3cb00d0..0000000
--- a/docs/plans/2026-04-19-multi-account-manager.md
+++ /dev/null
@@ -1,882 +0,0 @@
-# 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 c7e78f9..b885581 100644
--- a/guanaco/__init__.py
+++ b/guanaco/__init__.py
@@ -1,21 +1,7 @@
"""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
- _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
+ __version__ = _version("guanaco")
except Exception:
- pass
\ No newline at end of file
+ __version__ = "0.3.6" # fallback when not installed via pip
\ No newline at end of file
diff --git a/guanaco/accounts.py b/guanaco/accounts.py
deleted file mode 100644
index f909ab1..0000000
--- a/guanaco/accounts.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""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 b57f8c5..1e97bcf 100644
--- a/guanaco/analytics.py
+++ b/guanaco/analytics.py
@@ -15,11 +15,6 @@ 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
@@ -77,22 +72,6 @@ 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,
@@ -151,101 +130,25 @@ 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,
- source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name)
- VALUES (?, ?, 'llm', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
+ load_duration_seconds, error, request_id, provider, fallback_for)
+ VALUES (?, ?, 'llm', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(entry_id, time.time(), model, prompt_tokens, completion_tokens,
total_tokens, tps, prompt_tps, ttft_seconds, total_duration_seconds,
- load_duration_seconds, error, request_id, provider, fallback_for,
- source_ip, source_port, user_agent, input_text, output_text, fallback_reason, account_name),
+ load_duration_seconds, error, request_id, provider, fallback_for),
)
-
- # 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,
@@ -510,19 +413,6 @@ 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(
@@ -567,7 +457,6 @@ 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,
@@ -613,165 +502,9 @@ 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")
-
- 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
+ conn.execute("DELETE FROM usage_snapshots")
\ No newline at end of file
diff --git a/guanaco/app.py b/guanaco/app.py
index ea554b7..42b5dd9 100644
--- a/guanaco/app.py
+++ b/guanaco/app.py
@@ -13,8 +13,11 @@ from fastapi.middleware.cors import CORSMiddleware
from guanaco.config import load_config, get_config, AppConfig, get_base_url, get_tailscale_ip
from guanaco.client import OllamaClient
-from guanaco.accounts import AccountPool
-__version__ = "0.5.1"
+try:
+ from importlib.metadata import version as _pkg_version
+ __version__ = _pkg_version("guanaco")
+except Exception:
+ __version__ = "0.3.6"
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
@@ -33,13 +36,6 @@ 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()
@@ -137,7 +133,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, account_pool=account_pool))
+ app.include_router(create_llm_router(client, analytics=analytics, config=config))
# ── Search Providers ──
for provider_cls in ALL_PROVIDERS:
@@ -267,10 +263,7 @@ 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, account_pool=account_pool), prefix="/dashboard")
-
- # Store account_pool on app state for dashboard access
- app.state.account_pool = account_pool
+ app.include_router(create_dashboard_router(key_manager, analytics, client), prefix="/dashboard")
# ── Ollama status & models (top-level API) ──
diff --git a/guanaco/cli.py b/guanaco/cli.py
index a088e3b..d30dea9 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 [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"
+ 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"
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,9 +379,6 @@ 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:
@@ -805,64 +802,5 @@ 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 ae7b9aa..d12169a 100644
--- a/guanaco/client.py
+++ b/guanaco/client.py
@@ -5,7 +5,6 @@ from __future__ import annotations
import json
import time
import logging
-import re
from typing import Optional
import httpx
@@ -21,51 +20,39 @@ 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"], "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},
+ "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"]},
}
@@ -81,120 +68,10 @@ 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.
-
- 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
+ async def _get_client(self) -> httpx.AsyncClient:
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(
@@ -205,46 +82,35 @@ class OllamaClient:
# ── Search & Fetch ──
- async def search(self, query: str, max_results: int = 10, api_key: Optional[str] = None) -> dict:
+ async def search(self, query: str, max_results: int = 10) -> dict:
"""Search the web using Ollama's web_search API."""
- 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()
+ 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()
- async def fetch(self, url: str, api_key: Optional[str] = None) -> dict:
+ async def fetch(self, url: str) -> dict:
"""Fetch/scrape a URL using Ollama's web_fetch API."""
- 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()
+ client = await self._get_client()
+ payload = {"url": url}
+ resp = await client.post(OLLAMA_FETCH_URL, json=payload)
+ resp.raise_for_status()
+ return resp.json()
# ── Models ──
- async def list_models(self, force_refresh: bool = False, api_key: Optional[str] = None) -> list[dict]:
+ async def list_models(self, force_refresh: bool = False) -> 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 not api_key and self._models_cache and (now - self._models_cache_time) < self._models_cache_ttl:
+ if not force_refresh and self._models_cache and (now - self._models_cache_time) < self._models_cache_ttl:
return self._models_cache
- client = await self._get_client(api_key_override=api_key)
- is_temp = api_key is not None and api_key != self.api_key
+ client = await self._get_client()
try:
resp = await client.get(OLLAMA_MODELS_URL)
if resp.status_code == 401:
@@ -277,9 +143,6 @@ 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."""
@@ -289,104 +152,38 @@ 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.
-
- Fetches real usage levels from ollama.com library pages and includes
- them as usage_multiplier (0.25-1.00) alongside capabilities.
- """
+ """Get list of cloud-capable models with metadata."""
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", {})
- level = usage_levels.get(name, 0)
- multiplier = level * 0.25 if level else self._get_model_multiplier(name)
+ # 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", "")
cloud_models.append({
"name": name,
"display_name": name.replace("-cloud", ""),
"size_bytes": m.get("size", 0),
- "parameter_size": details.get("parameter_size", ""),
- "family": details.get("family", ""),
- "quantization": details.get("quantization_level", ""),
+ "parameter_size": size_info,
+ "family": family,
+ "quantization": quant,
"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. Falls back to name-based inference."""
+ """Get known capabilities for a model name."""
base_name = model_name.split(":")[0].replace("-cloud", "")
if base_name in KNOWN_CLOUD_MODELS:
return KNOWN_CLOUD_MODELS[base_name].get("capabilities", ["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
+ # Default capabilities for unknown models
+ return ["cloud"]
# ── Usage / Quota ──
@@ -431,11 +228,6 @@ class OllamaClient:
Weekly usage
30.9% used
... Resets in 3 days
-
- Per-model breakdown (new feature):
-
-
"""
import re
result = {}
@@ -478,45 +270,6 @@ 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