Spaces:
Paused
Paused
Upload models_dev_stub.py with huggingface_hub
Browse files- models_dev_stub.py +697 -174
models_dev_stub.py
CHANGED
|
@@ -1,203 +1,726 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from dataclasses import dataclass
|
|
|
|
| 3 |
from typing import Any, Dict, List, Optional, Tuple
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
logger = logging.getLogger(__name__)
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
@dataclass
|
| 17 |
class ModelInfo:
|
|
|
|
|
|
|
| 18 |
id: str
|
| 19 |
name: str
|
| 20 |
family: str
|
| 21 |
-
provider_id: str
|
|
|
|
|
|
|
| 22 |
reasoning: bool = False
|
| 23 |
tool_call: bool = False
|
| 24 |
-
attachment: bool = False
|
| 25 |
temperature: bool = False
|
| 26 |
structured_output: bool = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
@dataclass
|
| 29 |
class ProviderInfo:
|
| 30 |
-
|
| 31 |
-
base_url: str = ""
|
| 32 |
-
api_doc: str = ""
|
| 33 |
-
env_vars: Tuple[str, ...] = ()
|
| 34 |
-
extra_env_vars: Tuple[str, ...] = ()
|
| 35 |
-
is_aggregator: bool = False
|
| 36 |
-
|
| 37 |
-
CURATED_MODELS = [
|
| 38 |
-
{
|
| 39 |
-
"id": "meta-llama/llama-3.3-70b-instruct:free",
|
| 40 |
-
"name": "Llama 3.3 70B Instruct",
|
| 41 |
-
"provider_id": "openrouter"
|
| 42 |
-
},
|
| 43 |
-
{
|
| 44 |
-
"id": "deepseek/deepseek-v4-flash:free",
|
| 45 |
-
"name": "Deepseek V4 Flash",
|
| 46 |
-
"provider_id": "openrouter"
|
| 47 |
-
},
|
| 48 |
-
{
|
| 49 |
-
"id": "z-ai/glm-4.5-air:free",
|
| 50 |
-
"name": "Glm 4.5 Air",
|
| 51 |
-
"provider_id": "openrouter"
|
| 52 |
-
},
|
| 53 |
-
{
|
| 54 |
-
"id": "minimax/minimax-m2.5:free",
|
| 55 |
-
"name": "Minimax M2.5",
|
| 56 |
-
"provider_id": "openrouter"
|
| 57 |
-
},
|
| 58 |
-
{
|
| 59 |
-
"id": "google/gemma-4-31b-it:free",
|
| 60 |
-
"name": "Gemma 4 31B It",
|
| 61 |
-
"provider_id": "openrouter"
|
| 62 |
-
},
|
| 63 |
-
{
|
| 64 |
-
"id": "google/gemma-4-26b-a4b-it:free",
|
| 65 |
-
"name": "Gemma 4 26B A4B It",
|
| 66 |
-
"provider_id": "openrouter"
|
| 67 |
-
},
|
| 68 |
-
{
|
| 69 |
-
"id": "qwen/qwen3-coder:free",
|
| 70 |
-
"name": "Qwen3 Coder",
|
| 71 |
-
"provider_id": "openrouter"
|
| 72 |
-
},
|
| 73 |
-
{
|
| 74 |
-
"id": "qwen/qwen3-next-80b-a3b-instruct:free",
|
| 75 |
-
"name": "Qwen3 Next 80B A3B Instruct",
|
| 76 |
-
"provider_id": "openrouter"
|
| 77 |
-
},
|
| 78 |
-
{
|
| 79 |
-
"id": "nousresearch/hermes-3-llama-3.1-405b:free",
|
| 80 |
-
"name": "Hermes 3 Llama 3.1 405B",
|
| 81 |
-
"provider_id": "openrouter"
|
| 82 |
-
},
|
| 83 |
-
{
|
| 84 |
-
"id": "openai/gpt-oss-120b:free",
|
| 85 |
-
"name": "Gpt Oss 120B",
|
| 86 |
-
"provider_id": "openrouter"
|
| 87 |
-
},
|
| 88 |
-
{
|
| 89 |
-
"id": "openai/gpt-oss-20b:free",
|
| 90 |
-
"name": "Gpt Oss 20B",
|
| 91 |
-
"provider_id": "openrouter"
|
| 92 |
-
},
|
| 93 |
-
{
|
| 94 |
-
"id": "baidu/cobuddy:free",
|
| 95 |
-
"name": "Cobuddy",
|
| 96 |
-
"provider_id": "openrouter"
|
| 97 |
-
},
|
| 98 |
-
{
|
| 99 |
-
"id": "arcee-ai/trinity-large-thinking:free",
|
| 100 |
-
"name": "Trinity Large Thinking",
|
| 101 |
-
"provider_id": "openrouter"
|
| 102 |
-
},
|
| 103 |
-
{
|
| 104 |
-
"id": "liquid/lfm-2.5-1.2b-thinking:free",
|
| 105 |
-
"name": "Lfm 2.5 1.2B Thinking",
|
| 106 |
-
"provider_id": "openrouter"
|
| 107 |
-
},
|
| 108 |
-
{
|
| 109 |
-
"id": "liquid/lfm-2.5-1.2b-instruct:free",
|
| 110 |
-
"name": "Lfm 2.5 1.2B Instruct",
|
| 111 |
-
"provider_id": "openrouter"
|
| 112 |
-
},
|
| 113 |
-
{
|
| 114 |
-
"id": "meta-llama/llama-3.2-3b-instruct:free",
|
| 115 |
-
"name": "Llama 3.2 3B Instruct",
|
| 116 |
-
"provider_id": "openrouter"
|
| 117 |
-
},
|
| 118 |
-
{
|
| 119 |
-
"id": "cognitivecomputations/dolphin-mistral-24b-venice-edition:free",
|
| 120 |
-
"name": "Dolphin Mistral 24B Venice Edition",
|
| 121 |
-
"provider_id": "openrouter"
|
| 122 |
-
},
|
| 123 |
-
{
|
| 124 |
-
"id": "nvidia/nemotron-3-super-120b-a12b:free",
|
| 125 |
-
"name": "Nemotron 3 Super 120B A12B",
|
| 126 |
-
"provider_id": "openrouter"
|
| 127 |
-
},
|
| 128 |
-
{
|
| 129 |
-
"id": "nvidia/nemotron-3-nano-30b-a3b:free",
|
| 130 |
-
"name": "Nemotron 3 Nano 30B A3B",
|
| 131 |
-
"provider_id": "openrouter"
|
| 132 |
-
},
|
| 133 |
-
{
|
| 134 |
-
"id": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
|
| 135 |
-
"name": "Nemotron 3 Nano Omni 30B A3B Reasoning",
|
| 136 |
-
"provider_id": "openrouter"
|
| 137 |
-
},
|
| 138 |
-
{
|
| 139 |
-
"id": "nvidia/nemotron-nano-9b-v2:free",
|
| 140 |
-
"name": "Nemotron Nano 9B V2",
|
| 141 |
-
"provider_id": "openrouter"
|
| 142 |
-
},
|
| 143 |
-
{
|
| 144 |
-
"id": "nvidia/nemotron-nano-12b-v2-vl:free",
|
| 145 |
-
"name": "Nemotron Nano 12B V2 Vl",
|
| 146 |
-
"provider_id": "openrouter"
|
| 147 |
-
},
|
| 148 |
-
{
|
| 149 |
-
"id": "nvidia/llama-nemotron-embed-vl-1b-v2:free",
|
| 150 |
-
"name": "Llama Nemotron Embed Vl 1B V2",
|
| 151 |
-
"provider_id": "openrouter"
|
| 152 |
-
},
|
| 153 |
-
{
|
| 154 |
-
"id": "poolside/laguna-m.1:free",
|
| 155 |
-
"name": "Laguna M.1",
|
| 156 |
-
"provider_id": "openrouter"
|
| 157 |
-
},
|
| 158 |
-
{
|
| 159 |
-
"id": "poolside/laguna-xs.2:free",
|
| 160 |
-
"name": "Laguna Xs.2",
|
| 161 |
-
"provider_id": "openrouter"
|
| 162 |
-
},
|
| 163 |
-
{
|
| 164 |
-
"id": "openrouter/auto:free",
|
| 165 |
-
"name": "Auto",
|
| 166 |
-
"provider_id": "openrouter"
|
| 167 |
-
}
|
| 168 |
-
]
|
| 169 |
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
return None
|
| 174 |
|
| 175 |
-
def get_all_models() -> list:
|
| 176 |
-
return CURATED_MODELS
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
-
def get_model_info(model_id: str) -> Optional[ModelInfo]:
|
| 184 |
-
for m in CURATED_MODELS:
|
| 185 |
-
if m["id"] == model_id:
|
| 186 |
-
return ModelInfo(id=m["id"], name=m["name"], family="", provider_id=m.get("provider_id",""))
|
| 187 |
return None
|
| 188 |
|
| 189 |
-
def get_model_capabilities(model_id: str) -> ModelCapabilities:
|
| 190 |
-
return ModelCapabilities()
|
| 191 |
|
| 192 |
-
def
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
-
def get_model(model_id: str) -> Optional[dict]:
|
| 201 |
-
for m in CURATED_MODELS:
|
| 202 |
-
if m["id"] == model_id: return m
|
| 203 |
return None
|
|
|
|
| 1 |
+
"""Models.dev registry integration — primary database for providers and models.
|
| 2 |
+
|
| 3 |
+
Fetches from https://models.dev/api.json — a community-maintained database
|
| 4 |
+
of 4000+ models across 109+ providers. Provides:
|
| 5 |
+
|
| 6 |
+
- **Provider metadata**: name, base URL, env vars, documentation link
|
| 7 |
+
- **Model metadata**: context window, max output, cost/M tokens, capabilities
|
| 8 |
+
(reasoning, tools, vision, PDF, audio), modalities, knowledge cutoff,
|
| 9 |
+
open-weights flag, family grouping, deprecation status
|
| 10 |
+
|
| 11 |
+
Data resolution order (like TypeScript OpenCode):
|
| 12 |
+
1. Bundled snapshot (ships with the package — offline-first)
|
| 13 |
+
2. Disk cache (~/.hermes/models_dev_cache.json)
|
| 14 |
+
3. Network fetch (https://models.dev/api.json)
|
| 15 |
+
4. Background refresh every 60 minutes
|
| 16 |
+
|
| 17 |
+
Other modules should import the dataclasses and query functions from here
|
| 18 |
+
rather than parsing the raw JSON themselves.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import logging
|
| 23 |
+
import time
|
| 24 |
from dataclasses import dataclass
|
| 25 |
+
from pathlib import Path
|
| 26 |
from typing import Any, Dict, List, Optional, Tuple
|
| 27 |
|
| 28 |
+
from utils import atomic_json_write
|
| 29 |
+
|
| 30 |
+
import requests
|
| 31 |
+
|
| 32 |
logger = logging.getLogger(__name__)
|
| 33 |
|
| 34 |
+
# HuggingMes: curated model allowlist
|
| 35 |
+
_CURATED_MODEL_IDS = ["meta-llama/llama-3.3-70b-instruct:free", "deepseek/deepseek-v4-flash:free", "z-ai/glm-4.5-air:free", "minimax/minimax-m2.5:free", "google/gemma-4-31b-it:free", "google/gemma-4-26b-a4b-it:free", "qwen/qwen3-coder:free", "qwen/qwen3-next-80b-a3b-instruct:free", "nousresearch/hermes-3-llama-3.1-405b:free", "openai/gpt-oss-120b:free", "openai/gpt-oss-20b:free", "baidu/cobuddy:free", "arcee-ai/trinity-large-thinking:free", "liquid/lfm-2.5-1.2b-thinking:free", "liquid/lfm-2.5-1.2b-instruct:free", "meta-llama/llama-3.2-3b-instruct:free", "cognitivecomputations/dolphin-mistral-24b-venice-edition:free", "nvidia/nemotron-3-super-120b-a12b:free", "nvidia/nemotron-3-nano-30b-a3b:free", "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", "nvidia/nemotron-nano-9b-v2:free", "nvidia/nemotron-nano-12b-v2-vl:free", "nvidia/llama-nemotron-embed-vl-1b-v2:free", "poolside/laguna-m.1:free", "poolside/laguna-xs.2:free", "openrouter/auto:free"]
|
| 36 |
+
|
| 37 |
+
MODELS_DEV_URL = "https://models.dev/api.json"
|
| 38 |
+
_MODELS_DEV_CACHE_TTL = 3600 # 1 hour in-memory
|
| 39 |
+
|
| 40 |
+
# In-memory cache
|
| 41 |
+
_models_dev_cache: Dict[str, Any] = {}
|
| 42 |
+
_models_dev_cache_time: float = 0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Dataclasses — rich metadata for providers and models
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
|
| 49 |
@dataclass
|
| 50 |
class ModelInfo:
|
| 51 |
+
"""Full metadata for a single model from models.dev."""
|
| 52 |
+
|
| 53 |
id: str
|
| 54 |
name: str
|
| 55 |
family: str
|
| 56 |
+
provider_id: str # models.dev provider ID (e.g. "anthropic")
|
| 57 |
+
|
| 58 |
+
# Capabilities
|
| 59 |
reasoning: bool = False
|
| 60 |
tool_call: bool = False
|
| 61 |
+
attachment: bool = False # supports image/file attachments (vision)
|
| 62 |
temperature: bool = False
|
| 63 |
structured_output: bool = False
|
| 64 |
+
open_weights: bool = False
|
| 65 |
+
|
| 66 |
+
# Modalities
|
| 67 |
+
input_modalities: Tuple[str, ...] = () # ("text", "image", "pdf", ...)
|
| 68 |
+
output_modalities: Tuple[str, ...] = ()
|
| 69 |
+
|
| 70 |
+
# Limits
|
| 71 |
+
context_window: int = 0
|
| 72 |
+
max_output: int = 0
|
| 73 |
+
max_input: Optional[int] = None
|
| 74 |
+
|
| 75 |
+
# Cost (per million tokens, USD)
|
| 76 |
+
cost_input: float = 0.0
|
| 77 |
+
cost_output: float = 0.0
|
| 78 |
+
cost_cache_read: Optional[float] = None
|
| 79 |
+
cost_cache_write: Optional[float] = None
|
| 80 |
+
|
| 81 |
+
# Metadata
|
| 82 |
+
knowledge_cutoff: str = ""
|
| 83 |
+
release_date: str = ""
|
| 84 |
+
status: str = "" # "alpha", "beta", "deprecated", or ""
|
| 85 |
+
interleaved: Any = False # True or {"field": "reasoning_content"}
|
| 86 |
+
|
| 87 |
+
def has_cost_data(self) -> bool:
|
| 88 |
+
return self.cost_input > 0 or self.cost_output > 0
|
| 89 |
+
|
| 90 |
+
def supports_vision(self) -> bool:
|
| 91 |
+
return self.attachment or "image" in self.input_modalities
|
| 92 |
+
|
| 93 |
+
def supports_pdf(self) -> bool:
|
| 94 |
+
return "pdf" in self.input_modalities
|
| 95 |
+
|
| 96 |
+
def supports_audio_input(self) -> bool:
|
| 97 |
+
return "audio" in self.input_modalities
|
| 98 |
+
|
| 99 |
+
def format_cost(self) -> str:
|
| 100 |
+
"""Human-readable cost string, e.g. '$3.00/M in, $15.00/M out'."""
|
| 101 |
+
if not self.has_cost_data():
|
| 102 |
+
return "unknown"
|
| 103 |
+
parts = [f"${self.cost_input:.2f}/M in", f"${self.cost_output:.2f}/M out"]
|
| 104 |
+
if self.cost_cache_read is not None:
|
| 105 |
+
parts.append(f"cache read ${self.cost_cache_read:.2f}/M")
|
| 106 |
+
return ", ".join(parts)
|
| 107 |
+
|
| 108 |
+
def format_capabilities(self) -> str:
|
| 109 |
+
"""Human-readable capabilities, e.g. 'reasoning, tools, vision, PDF'."""
|
| 110 |
+
caps = []
|
| 111 |
+
if self.reasoning:
|
| 112 |
+
caps.append("reasoning")
|
| 113 |
+
if self.tool_call:
|
| 114 |
+
caps.append("tools")
|
| 115 |
+
if self.supports_vision():
|
| 116 |
+
caps.append("vision")
|
| 117 |
+
if self.supports_pdf():
|
| 118 |
+
caps.append("PDF")
|
| 119 |
+
if self.supports_audio_input():
|
| 120 |
+
caps.append("audio")
|
| 121 |
+
if self.structured_output:
|
| 122 |
+
caps.append("structured output")
|
| 123 |
+
if self.open_weights:
|
| 124 |
+
caps.append("open weights")
|
| 125 |
+
return ", ".join(caps) if caps else "basic"
|
| 126 |
+
|
| 127 |
|
| 128 |
@dataclass
|
| 129 |
class ProviderInfo:
|
| 130 |
+
"""Full metadata for a provider from models.dev."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
+
id: str # models.dev provider ID
|
| 133 |
+
name: str # display name
|
| 134 |
+
env: Tuple[str, ...] # env var names for API key
|
| 135 |
+
api: str # base URL
|
| 136 |
+
doc: str = "" # documentation URL
|
| 137 |
+
model_count: int = 0
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
# Provider ID mapping: Hermes ↔ models.dev
|
| 142 |
+
# ---------------------------------------------------------------------------
|
| 143 |
+
|
| 144 |
+
# Hermes provider names → models.dev provider IDs
|
| 145 |
+
PROVIDER_TO_MODELS_DEV: Dict[str, str] = {
|
| 146 |
+
"openrouter": "openrouter",
|
| 147 |
+
"novita": "novita-ai",
|
| 148 |
+
"anthropic": "anthropic",
|
| 149 |
+
"openai": "openai",
|
| 150 |
+
"openai-codex": "openai",
|
| 151 |
+
"zai": "zai",
|
| 152 |
+
"kimi": "kimi-for-coding",
|
| 153 |
+
"kimi-coding": "kimi-for-coding",
|
| 154 |
+
"moonshot": "kimi-for-coding",
|
| 155 |
+
"stepfun": "stepfun",
|
| 156 |
+
"kimi-coding-cn": "kimi-for-coding",
|
| 157 |
+
"minimax": "minimax",
|
| 158 |
+
"minimax-oauth": "minimax",
|
| 159 |
+
"minimax-cn": "minimax-cn",
|
| 160 |
+
"deepseek": "deepseek",
|
| 161 |
+
"alibaba": "alibaba",
|
| 162 |
+
"qwen-oauth": "alibaba",
|
| 163 |
+
"copilot": "github-copilot",
|
| 164 |
+
"ai-gateway": "vercel",
|
| 165 |
+
"opencode-zen": "opencode",
|
| 166 |
+
"opencode-go": "opencode-go",
|
| 167 |
+
"kilocode": "kilo",
|
| 168 |
+
"fireworks": "fireworks-ai",
|
| 169 |
+
"huggingface": "huggingface",
|
| 170 |
+
"gemini": "google",
|
| 171 |
+
"google": "google",
|
| 172 |
+
"xai": "xai",
|
| 173 |
+
"xiaomi": "xiaomi",
|
| 174 |
+
"nvidia": "nvidia",
|
| 175 |
+
"groq": "groq",
|
| 176 |
+
"mistral": "mistral",
|
| 177 |
+
"togetherai": "togetherai",
|
| 178 |
+
"perplexity": "perplexity",
|
| 179 |
+
"cohere": "cohere",
|
| 180 |
+
"ollama-cloud": "ollama-cloud",
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
# Reverse mapping: models.dev → Hermes (built lazily)
|
| 184 |
+
_MODELS_DEV_TO_PROVIDER: Optional[Dict[str, str]] = None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def _get_cache_path() -> Path:
|
| 189 |
+
"""Return path to disk cache file."""
|
| 190 |
+
from hermes_constants import get_hermes_home
|
| 191 |
+
return get_hermes_home() / "models_dev_cache.json"
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _load_disk_cache() -> Dict[str, Any]:
|
| 195 |
+
"""Load models.dev data from disk cache."""
|
| 196 |
+
try:
|
| 197 |
+
cache_path = _get_cache_path()
|
| 198 |
+
if cache_path.exists():
|
| 199 |
+
with open(cache_path, encoding="utf-8") as f:
|
| 200 |
+
return json.load(f)
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logger.debug("Failed to load models.dev disk cache: %s", e)
|
| 203 |
+
return {}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _disk_cache_age_seconds() -> Optional[float]:
|
| 207 |
+
"""Return age (in seconds) of the disk cache file, or None if missing.
|
| 208 |
+
|
| 209 |
+
Used by ``fetch_models_dev`` to short-circuit the network probe when
|
| 210 |
+
a recent on-disk cache exists. Errors (missing file, permission
|
| 211 |
+
denied, weird filesystem) all return None — callers fall through
|
| 212 |
+
to the network fetch path.
|
| 213 |
+
"""
|
| 214 |
+
try:
|
| 215 |
+
cache_path = _get_cache_path()
|
| 216 |
+
if not cache_path.exists():
|
| 217 |
+
return None
|
| 218 |
+
mtime = cache_path.stat().st_mtime
|
| 219 |
+
age = time.time() - mtime
|
| 220 |
+
# Negative age means the file's mtime is in the future (clock skew
|
| 221 |
+
# or system clock reset). Treat as "unknown freshness" → fall
|
| 222 |
+
# through to network so we don't serve potentially-bad data
|
| 223 |
+
# forever.
|
| 224 |
+
if age < 0:
|
| 225 |
+
return None
|
| 226 |
+
return age
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logger.debug("Failed to stat models.dev disk cache: %s", e)
|
| 229 |
+
return None
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def _save_disk_cache(data: Dict[str, Any]) -> None:
|
| 233 |
+
"""Save models.dev data to disk cache atomically."""
|
| 234 |
+
try:
|
| 235 |
+
cache_path = _get_cache_path()
|
| 236 |
+
atomic_json_write(cache_path, data, indent=None, separators=(",", ":"))
|
| 237 |
+
except Exception as e:
|
| 238 |
+
logger.debug("Failed to save models.dev disk cache: %s", e)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def fetch_models_dev(force_refresh: bool = False) -> Dict[str, Any]:
|
| 242 |
+
"""Fetch models.dev registry. Cache hierarchy: in-mem → disk → network.
|
| 243 |
+
|
| 244 |
+
Returns the full registry dict keyed by provider ID, or empty dict on failure.
|
| 245 |
+
|
| 246 |
+
Cache hierarchy (when ``force_refresh=False``):
|
| 247 |
+
1. In-memory cache, populated and < TTL old → return immediately.
|
| 248 |
+
2. **Disk cache file < TTL old by mtime → load, populate in-mem, return.**
|
| 249 |
+
No network call. Saves ~500 ms per cold-start agent construction;
|
| 250 |
+
``models.dev`` only changes when providers add new models, so a
|
| 251 |
+
1 hour staleness window is acceptable (same TTL as in-mem cache).
|
| 252 |
+
3. Network fetch → on success, save to disk + in-mem and return.
|
| 253 |
+
4. Network fails → fall back to ANY available disk cache (even stale)
|
| 254 |
+
with a short 5 min in-mem grace period before retrying network.
|
| 255 |
+
|
| 256 |
+
When ``force_refresh=True`` (used by ``hermes config refresh``, the
|
| 257 |
+
\"refresh model catalog\" code path), stages 1 and 2 are skipped. The
|
| 258 |
+
function always hits the network and only falls back to disk if the
|
| 259 |
+
network call fails.
|
| 260 |
+
"""
|
| 261 |
+
global _models_dev_cache, _models_dev_cache_time
|
| 262 |
+
|
| 263 |
+
# Stage 1: fresh in-memory cache wins. This is the hot path on
|
| 264 |
+
# long-lived processes — no I/O, no system calls.
|
| 265 |
+
if (
|
| 266 |
+
not force_refresh
|
| 267 |
+
and _models_dev_cache
|
| 268 |
+
and (time.time() - _models_dev_cache_time) < _MODELS_DEV_CACHE_TTL
|
| 269 |
+
):
|
| 270 |
+
return _models_dev_cache
|
| 271 |
+
|
| 272 |
+
# Stage 2: fresh-by-mtime disk cache short-circuits the network call.
|
| 273 |
+
# Only kicks in on cold-start processes (in-mem cache is empty or
|
| 274 |
+
# expired) and only when the user hasn't asked for a forced refresh.
|
| 275 |
+
# Skipped if the disk cache file is missing, unreadable, or older
|
| 276 |
+
# than _MODELS_DEV_CACHE_TTL.
|
| 277 |
+
if not force_refresh:
|
| 278 |
+
disk_age = _disk_cache_age_seconds()
|
| 279 |
+
if disk_age is not None and disk_age < _MODELS_DEV_CACHE_TTL:
|
| 280 |
+
disk_data = _load_disk_cache()
|
| 281 |
+
if disk_data:
|
| 282 |
+
_models_dev_cache = disk_data
|
| 283 |
+
# Anchor in-mem TTL to the disk file's age so we don't
|
| 284 |
+
# extend an already-aging cache by another full hour.
|
| 285 |
+
_models_dev_cache_time = time.time() - disk_age
|
| 286 |
+
logger.debug(
|
| 287 |
+
"Loaded models.dev from fresh disk cache "
|
| 288 |
+
"(%d providers, age=%.0fs)", len(disk_data), disk_age,
|
| 289 |
+
)
|
| 290 |
+
return _models_dev_cache
|
| 291 |
+
|
| 292 |
+
# Stage 3: network fetch.
|
| 293 |
+
try:
|
| 294 |
+
response = requests.get(MODELS_DEV_URL, timeout=15)
|
| 295 |
+
response.raise_for_status()
|
| 296 |
+
data = response.json()
|
| 297 |
+
if isinstance(data, dict) and data:
|
| 298 |
+
_models_dev_cache = data
|
| 299 |
+
_models_dev_cache_time = time.time()
|
| 300 |
+
_save_disk_cache(data)
|
| 301 |
+
logger.debug(
|
| 302 |
+
"Fetched models.dev registry: %d providers, %d total models",
|
| 303 |
+
len(data),
|
| 304 |
+
sum(len(p.get("models", {})) for p in data.values() if isinstance(p, dict)),
|
| 305 |
+
)
|
| 306 |
+
return data
|
| 307 |
+
except Exception as e:
|
| 308 |
+
logger.debug("Failed to fetch models.dev: %s", e)
|
| 309 |
+
|
| 310 |
+
# Stage 4: network failed — fall back to whatever disk cache exists,
|
| 311 |
+
# even if it's stale. Give it a short 5 min in-mem TTL so we retry
|
| 312 |
+
# the network soon instead of serving stale data for a full hour.
|
| 313 |
+
if not _models_dev_cache:
|
| 314 |
+
_models_dev_cache = _load_disk_cache()
|
| 315 |
+
if _models_dev_cache:
|
| 316 |
+
_models_dev_cache_time = time.time() - _MODELS_DEV_CACHE_TTL + 300
|
| 317 |
+
logger.debug("Loaded models.dev from disk cache (%d providers)", len(_models_dev_cache))
|
| 318 |
+
|
| 319 |
+
return _models_dev_cache
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def lookup_models_dev_context(provider: str, model: str) -> Optional[int]:
|
| 323 |
+
"""Look up context_length for a provider+model combo in models.dev.
|
| 324 |
+
|
| 325 |
+
Returns the context window in tokens, or None if not found.
|
| 326 |
+
Handles case-insensitive matching and filters out context=0 entries.
|
| 327 |
+
"""
|
| 328 |
+
mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)
|
| 329 |
+
if not mdev_provider_id:
|
| 330 |
+
return None
|
| 331 |
+
|
| 332 |
+
data = fetch_models_dev()
|
| 333 |
+
provider_data = data.get(mdev_provider_id)
|
| 334 |
+
if not isinstance(provider_data, dict):
|
| 335 |
+
return None
|
| 336 |
+
|
| 337 |
+
models = provider_data.get("models", {})
|
| 338 |
+
if not isinstance(models, dict):
|
| 339 |
+
return None
|
| 340 |
+
|
| 341 |
+
# Exact match
|
| 342 |
+
entry = models.get(model)
|
| 343 |
+
if entry:
|
| 344 |
+
ctx = _extract_context(entry)
|
| 345 |
+
if ctx:
|
| 346 |
+
return ctx
|
| 347 |
+
|
| 348 |
+
# Case-insensitive match
|
| 349 |
+
model_lower = model.lower()
|
| 350 |
+
for mid, mdata in models.items():
|
| 351 |
+
if mid.lower() == model_lower:
|
| 352 |
+
ctx = _extract_context(mdata)
|
| 353 |
+
if ctx:
|
| 354 |
+
return ctx
|
| 355 |
+
|
| 356 |
+
# Suffix-aware fallback: some providers (e.g. ollama-cloud) store
|
| 357 |
+
# model IDs with :cloud / -cloud suffixes in models.dev while the
|
| 358 |
+
# live API returns bare names. Without this, kimi-k2.6 misses the
|
| 359 |
+
# kimi-k2.6:cloud entry and falls through to stale OpenRouter metadata
|
| 360 |
+
# reporting 32768 — tripping the 64k minimum-context guard.
|
| 361 |
+
# The suffix-stripping in fetch_ollama_cloud_models() handles the
|
| 362 |
+
# model-picker UX; this handles the context-length lookup path.
|
| 363 |
+
for suffix in (":cloud", "-cloud"):
|
| 364 |
+
suffixed_key = model + suffix
|
| 365 |
+
entry = models.get(suffixed_key)
|
| 366 |
+
if entry:
|
| 367 |
+
ctx = _extract_context(entry)
|
| 368 |
+
if ctx:
|
| 369 |
+
return ctx
|
| 370 |
+
# Also try case-insensitive
|
| 371 |
+
suffixed_lower = model_lower + suffix
|
| 372 |
+
for mid, mdata in models.items():
|
| 373 |
+
if mid.lower() == suffixed_lower:
|
| 374 |
+
ctx = _extract_context(mdata)
|
| 375 |
+
if ctx:
|
| 376 |
+
return ctx
|
| 377 |
+
|
| 378 |
+
return None
|
| 379 |
+
|
| 380 |
+
|
| 381 |
+
def _extract_context(entry: Dict[str, Any]) -> Optional[int]:
|
| 382 |
+
"""Extract context_length from a models.dev model entry.
|
| 383 |
+
|
| 384 |
+
Returns None for invalid/zero values (some audio/image models have context=0).
|
| 385 |
+
"""
|
| 386 |
+
if not isinstance(entry, dict):
|
| 387 |
+
return None
|
| 388 |
+
limit = entry.get("limit")
|
| 389 |
+
if not isinstance(limit, dict):
|
| 390 |
+
return None
|
| 391 |
+
ctx = limit.get("context")
|
| 392 |
+
if isinstance(ctx, (int, float)) and ctx > 0:
|
| 393 |
+
return int(ctx)
|
| 394 |
return None
|
| 395 |
|
|
|
|
|
|
|
| 396 |
|
| 397 |
+
# ---------------------------------------------------------------------------
|
| 398 |
+
# Model capability metadata
|
| 399 |
+
# ---------------------------------------------------------------------------
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
@dataclass
|
| 403 |
+
class ModelCapabilities:
|
| 404 |
+
"""Structured capability metadata for a model from models.dev."""
|
| 405 |
+
|
| 406 |
+
supports_tools: bool = True
|
| 407 |
+
supports_vision: bool = False
|
| 408 |
+
supports_reasoning: bool = False
|
| 409 |
+
context_window: int = 200000
|
| 410 |
+
max_output_tokens: int = 8192
|
| 411 |
+
model_family: str = ""
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _get_provider_models(provider: str) -> Optional[Dict[str, Any]]:
|
| 415 |
+
"""Resolve a Hermes provider ID to its models dict from models.dev.
|
| 416 |
+
|
| 417 |
+
Returns the models dict or None if the provider is unknown or has no data.
|
| 418 |
+
"""
|
| 419 |
+
mdev_provider_id = PROVIDER_TO_MODELS_DEV.get(provider)
|
| 420 |
+
if not mdev_provider_id:
|
| 421 |
+
return None
|
| 422 |
+
|
| 423 |
+
data = fetch_models_dev()
|
| 424 |
+
provider_data = data.get(mdev_provider_id)
|
| 425 |
+
if not isinstance(provider_data, dict):
|
| 426 |
+
return None
|
| 427 |
+
|
| 428 |
+
models = provider_data.get("models", {})
|
| 429 |
+
if not isinstance(models, dict):
|
| 430 |
+
return None
|
| 431 |
+
|
| 432 |
+
return models
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def _find_model_entry(models: Dict[str, Any], model: str) -> Optional[Dict[str, Any]]:
|
| 436 |
+
"""Find a model entry by exact match, then case-insensitive fallback."""
|
| 437 |
+
# Exact match
|
| 438 |
+
entry = models.get(model)
|
| 439 |
+
if isinstance(entry, dict):
|
| 440 |
+
return entry
|
| 441 |
+
|
| 442 |
+
# Case-insensitive match
|
| 443 |
+
model_lower = model.lower()
|
| 444 |
+
for mid, mdata in models.items():
|
| 445 |
+
if mid.lower() == model_lower and isinstance(mdata, dict):
|
| 446 |
+
return mdata
|
| 447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
return None
|
| 449 |
|
|
|
|
|
|
|
| 450 |
|
| 451 |
+
def get_model_capabilities(provider: str, model: str) -> Optional[ModelCapabilities]:
|
| 452 |
+
"""Look up full capability metadata from models.dev cache.
|
| 453 |
+
|
| 454 |
+
Uses the existing fetch_models_dev() and PROVIDER_TO_MODELS_DEV mapping.
|
| 455 |
+
Returns None if model not found.
|
| 456 |
+
|
| 457 |
+
Extracts from model entry fields:
|
| 458 |
+
- reasoning (bool) → supports_reasoning
|
| 459 |
+
- tool_call (bool) → supports_tools
|
| 460 |
+
- attachment (bool) → supports_vision
|
| 461 |
+
- limit.context (int) → context_window
|
| 462 |
+
- limit.output (int) → max_output_tokens
|
| 463 |
+
- family (str) → model_family
|
| 464 |
+
"""
|
| 465 |
+
models = _get_provider_models(provider)
|
| 466 |
+
if models is None:
|
| 467 |
+
return None
|
| 468 |
+
|
| 469 |
+
entry = _find_model_entry(models, model)
|
| 470 |
+
if entry is None:
|
| 471 |
+
return None
|
| 472 |
+
|
| 473 |
+
# Extract capability flags (default to False if missing)
|
| 474 |
+
supports_tools = bool(entry.get("tool_call", False))
|
| 475 |
+
# Vision: prefer explicit `modalities.input` when models.dev provides it.
|
| 476 |
+
# The older `attachment` flag can be stale or too broad for image routing;
|
| 477 |
+
# fall back to it only when the input modalities are absent/invalid.
|
| 478 |
+
input_mods = entry.get("modalities", {})
|
| 479 |
+
if isinstance(input_mods, dict):
|
| 480 |
+
input_mods = input_mods.get("input")
|
| 481 |
+
else:
|
| 482 |
+
input_mods = None
|
| 483 |
+
if isinstance(input_mods, list):
|
| 484 |
+
supports_vision = "image" in input_mods
|
| 485 |
+
else:
|
| 486 |
+
supports_vision = bool(entry.get("attachment", False))
|
| 487 |
+
supports_reasoning = bool(entry.get("reasoning", False))
|
| 488 |
+
|
| 489 |
+
# Extract limits
|
| 490 |
+
limit = entry.get("limit", {})
|
| 491 |
+
if not isinstance(limit, dict):
|
| 492 |
+
limit = {}
|
| 493 |
+
|
| 494 |
+
ctx = limit.get("context")
|
| 495 |
+
context_window = int(ctx) if isinstance(ctx, (int, float)) and ctx > 0 else 200000
|
| 496 |
+
|
| 497 |
+
out = limit.get("output")
|
| 498 |
+
max_output_tokens = int(out) if isinstance(out, (int, float)) and out > 0 else 8192
|
| 499 |
+
|
| 500 |
+
model_family = entry.get("family", "") or ""
|
| 501 |
+
|
| 502 |
+
return ModelCapabilities(
|
| 503 |
+
supports_tools=supports_tools,
|
| 504 |
+
supports_vision=supports_vision,
|
| 505 |
+
supports_reasoning=supports_reasoning,
|
| 506 |
+
context_window=context_window,
|
| 507 |
+
max_output_tokens=max_output_tokens,
|
| 508 |
+
model_family=model_family,
|
| 509 |
+
)
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
def list_provider_models(provider: str) -> List[str]:
|
| 513 |
+
"""Return all model IDs for a provider from models.dev.
|
| 514 |
+
|
| 515 |
+
Returns an empty list if the provider is unknown or has no data.
|
| 516 |
+
"""
|
| 517 |
+
from hermes_cli.models import normalize_provider
|
| 518 |
+
provider = normalize_provider(provider) or provider
|
| 519 |
+
|
| 520 |
+
models = _get_provider_models(provider)
|
| 521 |
+
if models is None:
|
| 522 |
+
return []
|
| 523 |
+
return [
|
| 524 |
+
mid for mid in models.keys()
|
| 525 |
+
if not _should_hide_from_provider_catalog(provider, mid)
|
| 526 |
+
]
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
# Patterns that indicate non-agentic or noise models (TTS, embedding,
|
| 530 |
+
# dated preview snapshots, live/streaming-only, image-only).
|
| 531 |
+
import re
|
| 532 |
+
_NOISE_PATTERNS: re.Pattern = re.compile(
|
| 533 |
+
r"-tts\b|embedding|live-|-(preview|exp)-\d{2,4}[-_]|"
|
| 534 |
+
r"-image\b|-image-preview\b|-customtools\b",
|
| 535 |
+
re.IGNORECASE,
|
| 536 |
+
)
|
| 537 |
+
|
| 538 |
+
# Google's live Gemini catalogs currently include a mix of stale slugs and
|
| 539 |
+
# Gemma models whose TPM quotas are too small for normal Hermes agent traffic.
|
| 540 |
+
# Keep capability metadata available for direct/manual use, but hide these from
|
| 541 |
+
# the Gemini model catalogs we surface in setup and model selection.
|
| 542 |
+
_GOOGLE_HIDDEN_MODELS = frozenset({
|
| 543 |
+
# Low-TPM Gemma models that trip Google input-token quota walls under
|
| 544 |
+
# agent-style traffic despite advertising large context windows.
|
| 545 |
+
"gemma-4-31b-it",
|
| 546 |
+
"gemma-4-26b-it",
|
| 547 |
+
"gemma-4-26b-a4b-it",
|
| 548 |
+
"gemma-3-1b",
|
| 549 |
+
"gemma-3-1b-it",
|
| 550 |
+
"gemma-3-2b",
|
| 551 |
+
"gemma-3-2b-it",
|
| 552 |
+
"gemma-3-4b",
|
| 553 |
+
"gemma-3-4b-it",
|
| 554 |
+
"gemma-3-12b",
|
| 555 |
+
"gemma-3-12b-it",
|
| 556 |
+
"gemma-3-27b",
|
| 557 |
+
"gemma-3-27b-it",
|
| 558 |
+
# Stale/retired Google slugs that still surface through models.dev-backed
|
| 559 |
+
# Gemini selection but 404 on the current Google endpoints.
|
| 560 |
+
"gemini-1.5-flash",
|
| 561 |
+
"gemini-1.5-pro",
|
| 562 |
+
"gemini-1.5-flash-8b",
|
| 563 |
+
"gemini-2.0-flash",
|
| 564 |
+
"gemini-2.0-flash-lite",
|
| 565 |
+
})
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def _should_hide_from_provider_catalog(provider: str, model_id: str) -> bool:
|
| 569 |
+
provider_lower = (provider or "").strip().lower()
|
| 570 |
+
model_lower = (model_id or "").strip().lower()
|
| 571 |
+
if provider_lower in {"gemini", "google"} and model_lower in _GOOGLE_HIDDEN_MODELS:
|
| 572 |
+
return True
|
| 573 |
+
return False
|
| 574 |
+
|
| 575 |
+
|
| 576 |
+
def list_agentic_models(provider: str) -> List[str]:
|
| 577 |
+
"""Return model IDs suitable for agentic use from models.dev.
|
| 578 |
+
|
| 579 |
+
Filters for tool_call=True and excludes noise (TTS, embedding,
|
| 580 |
+
dated preview snapshots, live/streaming, image-only models).
|
| 581 |
+
Returns an empty list on any failure.
|
| 582 |
+
"""
|
| 583 |
+
models = _get_provider_models(provider)
|
| 584 |
+
if models is None:
|
| 585 |
+
return []
|
| 586 |
+
|
| 587 |
+
result = []
|
| 588 |
+
for mid, entry in models.items():
|
| 589 |
+
if not isinstance(entry, dict):
|
| 590 |
+
continue
|
| 591 |
+
if _should_hide_from_provider_catalog(provider, mid):
|
| 592 |
+
continue
|
| 593 |
+
if not entry.get("tool_call", False):
|
| 594 |
+
continue
|
| 595 |
+
if _NOISE_PATTERNS.search(mid):
|
| 596 |
+
continue
|
| 597 |
+
result.append(mid)
|
| 598 |
+
return result
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
# ---------------------------------------------------------------------------
|
| 603 |
+
# Rich dataclass constructors — parse raw models.dev JSON into dataclasses
|
| 604 |
+
# ---------------------------------------------------------------------------
|
| 605 |
+
|
| 606 |
+
def _parse_model_info(model_id: str, raw: Dict[str, Any], provider_id: str) -> ModelInfo:
|
| 607 |
+
"""Convert a raw models.dev model entry dict into a ModelInfo dataclass."""
|
| 608 |
+
limit = raw.get("limit") or {}
|
| 609 |
+
if not isinstance(limit, dict):
|
| 610 |
+
limit = {}
|
| 611 |
+
|
| 612 |
+
cost = raw.get("cost") or {}
|
| 613 |
+
if not isinstance(cost, dict):
|
| 614 |
+
cost = {}
|
| 615 |
+
|
| 616 |
+
modalities = raw.get("modalities") or {}
|
| 617 |
+
if not isinstance(modalities, dict):
|
| 618 |
+
modalities = {}
|
| 619 |
+
|
| 620 |
+
input_mods = modalities.get("input") or []
|
| 621 |
+
output_mods = modalities.get("output") or []
|
| 622 |
+
|
| 623 |
+
ctx = limit.get("context")
|
| 624 |
+
ctx_int = int(ctx) if isinstance(ctx, (int, float)) and ctx > 0 else 0
|
| 625 |
+
out = limit.get("output")
|
| 626 |
+
out_int = int(out) if isinstance(out, (int, float)) and out > 0 else 0
|
| 627 |
+
inp = limit.get("input")
|
| 628 |
+
inp_int = int(inp) if isinstance(inp, (int, float)) and inp > 0 else None
|
| 629 |
+
|
| 630 |
+
return ModelInfo(
|
| 631 |
+
id=model_id,
|
| 632 |
+
name=raw.get("name", "") or model_id,
|
| 633 |
+
family=raw.get("family", "") or "",
|
| 634 |
+
provider_id=provider_id,
|
| 635 |
+
reasoning=bool(raw.get("reasoning", False)),
|
| 636 |
+
tool_call=bool(raw.get("tool_call", False)),
|
| 637 |
+
attachment=bool(raw.get("attachment", False)),
|
| 638 |
+
temperature=bool(raw.get("temperature", False)),
|
| 639 |
+
structured_output=bool(raw.get("structured_output", False)),
|
| 640 |
+
open_weights=bool(raw.get("open_weights", False)),
|
| 641 |
+
input_modalities=tuple(input_mods) if isinstance(input_mods, list) else (),
|
| 642 |
+
output_modalities=tuple(output_mods) if isinstance(output_mods, list) else (),
|
| 643 |
+
context_window=ctx_int,
|
| 644 |
+
max_output=out_int,
|
| 645 |
+
max_input=inp_int,
|
| 646 |
+
cost_input=float(cost.get("input", 0) or 0),
|
| 647 |
+
cost_output=float(cost.get("output", 0) or 0),
|
| 648 |
+
cost_cache_read=float(cost["cache_read"]) if "cache_read" in cost and cost["cache_read"] is not None else None,
|
| 649 |
+
cost_cache_write=float(cost["cache_write"]) if "cache_write" in cost and cost["cache_write"] is not None else None,
|
| 650 |
+
knowledge_cutoff=raw.get("knowledge", "") or "",
|
| 651 |
+
release_date=raw.get("release_date", "") or "",
|
| 652 |
+
status=raw.get("status", "") or "",
|
| 653 |
+
interleaved=raw.get("interleaved", False),
|
| 654 |
+
)
|
| 655 |
+
|
| 656 |
+
|
| 657 |
+
def _parse_provider_info(provider_id: str, raw: Dict[str, Any]) -> ProviderInfo:
|
| 658 |
+
"""Convert a raw models.dev provider entry dict into a ProviderInfo."""
|
| 659 |
+
env = raw.get("env") or []
|
| 660 |
+
models = raw.get("models") or {}
|
| 661 |
+
return ProviderInfo(
|
| 662 |
+
id=provider_id,
|
| 663 |
+
name=raw.get("name", "") or provider_id,
|
| 664 |
+
env=tuple(env) if isinstance(env, list) else (),
|
| 665 |
+
api=raw.get("api", "") or "",
|
| 666 |
+
doc=raw.get("doc", "") or "",
|
| 667 |
+
model_count=len(models) if isinstance(models, dict) else 0,
|
| 668 |
+
)
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
# ---------------------------------------------------------------------------
|
| 672 |
+
# Provider-level queries
|
| 673 |
+
# ---------------------------------------------------------------------------
|
| 674 |
+
|
| 675 |
+
def get_provider_info(provider_id: str) -> Optional[ProviderInfo]:
|
| 676 |
+
"""Get full provider metadata from models.dev.
|
| 677 |
+
|
| 678 |
+
Accepts either a Hermes provider ID (e.g. "kilocode") or a models.dev
|
| 679 |
+
ID (e.g. "kilo"). Returns None if the provider is not in the catalog.
|
| 680 |
+
"""
|
| 681 |
+
# Resolve Hermes ID → models.dev ID
|
| 682 |
+
mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id)
|
| 683 |
+
|
| 684 |
+
data = fetch_models_dev()
|
| 685 |
+
raw = data.get(mdev_id)
|
| 686 |
+
if not isinstance(raw, dict):
|
| 687 |
+
return None
|
| 688 |
+
|
| 689 |
+
return _parse_provider_info(mdev_id, raw)
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
# ---------------------------------------------------------------------------
|
| 693 |
+
# Model-level queries (rich ModelInfo)
|
| 694 |
+
# ---------------------------------------------------------------------------
|
| 695 |
+
|
| 696 |
+
def get_model_info(
|
| 697 |
+
provider_id: str, model_id: str
|
| 698 |
+
) -> Optional[ModelInfo]:
|
| 699 |
+
"""Get full model metadata from models.dev.
|
| 700 |
+
|
| 701 |
+
Accepts Hermes or models.dev provider ID. Tries exact match then
|
| 702 |
+
case-insensitive fallback. Returns None if not found.
|
| 703 |
+
"""
|
| 704 |
+
mdev_id = PROVIDER_TO_MODELS_DEV.get(provider_id, provider_id)
|
| 705 |
+
|
| 706 |
+
data = fetch_models_dev()
|
| 707 |
+
pdata = data.get(mdev_id)
|
| 708 |
+
if not isinstance(pdata, dict):
|
| 709 |
+
return None
|
| 710 |
+
|
| 711 |
+
models = pdata.get("models", {})
|
| 712 |
+
if not isinstance(models, dict):
|
| 713 |
+
return None
|
| 714 |
+
|
| 715 |
+
# Exact match
|
| 716 |
+
raw = models.get(model_id)
|
| 717 |
+
if isinstance(raw, dict):
|
| 718 |
+
return _parse_model_info(model_id, raw, mdev_id)
|
| 719 |
|
| 720 |
+
# Case-insensitive fallback
|
| 721 |
+
model_lower = model_id.lower()
|
| 722 |
+
for mid, mdata in models.items():
|
| 723 |
+
if mid.lower() == model_lower and isinstance(mdata, dict):
|
| 724 |
+
return _parse_model_info(mid, mdata, mdev_id)
|
| 725 |
|
|
|
|
|
|
|
|
|
|
| 726 |
return None
|