vtx-coding-agent 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- vtx/__init__.py +63 -0
- vtx/async_utils.py +40 -0
- vtx/builtin_skills/github/SKILL.md +139 -0
- vtx/builtin_skills/init/SKILL.md +74 -0
- vtx/builtin_skills/review/SKILL.md +73 -0
- vtx/builtin_skills/skill-builder/SKILL.md +133 -0
- vtx/cli.py +90 -0
- vtx/config.py +741 -0
- vtx/context/__init__.py +15 -0
- vtx/context/_xml.py +8 -0
- vtx/context/agent_mds.py +128 -0
- vtx/context/git.py +64 -0
- vtx/context/loader.py +41 -0
- vtx/context/skills.py +423 -0
- vtx/core/__init__.py +47 -0
- vtx/core/compaction.py +89 -0
- vtx/core/errors.py +17 -0
- vtx/core/handoff.py +51 -0
- vtx/core/scratchpad.py +54 -0
- vtx/core/types.py +197 -0
- vtx/defaults/__init__.py +0 -0
- vtx/defaults/config.yml +53 -0
- vtx/diff_display.py +12 -0
- vtx/events.py +224 -0
- vtx/gh_cli.py +82 -0
- vtx/git_branch.py +90 -0
- vtx/headless.py +127 -0
- vtx/llm/__init__.py +93 -0
- vtx/llm/base.py +217 -0
- vtx/llm/context_length.py +150 -0
- vtx/llm/dynamic_models.py +735 -0
- vtx/llm/model_fetcher.py +279 -0
- vtx/llm/models.py +78 -0
- vtx/llm/oauth/__init__.py +59 -0
- vtx/llm/oauth/copilot.py +358 -0
- vtx/llm/oauth/dynamic.py +236 -0
- vtx/llm/oauth/openai.py +400 -0
- vtx/llm/phase_parser.py +270 -0
- vtx/llm/provider.yaml +280 -0
- vtx/llm/provider_catalog.py +230 -0
- vtx/llm/providers/__init__.py +45 -0
- vtx/llm/providers/anthropic_sdk.py +256 -0
- vtx/llm/providers/mock.py +249 -0
- vtx/llm/providers/openai_sdk.py +246 -0
- vtx/llm/providers/sanitize.py +14 -0
- vtx/llm/sdk/__init__.py +13 -0
- vtx/llm/sdk/anthropic.py +382 -0
- vtx/llm/sdk/base.py +82 -0
- vtx/llm/sdk/openai.py +344 -0
- vtx/llm/tool_parser.py +161 -0
- vtx/loop.py +272 -0
- vtx/notify.py +109 -0
- vtx/permissions.py +114 -0
- vtx/prompts/__init__.py +45 -0
- vtx/prompts/builder.py +86 -0
- vtx/prompts/env.py +58 -0
- vtx/prompts/identity.py +166 -0
- vtx/prompts/tooling.py +36 -0
- vtx/py.typed +0 -0
- vtx/runtime.py +580 -0
- vtx/session.py +868 -0
- vtx/sounds/completion.wav +0 -0
- vtx/sounds/error.wav +0 -0
- vtx/sounds/permission.wav +0 -0
- vtx/themes.py +1104 -0
- vtx/tools/__init__.py +68 -0
- vtx/tools/_read_image.py +106 -0
- vtx/tools/_tool_utils.py +90 -0
- vtx/tools/base.py +36 -0
- vtx/tools/bash.py +371 -0
- vtx/tools/edit.py +261 -0
- vtx/tools/find.py +132 -0
- vtx/tools/read.py +238 -0
- vtx/tools/skill.py +278 -0
- vtx/tools/web.py +238 -0
- vtx/tools/write.py +88 -0
- vtx/tools_manager.py +216 -0
- vtx/turn.py +789 -0
- vtx/ui/__init__.py +0 -0
- vtx/ui/agent_runner.py +417 -0
- vtx/ui/app.py +665 -0
- vtx/ui/app_protocol.py +29 -0
- vtx/ui/autocomplete.py +440 -0
- vtx/ui/blocks.py +735 -0
- vtx/ui/chat.py +613 -0
- vtx/ui/clipboard.py +59 -0
- vtx/ui/commands/__init__.py +100 -0
- vtx/ui/commands/auth.py +306 -0
- vtx/ui/commands/base.py +122 -0
- vtx/ui/commands/models.py +144 -0
- vtx/ui/commands/sessions.py +388 -0
- vtx/ui/commands/settings.py +286 -0
- vtx/ui/completion_ui.py +313 -0
- vtx/ui/export.py +703 -0
- vtx/ui/floating_list.py +370 -0
- vtx/ui/formatting.py +287 -0
- vtx/ui/input.py +760 -0
- vtx/ui/latex.py +349 -0
- vtx/ui/launch.py +108 -0
- vtx/ui/path_complete.py +228 -0
- vtx/ui/prompt_history.py +102 -0
- vtx/ui/queue_ui.py +141 -0
- vtx/ui/selection_mode.py +18 -0
- vtx/ui/session_ui.py +235 -0
- vtx/ui/startup.py +124 -0
- vtx/ui/styles.py +327 -0
- vtx/ui/tool_output.py +34 -0
- vtx/ui/tree.py +437 -0
- vtx/ui/welcome.py +51 -0
- vtx/ui/widgets.py +558 -0
- vtx/update_check.py +49 -0
- vtx/version.py +22 -0
- vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
- vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
- vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
- vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
- vtx_coding_agent-0.1.1.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dynamic model fetching and caching for OpenAI-compatible providers.
|
|
3
|
+
|
|
4
|
+
Many modern LLM gateways (Kilo, OpenCode Zen, Airouter, TokenRouter, etc.) expose
|
|
5
|
+
their model catalog via the standard OpenAI ``GET /v1/models`` endpoint and rotate
|
|
6
|
+
it frequently. Hard-coding a model list in ``models.py`` would go stale the same
|
|
7
|
+
day it ships, so this module:
|
|
8
|
+
|
|
9
|
+
1. Derives :data:`DYNAMIC_PROVIDERS` from ``provider.yaml`` — every provider
|
|
10
|
+
with a ``base_url`` becomes a dynamic provider. Providers with
|
|
11
|
+
``api_key_optional: true`` (e.g. ollama) are recognized as not requiring
|
|
12
|
+
any key.
|
|
13
|
+
2. Fetches ``<base_url>/models`` on demand with short retries.
|
|
14
|
+
3. Persists the result to ``~/.vtx/models/<provider>.json`` with a TTL so
|
|
15
|
+
the UI stays responsive when the network is slow or offline.
|
|
16
|
+
4. Falls back to the cached snapshot on network failure (stale-while-revalidate).
|
|
17
|
+
5. Filters free vs. paid models using the same dual heuristic as the
|
|
18
|
+
``@neilurk12/pi-free-models`` extension that ships with pi:
|
|
19
|
+
|
|
20
|
+
- For providers that expose pricing (Kilo/OpenRouter-style), a model is free
|
|
21
|
+
iff ``cost.input == 0 and cost.output == 0`` (or its name contains
|
|
22
|
+
``"free"``).
|
|
23
|
+
- For providers without pricing (OpenCode Zen, etc.), a model is free iff
|
|
24
|
+
its name contains ``"free"`` (case-insensitive).
|
|
25
|
+
|
|
26
|
+
The fetched catalog is merged into the static ``MODELS`` table by
|
|
27
|
+
:func:`get_all_models` so the rest of vtx (model picker, runtime, etc.) does
|
|
28
|
+
not need to know the difference.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
import logging
|
|
36
|
+
import os
|
|
37
|
+
import time
|
|
38
|
+
from dataclasses import asdict, dataclass, field
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
import httpx
|
|
43
|
+
|
|
44
|
+
from .models import ApiType, Model
|
|
45
|
+
|
|
46
|
+
logger = logging.getLogger(__name__)
|
|
47
|
+
|
|
48
|
+
# =================================================================================================
|
|
49
|
+
# Configuration
|
|
50
|
+
# =================================================================================================
|
|
51
|
+
|
|
52
|
+
CACHE_DIR_NAME = "models"
|
|
53
|
+
CACHE_TTL_SECONDS = 60 * 60 * 6 # 6h; matches pi-free-models default behaviour
|
|
54
|
+
FETCH_TIMEOUT_SECONDS = 10.0
|
|
55
|
+
DEFAULT_CONTEXT_WINDOW = 128_000
|
|
56
|
+
DEFAULT_MAX_TOKENS = 16_384
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class DynamicProviderConfig:
|
|
61
|
+
"""Static metadata for a provider whose models are fetched at runtime."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
base_url: str
|
|
65
|
+
env_var: str
|
|
66
|
+
api: ApiType = field(default_factory=lambda: ApiType(ApiType.OPENAI_COMPLETIONS))
|
|
67
|
+
# Extra headers required by some gateways (e.g. Kilo's editor banner).
|
|
68
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
69
|
+
# If True, the provider does not check the Authorization header at all
|
|
70
|
+
# (e.g. a local server like ollama). Requests are sent with no key.
|
|
71
|
+
api_key_optional: bool = False
|
|
72
|
+
# If True, the provider's ``/models`` catalog endpoint is publicly
|
|
73
|
+
# accessible (no key required for discovery). Inference still needs a
|
|
74
|
+
# real key; this only relaxes the auth gate for the catalog fetch.
|
|
75
|
+
openmodelendpoint: bool = False
|
|
76
|
+
# Some gateways return a bare JSON array instead of ``{"data": [...]}``.
|
|
77
|
+
response_format: str = "openai" # "openai" | "bare_array"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
_FAMILY_TO_API: dict[str, ApiType] = {
|
|
81
|
+
"openai_compat": ApiType(ApiType.OPENAI_COMPLETIONS),
|
|
82
|
+
"anthropic": ApiType(ApiType.ANTHROPIC),
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _build_dynamic_providers() -> dict[str, DynamicProviderConfig]:
|
|
87
|
+
"""Build the dynamic-provider registry from ``provider.yaml``.
|
|
88
|
+
|
|
89
|
+
Every provider with a ``base_url`` becomes a dynamic provider. Extra
|
|
90
|
+
request headers and the ``api_key_optional`` flag are passed through
|
|
91
|
+
unchanged — they are how the catalog tells us "this provider does not
|
|
92
|
+
require a key at all" (e.g. ollama).
|
|
93
|
+
"""
|
|
94
|
+
from .provider_catalog import list_providers
|
|
95
|
+
|
|
96
|
+
out: dict[str, DynamicProviderConfig] = {}
|
|
97
|
+
for p in list_providers():
|
|
98
|
+
if not p.base_url:
|
|
99
|
+
continue
|
|
100
|
+
out[p.slug] = DynamicProviderConfig(
|
|
101
|
+
name=p.slug,
|
|
102
|
+
base_url=p.base_url,
|
|
103
|
+
env_var=p.api_key_env or "",
|
|
104
|
+
api=_FAMILY_TO_API.get(p.family, ApiType(ApiType.OPENAI_COMPLETIONS)),
|
|
105
|
+
headers=dict(p.headers),
|
|
106
|
+
api_key_optional=p.api_key_optional,
|
|
107
|
+
openmodelendpoint=p.openmodelendpoint,
|
|
108
|
+
)
|
|
109
|
+
return out
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# Built-in dynamic providers, derived from provider.yaml. Users can register
|
|
113
|
+
# more at runtime by calling :func:`register_dynamic_provider` before the first
|
|
114
|
+
# model lookup.
|
|
115
|
+
DYNAMIC_PROVIDERS: dict[str, DynamicProviderConfig] = _build_dynamic_providers()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def register_dynamic_provider(config: DynamicProviderConfig) -> None:
|
|
119
|
+
"""Register or replace a dynamic provider at runtime."""
|
|
120
|
+
DYNAMIC_PROVIDERS[config.name] = config
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_dynamic_provider(name: str) -> DynamicProviderConfig | None:
|
|
124
|
+
return DYNAMIC_PROVIDERS.get(name)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# =================================================================================================
|
|
128
|
+
# Catalog types
|
|
129
|
+
# =================================================================================================
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass
|
|
133
|
+
class DynamicModelEntry:
|
|
134
|
+
"""A single model returned by a dynamic provider's /models endpoint."""
|
|
135
|
+
|
|
136
|
+
id: str
|
|
137
|
+
name: str
|
|
138
|
+
context_window: int = DEFAULT_CONTEXT_WINDOW
|
|
139
|
+
max_tokens: int = DEFAULT_MAX_TOKENS
|
|
140
|
+
supports_images: bool = False
|
|
141
|
+
supports_thinking: bool = False
|
|
142
|
+
is_free: bool = False
|
|
143
|
+
pricing_known: bool = False # False = name-based free detection only
|
|
144
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class CachedCatalog:
|
|
149
|
+
"""Serialized snapshot of a provider's model catalog."""
|
|
150
|
+
|
|
151
|
+
provider: str
|
|
152
|
+
fetched_at: float
|
|
153
|
+
models: list[DynamicModelEntry]
|
|
154
|
+
|
|
155
|
+
def to_dict(self) -> dict[str, Any]:
|
|
156
|
+
return {
|
|
157
|
+
"provider": self.provider,
|
|
158
|
+
"fetched_at": self.fetched_at,
|
|
159
|
+
"models": [asdict(m) for m in self.models],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def from_dict(cls, data: dict[str, Any]) -> CachedCatalog:
|
|
164
|
+
models_data = data.get("models", [])
|
|
165
|
+
models = [DynamicModelEntry(**m) for m in models_data if isinstance(m, dict)]
|
|
166
|
+
return cls(
|
|
167
|
+
provider=data.get("provider", ""),
|
|
168
|
+
fetched_at=float(data.get("fetched_at", 0.0)),
|
|
169
|
+
models=models,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# =================================================================================================
|
|
174
|
+
# Cache IO
|
|
175
|
+
# =================================================================================================
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def get_cache_dir() -> Path:
|
|
179
|
+
"""Return the directory where per-provider model catalogs are cached."""
|
|
180
|
+
from ..config import get_config_dir
|
|
181
|
+
|
|
182
|
+
base = os.environ.get("VTX_MODELS_CACHE_DIR")
|
|
183
|
+
if base:
|
|
184
|
+
return Path(base)
|
|
185
|
+
return get_config_dir() / CACHE_DIR_NAME
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _cache_path(provider: str) -> Path:
|
|
189
|
+
safe = provider.replace("/", "_").replace("..", "_")
|
|
190
|
+
return get_cache_dir() / f"{safe}.json"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _read_cache(provider: str) -> CachedCatalog | None:
|
|
194
|
+
path = _cache_path(provider)
|
|
195
|
+
try:
|
|
196
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
197
|
+
except FileNotFoundError:
|
|
198
|
+
return None
|
|
199
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
200
|
+
logger.debug("Failed to read model cache for %s: %s", provider, exc)
|
|
201
|
+
return None
|
|
202
|
+
try:
|
|
203
|
+
return CachedCatalog.from_dict(data)
|
|
204
|
+
except (TypeError, ValueError) as exc:
|
|
205
|
+
logger.debug("Discarding corrupt cache for %s: %s", provider, exc)
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _write_cache(catalog: CachedCatalog) -> None:
|
|
210
|
+
path = _cache_path(catalog.provider)
|
|
211
|
+
try:
|
|
212
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
213
|
+
tmp = path.with_suffix(".json.tmp")
|
|
214
|
+
tmp.write_text(json.dumps(catalog.to_dict(), indent=2), encoding="utf-8")
|
|
215
|
+
tmp.replace(path)
|
|
216
|
+
except OSError as exc:
|
|
217
|
+
logger.warning("Failed to write model cache for %s: %s", catalog.provider, exc)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# =================================================================================================
|
|
221
|
+
# Fetching
|
|
222
|
+
# =================================================================================================
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_pricing(raw: dict[str, Any]) -> tuple[float, float, bool]:
|
|
226
|
+
"""Extract (input_cost, output_cost, pricing_known) from a raw model entry."""
|
|
227
|
+
pricing = raw.get("pricing")
|
|
228
|
+
if not isinstance(pricing, dict):
|
|
229
|
+
return 0.0, 0.0, False
|
|
230
|
+
try:
|
|
231
|
+
prompt = float(pricing.get("prompt", 0) or 0)
|
|
232
|
+
completion = float(pricing.get("completion", 0) or 0)
|
|
233
|
+
except (TypeError, ValueError):
|
|
234
|
+
return 0.0, 0.0, False
|
|
235
|
+
return prompt, completion, True
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _supports_images(raw: dict[str, Any]) -> bool:
|
|
239
|
+
arch = raw.get("architecture")
|
|
240
|
+
if isinstance(arch, dict):
|
|
241
|
+
modalities = arch.get("input_modalities")
|
|
242
|
+
if isinstance(modalities, list) and "image" in modalities:
|
|
243
|
+
return True
|
|
244
|
+
modalities = raw.get("input_modalities")
|
|
245
|
+
return isinstance(modalities, list) and "image" in modalities
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _entry_id(raw: dict[str, Any]) -> str:
|
|
249
|
+
model_id = raw.get("id")
|
|
250
|
+
if isinstance(model_id, str) and model_id:
|
|
251
|
+
return model_id
|
|
252
|
+
return raw.get("name") or raw.get("model") or ""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _entry_name(raw: dict[str, Any], model_id: str) -> str:
|
|
256
|
+
name = raw.get("name")
|
|
257
|
+
if isinstance(name, str) and name and name != model_id:
|
|
258
|
+
# Some providers prefix the name with the model id; strip it.
|
|
259
|
+
if ":" in name:
|
|
260
|
+
candidate = name.split(":", 1)[1].strip()
|
|
261
|
+
if candidate:
|
|
262
|
+
return candidate
|
|
263
|
+
return name
|
|
264
|
+
return model_id
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _is_free_model(
|
|
268
|
+
name: str, prompt_cost: float, completion_cost: float, pricing_known: bool
|
|
269
|
+
) -> bool:
|
|
270
|
+
"""Dual heuristic mirroring the pi free-models extension."""
|
|
271
|
+
name_lower = name.lower()
|
|
272
|
+
has_free_keyword = "free" in name_lower
|
|
273
|
+
if pricing_known:
|
|
274
|
+
zero_cost = prompt_cost == 0.0 and completion_cost == 0.0
|
|
275
|
+
return zero_cost or has_free_keyword
|
|
276
|
+
return has_free_keyword
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _raw_model_list(payload: Any, response_format: str) -> list[dict[str, Any]]:
|
|
280
|
+
if isinstance(payload, list):
|
|
281
|
+
return [m for m in payload if isinstance(m, dict)]
|
|
282
|
+
if isinstance(payload, dict):
|
|
283
|
+
data = payload.get("data")
|
|
284
|
+
if isinstance(data, list):
|
|
285
|
+
return [m for m in data if isinstance(m, dict)]
|
|
286
|
+
models = payload.get("models")
|
|
287
|
+
if isinstance(models, list):
|
|
288
|
+
return [m for m in models if isinstance(m, dict)]
|
|
289
|
+
return []
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# Models.dev specifications URL
|
|
293
|
+
MODELS_DEV_URL = "https://models.dev/models.json"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
async def _fetch_models_dev() -> dict[str, Any]:
|
|
297
|
+
path = get_cache_dir() / "models_dev.json"
|
|
298
|
+
try:
|
|
299
|
+
if path.exists():
|
|
300
|
+
stat = path.stat()
|
|
301
|
+
# Cache for 24 hours
|
|
302
|
+
if (time.time() - stat.st_mtime) < (60 * 60 * 24):
|
|
303
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
304
|
+
except Exception as exc:
|
|
305
|
+
logger.debug("Failed to read models.dev cache: %s", exc)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
309
|
+
response = await client.get(MODELS_DEV_URL)
|
|
310
|
+
if response.is_success:
|
|
311
|
+
data = response.json()
|
|
312
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
314
|
+
return data
|
|
315
|
+
except Exception as exc:
|
|
316
|
+
logger.debug("Failed to fetch models.dev: %s", exc)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
if path.exists():
|
|
320
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
return {}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _read_models_dev_sync() -> dict[str, Any]:
|
|
327
|
+
path = get_cache_dir() / "models_dev.json"
|
|
328
|
+
try:
|
|
329
|
+
if path.exists():
|
|
330
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
331
|
+
except Exception:
|
|
332
|
+
pass
|
|
333
|
+
return {}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _find_spec_in_models_dev(model_id: str, models_dev: dict[str, Any]) -> dict[str, Any] | None:
|
|
337
|
+
model_id_lower = model_id.lower()
|
|
338
|
+
for key, spec in models_dev.items():
|
|
339
|
+
key_lower = key.lower()
|
|
340
|
+
if key_lower == model_id_lower:
|
|
341
|
+
return spec
|
|
342
|
+
if "/" in key_lower:
|
|
343
|
+
parts = key_lower.split("/", 1)
|
|
344
|
+
if parts[1] == model_id_lower:
|
|
345
|
+
return spec
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _parse_models(
|
|
350
|
+
raw_models: list[dict[str, Any]], models_dev: dict[str, Any] | None = None
|
|
351
|
+
) -> list[DynamicModelEntry]:
|
|
352
|
+
if models_dev is None:
|
|
353
|
+
models_dev = _read_models_dev_sync()
|
|
354
|
+
|
|
355
|
+
entries: list[DynamicModelEntry] = []
|
|
356
|
+
for raw in raw_models:
|
|
357
|
+
model_id = _entry_id(raw)
|
|
358
|
+
if not model_id:
|
|
359
|
+
continue
|
|
360
|
+
# Skip embedding / image-only models
|
|
361
|
+
output_modalities = raw.get("output_modalities")
|
|
362
|
+
if isinstance(output_modalities, list) and "image" in output_modalities:
|
|
363
|
+
continue
|
|
364
|
+
arch = raw.get("architecture")
|
|
365
|
+
if isinstance(arch, dict):
|
|
366
|
+
modalities = arch.get("output_modalities")
|
|
367
|
+
if isinstance(modalities, list) and "image" in modalities and "text" not in modalities:
|
|
368
|
+
continue
|
|
369
|
+
|
|
370
|
+
prompt_cost, completion_cost, pricing_known = _parse_pricing(raw)
|
|
371
|
+
name = _entry_name(raw, model_id)
|
|
372
|
+
|
|
373
|
+
spec = _find_spec_in_models_dev(model_id, models_dev)
|
|
374
|
+
|
|
375
|
+
# 1. Context window
|
|
376
|
+
context_window = None
|
|
377
|
+
if spec:
|
|
378
|
+
context_window = spec.get("limit", {}).get("context")
|
|
379
|
+
if context_window is None:
|
|
380
|
+
context_window = int(raw.get("context_length") or DEFAULT_CONTEXT_WINDOW)
|
|
381
|
+
else:
|
|
382
|
+
context_window = int(context_window)
|
|
383
|
+
|
|
384
|
+
# 2. Max output tokens
|
|
385
|
+
max_tokens = None
|
|
386
|
+
if spec:
|
|
387
|
+
max_tokens = spec.get("limit", {}).get("output")
|
|
388
|
+
if max_tokens is None:
|
|
389
|
+
max_tokens = int(
|
|
390
|
+
raw.get("max_completion_tokens")
|
|
391
|
+
or (raw.get("top_provider") or {}).get("max_completion_tokens")
|
|
392
|
+
or raw.get("max_tokens")
|
|
393
|
+
or DEFAULT_MAX_TOKENS
|
|
394
|
+
)
|
|
395
|
+
else:
|
|
396
|
+
max_tokens = int(max_tokens)
|
|
397
|
+
|
|
398
|
+
# 3. Supports thinking/reasoning
|
|
399
|
+
supports_thinking = None
|
|
400
|
+
if spec and "reasoning" in spec:
|
|
401
|
+
supports_thinking = bool(spec.get("reasoning"))
|
|
402
|
+
if supports_thinking is None:
|
|
403
|
+
model_id_lower = model_id.lower()
|
|
404
|
+
supports_thinking = bool(
|
|
405
|
+
raw.get("supports_reasoning")
|
|
406
|
+
or raw.get("reasoning")
|
|
407
|
+
or "thinking" in model_id_lower
|
|
408
|
+
or "reasoning" in model_id_lower
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
# 4. Supports images
|
|
412
|
+
supports_images = None
|
|
413
|
+
if spec:
|
|
414
|
+
input_modalities = spec.get("modalities", {}).get("input", [])
|
|
415
|
+
if input_modalities:
|
|
416
|
+
supports_images = "image" in input_modalities
|
|
417
|
+
if supports_images is None:
|
|
418
|
+
supports_images = _supports_images(raw)
|
|
419
|
+
|
|
420
|
+
entry = DynamicModelEntry(
|
|
421
|
+
id=model_id,
|
|
422
|
+
name=name,
|
|
423
|
+
context_window=context_window,
|
|
424
|
+
max_tokens=max_tokens,
|
|
425
|
+
supports_images=supports_images,
|
|
426
|
+
supports_thinking=supports_thinking,
|
|
427
|
+
is_free=_is_free_model(name, prompt_cost, completion_cost, pricing_known),
|
|
428
|
+
pricing_known=pricing_known,
|
|
429
|
+
raw=raw,
|
|
430
|
+
)
|
|
431
|
+
entries.append(entry)
|
|
432
|
+
return entries
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
async def _async_fetch_catalog(
|
|
436
|
+
config: DynamicProviderConfig, *, api_key: str | None, force: bool = False
|
|
437
|
+
) -> CachedCatalog:
|
|
438
|
+
"""Fetch the live model list for a single provider, refreshing the cache."""
|
|
439
|
+
if not force:
|
|
440
|
+
cached = _read_cache(config.name)
|
|
441
|
+
if cached and (time.time() - cached.fetched_at) < CACHE_TTL_SECONDS:
|
|
442
|
+
return cached
|
|
443
|
+
|
|
444
|
+
if not api_key and not config.api_key_optional and not config.openmodelendpoint:
|
|
445
|
+
cached = _read_cache(config.name)
|
|
446
|
+
if cached:
|
|
447
|
+
return cached
|
|
448
|
+
raise RuntimeError(
|
|
449
|
+
f"Authentication required for {config.name}. "
|
|
450
|
+
f"Set {config.env_var} or /login for this provider."
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
headers = {"Accept": "application/json", **config.headers}
|
|
454
|
+
if api_key:
|
|
455
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
456
|
+
|
|
457
|
+
base = config.base_url.rstrip("/")
|
|
458
|
+
url = f"{base}/models"
|
|
459
|
+
|
|
460
|
+
try:
|
|
461
|
+
async with httpx.AsyncClient(timeout=FETCH_TIMEOUT_SECONDS) as client:
|
|
462
|
+
response = await client.get(url, headers=headers)
|
|
463
|
+
except httpx.HTTPError as exc:
|
|
464
|
+
cached = _read_cache(config.name)
|
|
465
|
+
if cached:
|
|
466
|
+
logger.debug("Falling back to stale cache for %s: %s", config.name, exc)
|
|
467
|
+
return cached
|
|
468
|
+
raise RuntimeError(f"Network error fetching models for {config.name}: {exc}") from exc
|
|
469
|
+
|
|
470
|
+
if response.status_code == 401 or response.status_code == 403:
|
|
471
|
+
cached = _read_cache(config.name)
|
|
472
|
+
if cached:
|
|
473
|
+
logger.debug("Auth error fetching %s; using cached snapshot", config.name)
|
|
474
|
+
return cached
|
|
475
|
+
raise RuntimeError(
|
|
476
|
+
f"Authentication required for {config.name}. "
|
|
477
|
+
f"Set {config.env_var} or /login for this provider."
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
if response.status_code >= 500:
|
|
481
|
+
cached = _read_cache(config.name)
|
|
482
|
+
if cached:
|
|
483
|
+
return cached
|
|
484
|
+
raise RuntimeError(
|
|
485
|
+
f"Server error {response.status_code} fetching models for {config.name}"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if not response.is_success:
|
|
489
|
+
cached = _read_cache(config.name)
|
|
490
|
+
if cached:
|
|
491
|
+
return cached
|
|
492
|
+
raise RuntimeError(
|
|
493
|
+
f"Failed to fetch models for {config.name}: "
|
|
494
|
+
f"{response.status_code} {response.reason_phrase}"
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
try:
|
|
498
|
+
payload = response.json()
|
|
499
|
+
except json.JSONDecodeError as exc:
|
|
500
|
+
cached = _read_cache(config.name)
|
|
501
|
+
if cached:
|
|
502
|
+
return cached
|
|
503
|
+
raise RuntimeError(f"Invalid JSON from {config.name} /models: {exc}") from exc
|
|
504
|
+
|
|
505
|
+
raw_models = _raw_model_list(payload, config.response_format)
|
|
506
|
+
if not raw_models:
|
|
507
|
+
cached = _read_cache(config.name)
|
|
508
|
+
if cached:
|
|
509
|
+
return cached
|
|
510
|
+
raise RuntimeError(f"No models returned by {config.name} /models")
|
|
511
|
+
|
|
512
|
+
# Fetch models.dev specs cache
|
|
513
|
+
import contextlib
|
|
514
|
+
|
|
515
|
+
models_dev: dict[str, Any] | None = None
|
|
516
|
+
with contextlib.suppress(Exception):
|
|
517
|
+
models_dev = await _fetch_models_dev()
|
|
518
|
+
|
|
519
|
+
catalog = CachedCatalog(
|
|
520
|
+
provider=config.name, fetched_at=time.time(), models=_parse_models(raw_models, models_dev)
|
|
521
|
+
)
|
|
522
|
+
_write_cache(catalog)
|
|
523
|
+
return catalog
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# Public synchronous entry points
|
|
527
|
+
# -----------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _resolve_api_key(config: DynamicProviderConfig) -> str | None:
|
|
531
|
+
# Delegate to the auth module so the env-var/stored-key priority lives in
|
|
532
|
+
# exactly one place. Providers with api_key_optional (e.g. ollama) end
|
|
533
|
+
# up with no key here; the caller is responsible for skipping the
|
|
534
|
+
# Authorization header in that case.
|
|
535
|
+
from .oauth.dynamic import get_dynamic_api_key
|
|
536
|
+
|
|
537
|
+
return get_dynamic_api_key(config.name)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def get_provider_models(provider: str, *, force_refresh: bool = False) -> list[DynamicModelEntry]:
|
|
541
|
+
"""Return the cached/fetched model list for a single provider (sync)."""
|
|
542
|
+
config = DYNAMIC_PROVIDERS.get(provider)
|
|
543
|
+
if config is None:
|
|
544
|
+
return []
|
|
545
|
+
|
|
546
|
+
# If not forcing a refresh, return cached models if they exist.
|
|
547
|
+
# In an active event loop (like the Textual app), we must avoid blocking/asyncio.run()
|
|
548
|
+
# and always return the cache.
|
|
549
|
+
if not force_refresh:
|
|
550
|
+
cached = _read_cache(config.name)
|
|
551
|
+
if cached:
|
|
552
|
+
try:
|
|
553
|
+
asyncio.get_running_loop()
|
|
554
|
+
return list(cached.models)
|
|
555
|
+
except RuntimeError:
|
|
556
|
+
# No running loop, fallback to TTL check
|
|
557
|
+
if (time.time() - cached.fetched_at) < CACHE_TTL_SECONDS:
|
|
558
|
+
return list(cached.models)
|
|
559
|
+
|
|
560
|
+
api_key = _resolve_api_key(config)
|
|
561
|
+
try:
|
|
562
|
+
catalog = asyncio.run(_async_fetch_catalog(config, api_key=api_key, force=force_refresh))
|
|
563
|
+
except RuntimeError as exc:
|
|
564
|
+
logger.debug("Provider %s unavailable: %s", provider, exc)
|
|
565
|
+
# Fallback to cache on error
|
|
566
|
+
cached = _read_cache(config.name)
|
|
567
|
+
if cached:
|
|
568
|
+
return list(cached.models)
|
|
569
|
+
return []
|
|
570
|
+
return list(catalog.models)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
async def aget_provider_models(
|
|
574
|
+
provider: str, *, force_refresh: bool = False
|
|
575
|
+
) -> list[DynamicModelEntry]:
|
|
576
|
+
"""Async variant of :func:`get_provider_models`."""
|
|
577
|
+
config = DYNAMIC_PROVIDERS.get(provider)
|
|
578
|
+
if config is None:
|
|
579
|
+
return []
|
|
580
|
+
api_key = _resolve_api_key(config)
|
|
581
|
+
catalog = await _async_fetch_catalog(config, api_key=api_key, force=force_refresh)
|
|
582
|
+
return list(catalog.models)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def refresh_provider(provider: str) -> int:
|
|
586
|
+
"""Force-refresh a single provider's cache. Returns number of models cached."""
|
|
587
|
+
config = DYNAMIC_PROVIDERS.get(provider)
|
|
588
|
+
if config is None:
|
|
589
|
+
raise ValueError(f"Unknown dynamic provider: {provider}")
|
|
590
|
+
api_key = _resolve_api_key(config)
|
|
591
|
+
catalog = asyncio.run(_async_fetch_catalog(config, api_key=api_key, force=True))
|
|
592
|
+
return len(catalog.models)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def refresh_all_providers() -> dict[str, int]:
|
|
596
|
+
"""Force-refresh every known dynamic provider. Returns {name: model_count}."""
|
|
597
|
+
results: dict[str, int] = {}
|
|
598
|
+
for name, config in DYNAMIC_PROVIDERS.items():
|
|
599
|
+
api_key = _resolve_api_key(config)
|
|
600
|
+
try:
|
|
601
|
+
catalog = asyncio.run(_async_fetch_catalog(config, api_key=api_key, force=True))
|
|
602
|
+
except RuntimeError as exc:
|
|
603
|
+
logger.debug("Skipping %s during refresh: %s", name, exc)
|
|
604
|
+
results[name] = 0
|
|
605
|
+
continue
|
|
606
|
+
results[name] = len(catalog.models)
|
|
607
|
+
return results
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# =================================================================================================
|
|
611
|
+
# Integration with the static MODELS table
|
|
612
|
+
# =================================================================================================
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _to_static_model(provider: str, entry: DynamicModelEntry) -> Model:
|
|
616
|
+
config = DYNAMIC_PROVIDERS[provider]
|
|
617
|
+
|
|
618
|
+
# Start with entry values
|
|
619
|
+
max_tokens = entry.max_tokens
|
|
620
|
+
supports_images = entry.supports_images
|
|
621
|
+
supports_thinking = entry.supports_thinking
|
|
622
|
+
context_window = entry.context_window
|
|
623
|
+
|
|
624
|
+
from .context_length import context_length_manager
|
|
625
|
+
|
|
626
|
+
limits = context_length_manager.get_limits(entry.id)
|
|
627
|
+
is_matched = entry.id in context_length_manager._limits or any(
|
|
628
|
+
entry.id.lower() in k.lower() or k.lower() in entry.id.lower()
|
|
629
|
+
for k in context_length_manager._limits
|
|
630
|
+
)
|
|
631
|
+
|
|
632
|
+
if is_matched:
|
|
633
|
+
if context_window == DEFAULT_CONTEXT_WINDOW or context_window is None:
|
|
634
|
+
context_window = limits.context
|
|
635
|
+
if max_tokens == DEFAULT_MAX_TOKENS or max_tokens == 0:
|
|
636
|
+
max_tokens = limits.output
|
|
637
|
+
if not supports_thinking:
|
|
638
|
+
supports_thinking = limits.supports_reasoning
|
|
639
|
+
if not supports_images:
|
|
640
|
+
supports_images = limits.supports_vision
|
|
641
|
+
supports_tools = limits.supports_tools
|
|
642
|
+
supports_audio = limits.supports_audio
|
|
643
|
+
else:
|
|
644
|
+
supports_tools = True
|
|
645
|
+
supports_audio = False
|
|
646
|
+
|
|
647
|
+
return Model(
|
|
648
|
+
id=entry.id,
|
|
649
|
+
provider=provider,
|
|
650
|
+
api=config.api,
|
|
651
|
+
base_url=config.base_url,
|
|
652
|
+
max_tokens=max_tokens,
|
|
653
|
+
supports_images=supports_images,
|
|
654
|
+
supports_thinking=supports_thinking,
|
|
655
|
+
context_window=context_window,
|
|
656
|
+
supports_tools=supports_tools,
|
|
657
|
+
supports_audio=supports_audio,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def get_dynamic_models(force_refresh: bool = False) -> list[Model]:
|
|
662
|
+
"""Return all dynamic models converted to the static :class:`Model` shape."""
|
|
663
|
+
out: list[Model] = []
|
|
664
|
+
for provider in DYNAMIC_PROVIDERS:
|
|
665
|
+
for entry in get_provider_models(provider, force_refresh=force_refresh):
|
|
666
|
+
out.append(_to_static_model(provider, entry))
|
|
667
|
+
return out
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def find_dynamic_model(model_id: str, provider: str | None = None) -> Model | None:
|
|
671
|
+
"""Look up a single model in the dynamic catalog (cache-only, sync)."""
|
|
672
|
+
providers: list[str]
|
|
673
|
+
if provider:
|
|
674
|
+
if provider not in DYNAMIC_PROVIDERS:
|
|
675
|
+
return None
|
|
676
|
+
providers = [provider]
|
|
677
|
+
else:
|
|
678
|
+
providers = list(DYNAMIC_PROVIDERS)
|
|
679
|
+
|
|
680
|
+
for name in providers:
|
|
681
|
+
cached = _read_cache(name)
|
|
682
|
+
if cached is None:
|
|
683
|
+
continue
|
|
684
|
+
for entry in cached.models:
|
|
685
|
+
if entry.id == model_id:
|
|
686
|
+
return _to_static_model(name, entry)
|
|
687
|
+
return None
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def get_dynamic_provider_headers(provider: str) -> dict[str, str]:
|
|
691
|
+
"""Return the default headers a provider requires (e.g. Kilo banner)."""
|
|
692
|
+
config = DYNAMIC_PROVIDERS.get(provider)
|
|
693
|
+
return dict(config.headers) if config else {}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def get_dynamic_model_ids(force_refresh: bool = False) -> dict[str, list[str]]:
|
|
697
|
+
"""Return ``{provider: [model_id, ...]}`` for the dynamic catalog."""
|
|
698
|
+
result: dict[str, list[str]] = {}
|
|
699
|
+
for provider in DYNAMIC_PROVIDERS:
|
|
700
|
+
result[provider] = [
|
|
701
|
+
m.id for m in get_provider_models(provider, force_refresh=force_refresh)
|
|
702
|
+
]
|
|
703
|
+
return result
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def get_all_models_with_dynamic(force_refresh: bool = False) -> list[Model]:
|
|
707
|
+
"""Return the catalog merged with freshly-fetched dynamic models."""
|
|
708
|
+
from .provider_catalog import get_all_catalog_models
|
|
709
|
+
|
|
710
|
+
merged: list[Model] = get_all_catalog_models()
|
|
711
|
+
merged.extend(get_dynamic_models(force_refresh=force_refresh))
|
|
712
|
+
return merged
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
__all__ = [
|
|
716
|
+
"CACHE_DIR_NAME",
|
|
717
|
+
"CACHE_TTL_SECONDS",
|
|
718
|
+
"DYNAMIC_PROVIDERS",
|
|
719
|
+
"FETCH_TIMEOUT_SECONDS",
|
|
720
|
+
"CachedCatalog",
|
|
721
|
+
"DynamicModelEntry",
|
|
722
|
+
"DynamicProviderConfig",
|
|
723
|
+
"aget_provider_models",
|
|
724
|
+
"find_dynamic_model",
|
|
725
|
+
"get_all_models_with_dynamic",
|
|
726
|
+
"get_cache_dir",
|
|
727
|
+
"get_dynamic_model_ids",
|
|
728
|
+
"get_dynamic_models",
|
|
729
|
+
"get_dynamic_provider",
|
|
730
|
+
"get_dynamic_provider_headers",
|
|
731
|
+
"get_provider_models",
|
|
732
|
+
"refresh_all_providers",
|
|
733
|
+
"refresh_provider",
|
|
734
|
+
"register_dynamic_provider",
|
|
735
|
+
]
|