hugh007 commited on
Commit
7898f53
·
verified ·
1 Parent(s): dd059f0

Upload models_dev_stub.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. models_dev_stub.py +697 -174
models_dev_stub.py CHANGED
@@ -1,203 +1,726 @@
1
- import json, logging
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from dataclasses import dataclass
 
3
  from typing import Any, Dict, List, Optional, Tuple
4
 
 
 
 
 
5
  logger = logging.getLogger(__name__)
6
 
7
- @dataclass
8
- class ModelCapabilities:
9
- supports_tools: bool = True
10
- supports_vision: bool = False
11
- supports_reasoning: bool = False
12
- context_window: int = 200000
13
- max_output_tokens: int = 8192
14
- model_family: str = ""
 
 
 
 
 
 
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
- name: str = ""
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
- def get_provider_info(provider_id: str) -> Optional[ProviderInfo]:
171
- if provider_id == "openrouter":
172
- return ProviderInfo(name="OpenRouter", base_url="https://openrouter.ai/api/v1", is_aggregator=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  return None
174
 
175
- def get_all_models() -> list:
176
- return CURATED_MODELS
177
 
178
- def search_models(query: str = "", provider: str = "") -> list:
179
- if provider and provider != "openrouter": return []
180
- if query: return [m for m in CURATED_MODELS if query.lower() in m["id"].lower()]
181
- return CURATED_MODELS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 list_provider_models(provider_id: str) -> list:
193
- if provider_id == "openrouter":
194
- return [ModelInfo(id=m["id"], name=m["name"], family="", provider_id="openrouter") for m in CURATED_MODELS]
195
- return []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
- def get_providers() -> dict:
198
- return {"openrouter": {"name": "OpenRouter", "base_url": "https://openrouter.ai/api/v1", "is_aggregator": True}}
 
 
 
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