# Model Picker: Provider Detection & Diagnosis

How the `/model` picker (Telegram inline keyboard, Discord picker) decides which providers and models to show — and how to diagnose surprises.

## Pipeline Overview

```
User triggers /model
         │
         ▼
  send_model_picker()          (gateway/run.py ~line 8831)
    └─ list_picker_providers() (hermes_cli/model_switch.py ~line 1716)
         │
         ├─ (1) list_authenticated_providers()  — which providers have creds?
         │       │
         │       ├─ Section 1: provider_to_models_dev → env var check
         │       ├─ Section 2: HERMES_OVERLAYS → auth store → credential pool
         │       ├─ Section 2b: CANONICAL_PROVIDERS → auth store → pool
         │       └─ Section 4: custom_providers from config.yaml
         │
         └─ (2) For OpenRouter specifically:
                fetch_openrouter_models()
                  ├─ get_curated_openrouter_models()  → _get_provider_block("openrouter")
                  │     └─ _fetch_provider_override() → file:// override (if configured)
                  │       OR → get_catalog() → disk cache → remote fetch
                  │
                  └─ OpenRouter live /api/v1/models
                        └─ filter: keep only curated IDs that:
                            - exist in live catalog
                            - support tool calling
                            - (tag "free" if pricing input=0 AND output=0)
```

## Provider Detection — Why Providers Appear Without Config

The model picker checks **four credential sources** in order:

### Source A: Environment Variables

Defined in `hermes_cli/auth.py` (PROVIDER_REGISTRY) and `hermes_cli/providers.py` (HERMES_OVERLAYS). If ANY of the listed env vars is set, the provider appears:

| Provider | Env vars checked |
|----------|-----------------|
| OpenRouter | `OPENROUTER_API_KEY` |
| Anthropic | `ANTHROPIC_API_KEY`, `ANTHROPIC_TOKEN`, `CLAUDE_CODE_OAUTH_TOKEN` |
| Nous Portal | OAuth only (no env var, see Source B) |
| OpenAI Codex | OAuth only (no env var) |
| DeepSeek | `DEEPSEEK_API_KEY` |
| Google Gemini | `GOOGLE_API_KEY`, `GEMINI_API_KEY` |
| xAI/Grok | `XAI_API_KEY` |
| GitHub Copilot | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` |
| Z.AI/GLM | `GLM_API_KEY`, `ZAI_API_KEY`, `Z_AI_API_KEY` |
| MiniMax | `MINIMAX_API_KEY` |
| Kilo Code | `KILOCODE_API_KEY` |

### Source B: Auth Store (`~/.hermes/auth.json`)

Contains provider entries from `hermes login` or manual `hermes auth add`. Checked by:
```python
from hermes_cli.auth import _load_auth_store
store = _load_auth_store()
providers_store = store.get("providers", {})
# If pid or hermes_slug in providers_store → has_creds
```

### Source C: Credential Pool

Checked by:
```python
from agent.credential_pool import load_pool
pool = load_pool(hermes_slug)
if pool.has_credentials():
    has_creds = True
```

### Source D: External OAuth Files (Anthropic-only)

Hermes checks **two** OAuth credential files:

1. **Claude Code OAuth:** `~/.claude/credentials` — from standard Claude Code installs. Checked via `read_claude_code_credentials()`. Looks for the `claudeAiOauth.accessToken` field.
2. **Hermes-managed OAuth:** `~/.hermes/.anthropic_oauth.json` — from `hermes login --provider anthropic` or leftover `openai-codex` OAuth flows that also provision Anthropic tokens. Checked via `read_hermes_oauth_credentials()`.

Either file causes **Anthropic** to appear in the picker, even with `ANTHROPIC_API_KEY` unset.

**To remove Anthropic from the picker:** move (don't delete) the credential file aside:
```bash
# Remove Hermes OAuth tokens
mv ~/.hermes/.anthropic_oauth.json ~/.hermes/backups/anthropic_oauth.bak
# Remove Claude Code OAuth tokens (if present)
mv ~/.claude/credentials ~/.claude/credentials.bak
```
Back up to `~/.hermes/backups/` so you can restore later. The change is immediate — no restart needed. If Anthropic still shows after removing both files, check `ANTHROPIC_TOKEN` or `CLAUDE_CODE_OAUTH_TOKEN` env vars.

## Model Catalog Resolution

### OpenRouter Provider

1. **Override (if configured):** `model_catalog.providers.openrouter.url` is set to `file:///path/to/override.json` in config. This file is fetched fresh on every call (no cache). Must contain ALL desired models (override replaces, not merges).

