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.
Files changed (117) hide show
  1. vtx/__init__.py +63 -0
  2. vtx/async_utils.py +40 -0
  3. vtx/builtin_skills/github/SKILL.md +139 -0
  4. vtx/builtin_skills/init/SKILL.md +74 -0
  5. vtx/builtin_skills/review/SKILL.md +73 -0
  6. vtx/builtin_skills/skill-builder/SKILL.md +133 -0
  7. vtx/cli.py +90 -0
  8. vtx/config.py +741 -0
  9. vtx/context/__init__.py +15 -0
  10. vtx/context/_xml.py +8 -0
  11. vtx/context/agent_mds.py +128 -0
  12. vtx/context/git.py +64 -0
  13. vtx/context/loader.py +41 -0
  14. vtx/context/skills.py +423 -0
  15. vtx/core/__init__.py +47 -0
  16. vtx/core/compaction.py +89 -0
  17. vtx/core/errors.py +17 -0
  18. vtx/core/handoff.py +51 -0
  19. vtx/core/scratchpad.py +54 -0
  20. vtx/core/types.py +197 -0
  21. vtx/defaults/__init__.py +0 -0
  22. vtx/defaults/config.yml +53 -0
  23. vtx/diff_display.py +12 -0
  24. vtx/events.py +224 -0
  25. vtx/gh_cli.py +82 -0
  26. vtx/git_branch.py +90 -0
  27. vtx/headless.py +127 -0
  28. vtx/llm/__init__.py +93 -0
  29. vtx/llm/base.py +217 -0
  30. vtx/llm/context_length.py +150 -0
  31. vtx/llm/dynamic_models.py +735 -0
  32. vtx/llm/model_fetcher.py +279 -0
  33. vtx/llm/models.py +78 -0
  34. vtx/llm/oauth/__init__.py +59 -0
  35. vtx/llm/oauth/copilot.py +358 -0
  36. vtx/llm/oauth/dynamic.py +236 -0
  37. vtx/llm/oauth/openai.py +400 -0
  38. vtx/llm/phase_parser.py +270 -0
  39. vtx/llm/provider.yaml +280 -0
  40. vtx/llm/provider_catalog.py +230 -0
  41. vtx/llm/providers/__init__.py +45 -0
  42. vtx/llm/providers/anthropic_sdk.py +256 -0
  43. vtx/llm/providers/mock.py +249 -0
  44. vtx/llm/providers/openai_sdk.py +246 -0
  45. vtx/llm/providers/sanitize.py +14 -0
  46. vtx/llm/sdk/__init__.py +13 -0
  47. vtx/llm/sdk/anthropic.py +382 -0
  48. vtx/llm/sdk/base.py +82 -0
  49. vtx/llm/sdk/openai.py +344 -0
  50. vtx/llm/tool_parser.py +161 -0
  51. vtx/loop.py +272 -0
  52. vtx/notify.py +109 -0
  53. vtx/permissions.py +114 -0
  54. vtx/prompts/__init__.py +45 -0
  55. vtx/prompts/builder.py +86 -0
  56. vtx/prompts/env.py +58 -0
  57. vtx/prompts/identity.py +166 -0
  58. vtx/prompts/tooling.py +36 -0
  59. vtx/py.typed +0 -0
  60. vtx/runtime.py +580 -0
  61. vtx/session.py +868 -0
  62. vtx/sounds/completion.wav +0 -0
  63. vtx/sounds/error.wav +0 -0
  64. vtx/sounds/permission.wav +0 -0
  65. vtx/themes.py +1104 -0
  66. vtx/tools/__init__.py +68 -0
  67. vtx/tools/_read_image.py +106 -0
  68. vtx/tools/_tool_utils.py +90 -0
  69. vtx/tools/base.py +36 -0
  70. vtx/tools/bash.py +371 -0
  71. vtx/tools/edit.py +261 -0
  72. vtx/tools/find.py +132 -0
  73. vtx/tools/read.py +238 -0
  74. vtx/tools/skill.py +278 -0
  75. vtx/tools/web.py +238 -0
  76. vtx/tools/write.py +88 -0
  77. vtx/tools_manager.py +216 -0
  78. vtx/turn.py +789 -0
  79. vtx/ui/__init__.py +0 -0
  80. vtx/ui/agent_runner.py +417 -0
  81. vtx/ui/app.py +665 -0
  82. vtx/ui/app_protocol.py +29 -0
  83. vtx/ui/autocomplete.py +440 -0
  84. vtx/ui/blocks.py +735 -0
  85. vtx/ui/chat.py +613 -0
  86. vtx/ui/clipboard.py +59 -0
  87. vtx/ui/commands/__init__.py +100 -0
  88. vtx/ui/commands/auth.py +306 -0
  89. vtx/ui/commands/base.py +122 -0
  90. vtx/ui/commands/models.py +144 -0
  91. vtx/ui/commands/sessions.py +388 -0
  92. vtx/ui/commands/settings.py +286 -0
  93. vtx/ui/completion_ui.py +313 -0
  94. vtx/ui/export.py +703 -0
  95. vtx/ui/floating_list.py +370 -0
  96. vtx/ui/formatting.py +287 -0
  97. vtx/ui/input.py +760 -0
  98. vtx/ui/latex.py +349 -0
  99. vtx/ui/launch.py +108 -0
  100. vtx/ui/path_complete.py +228 -0
  101. vtx/ui/prompt_history.py +102 -0
  102. vtx/ui/queue_ui.py +141 -0
  103. vtx/ui/selection_mode.py +18 -0
  104. vtx/ui/session_ui.py +235 -0
  105. vtx/ui/startup.py +124 -0
  106. vtx/ui/styles.py +327 -0
  107. vtx/ui/tool_output.py +34 -0
  108. vtx/ui/tree.py +437 -0
  109. vtx/ui/welcome.py +51 -0
  110. vtx/ui/widgets.py +558 -0
  111. vtx/update_check.py +49 -0
  112. vtx/version.py +22 -0
  113. vtx_coding_agent-0.1.1.dist-info/METADATA +259 -0
  114. vtx_coding_agent-0.1.1.dist-info/RECORD +117 -0
  115. vtx_coding_agent-0.1.1.dist-info/WHEEL +4 -0
  116. vtx_coding_agent-0.1.1.dist-info/entry_points.txt +2 -0
  117. 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
+ ]