2. **Main catalog (fallback):** If no override, fetches `https://hermes-agent.nousresearch.com/docs/api/model-catalog.json`, caches to `~/.hermes/cache/model_catalog.json` with 24-hour TTL.

3. **Live API filter:** Both override and catalog models are filtered against OpenRouter's live `GET /api/v1/models`:
   - Curated model IDs that DON'T exist in the live catalog are removed
   - Models that DON'T advertise tool-calling support (`tools` in their capabilities) are removed
   - Models with free pricing get tagged `[free]`

### Nous Portal Provider

Uses OpenRouter's curated model list as fallback if `nous` isn't separately defined in `_PROVIDER_MODELS` (line 1164 of `model_switch.py`):
```python
if "nous" not in curated:
    curated["nous"] = curated["openrouter"]
```

## Diagnosis — Quick CLI Test

From the Hermes source directory:

```bash
cd ~/.hermes/hermes-agent
python3 -c "
import sys, os
sys.path.insert(0, '.')
os.environ['HERMES_HOME'] = os.path.expanduser('~/.hermes')
from hermes_cli.model_switch import list_authenticated_providers
providers = list_authenticated_providers(
    current_provider='custom:local',
    current_base_url='http://127.0.0.1:8081/v1',
    current_model='Qwen3.6-35B-A3B-UD-Q4_K_M.gguf',
    max_models=5,
)
for p in providers:
    print(f'  slug={p[\"slug\"]} is_current={p[\"is_current\"]} models={p[\"models\"][:3]}')
"
```

To check which OpenRouter models survive the live filter:

```bash
python3 -c "
import sys, os
sys.path.insert(0, '.')
os.environ['HERMES_HOME'] = os.path.expanduser('~/.hermes')
from hermes_cli.models import fetch_openrouter_models
for mid, desc in fetch_openrouter_models(force_refresh=True, timeout=10):
    print(f'  {mid} [{desc}]')
"
```

## Key Code Locations

| File | Class/Function | Purpose |
|------|---------------|---------|
| `gateway/run.py:8831` | `_handle_model_command()` | Gateway model switch handler |
| `gateway/platforms/telegram.py:1883` | `send_model_picker()` | Telegram inline keyboard |
| `hermes_cli/model_switch.py:1716` | `list_picker_providers()` | Picker-specific provider list |
| `hermes_cli/model_switch.py:1047` | `list_authenticated_providers()` | Core credential check pipeline |
| `hermes_cli/model_catalog.py:258` | `_fetch_provider_override()` | Model catalog override fetch |
| `hermes_cli/model_catalog.py:275` | `_get_provider_block()` | Provider block with override support |
| `hermes_cli/models.py:961` | `fetch_openrouter_models()` | Curated list + live API filter |
| `hermes_cli/providers.py:46` | `HERMES_OVERLAYS` dict | Provider credential definitions |
| `hermes_cli/auth.py` | `PROVIDER_REGISTRY` | Auth type + env var definitions |

## Common User Complaints & Answers

**"I still see Anthropic even though I removed it from config"**
→ You have Claude Code installed. Its OAuth tokens auto-register Anthropic. Check `~/.claude/credentials` or run `read_claude_code_credentials()`.

**"I don't see my OpenRouter models in the picker"**
→ Check if the custom model IDs exist in OpenRouter's live `/api/v1/models` and support tool calling. Models that fail either check are filtered out. Verify with `fetch_openrouter_models(force_refresh=True)`.

**"I see OpenRouter models where I don't recognize the names"**
→ The short model names shown in inline keyboards are the portion after the `/` in the model ID (e.g. `claude-opus-4.7` from `anthropic/claude-opus-4.7`). These are all OpenRouter-routed models.

**"My override file has more models than the picker shows"**
→ Yes — the live OpenRouter API filter removes curated model IDs that aren't currently offered OR that lack tool-calling support. This is by design so the picker never offers a model that would fail at runtime.
