cct-cli 0.7.9.0__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.
- calc_terminal/__init__.py +14 -0
- calc_terminal/__main__.py +14 -0
- calc_terminal/activity.py +1334 -0
- calc_terminal/agent.py +3387 -0
- calc_terminal/agent_runtime.py +519 -0
- calc_terminal/ai_context.py +447 -0
- calc_terminal/ai_modes.py +752 -0
- calc_terminal/ai_personalization.py +286 -0
- calc_terminal/ai_preview_feedback.py +213 -0
- calc_terminal/aicore.py +2572 -0
- calc_terminal/anim.py +367 -0
- calc_terminal/app.py +3685 -0
- calc_terminal/art.py +639 -0
- calc_terminal/atomsim.py +368 -0
- calc_terminal/attachments.py +743 -0
- calc_terminal/benchmark_system.py +414 -0
- calc_terminal/browser/__init__.py +36 -0
- calc_terminal/browser/browser_state.py +346 -0
- calc_terminal/browser/devserver.py +176 -0
- calc_terminal/browser/engine.py +494 -0
- calc_terminal/browser/navigation.py +84 -0
- calc_terminal/browser/preview.py +429 -0
- calc_terminal/browser/preview_entry.py +95 -0
- calc_terminal/browser/project_detector.py +144 -0
- calc_terminal/browser/server.py +449 -0
- calc_terminal/browser/state.py +75 -0
- calc_terminal/browser/watcher.py +99 -0
- calc_terminal/browser_gui/__init__.py +1 -0
- calc_terminal/browser_gui/__main__.py +3 -0
- calc_terminal/browser_gui/launcher.py +173 -0
- calc_terminal/browser_gui/playwright_browser.py +117 -0
- calc_terminal/browser_gui/qt_browser.py +1501 -0
- calc_terminal/browser_gui/webview_browser.py +57 -0
- calc_terminal/capabilities/__init__.py +35 -0
- calc_terminal/capabilities/adapters/__init__.py +33 -0
- calc_terminal/capabilities/adapters/bioinformatics.py +204 -0
- calc_terminal/capabilities/adapters/browser_adapter.py +205 -0
- calc_terminal/capabilities/adapters/filesystem.py +206 -0
- calc_terminal/capabilities/adapters/git_adapter.py +202 -0
- calc_terminal/capabilities/adapters/jupyter_adapter.py +138 -0
- calc_terminal/capabilities/adapters/ml_frameworks.py +158 -0
- calc_terminal/capabilities/adapters/platforms.py +200 -0
- calc_terminal/capabilities/adapters/python_exec.py +93 -0
- calc_terminal/capabilities/adapters/quantum_adapter.py +150 -0
- calc_terminal/capabilities/adapters/scientific_comp.py +123 -0
- calc_terminal/capabilities/adapters/structural_bio.py +161 -0
- calc_terminal/capabilities/adapters/terminal.py +99 -0
- calc_terminal/capabilities/bus.py +178 -0
- calc_terminal/capabilities/discovery.py +207 -0
- calc_terminal/capabilities/schema.py +221 -0
- calc_terminal/cat.ico +0 -0
- calc_terminal/cat_browser.py +2018 -0
- calc_terminal/chat_store.py +703 -0
- calc_terminal/cli.py +1178 -0
- calc_terminal/code_editor.py +640 -0
- calc_terminal/collaboration.py +723 -0
- calc_terminal/commands_data.py +139 -0
- calc_terminal/compatibility_engine.py +352 -0
- calc_terminal/compute/__init__.py +31 -0
- calc_terminal/compute/fabric.py +350 -0
- calc_terminal/config.py +227 -0
- calc_terminal/core/__init__.py +41 -0
- calc_terminal/core/checkpoint.py +156 -0
- calc_terminal/core/input/__init__.py +45 -0
- calc_terminal/core/mode_registry.py +300 -0
- calc_terminal/core/project_graph.py +172 -0
- calc_terminal/core/recovery.py +129 -0
- calc_terminal/core/security_layer.py +112 -0
- calc_terminal/core/task_graph.py +202 -0
- calc_terminal/core/unified_runtime.py +184 -0
- calc_terminal/core/verification.py +257 -0
- calc_terminal/customization.py +1566 -0
- calc_terminal/derivations.py +153 -0
- calc_terminal/device_control.py +263 -0
- calc_terminal/diagnostics/__init__.py +27 -0
- calc_terminal/diagnostics/doctor_engine.py +382 -0
- calc_terminal/diagnostics/self_test.py +247 -0
- calc_terminal/doctor.py +519 -0
- calc_terminal/easter_eggs.py +274 -0
- calc_terminal/editor/__init__.py +1 -0
- calc_terminal/editor/actions.py +263 -0
- calc_terminal/editor/commands.py +160 -0
- calc_terminal/editor/shortcuts.py +226 -0
- calc_terminal/engine.py +259 -0
- calc_terminal/errors.py +120 -0
- calc_terminal/event_stream.py +146 -0
- calc_terminal/eventbus.py +133 -0
- calc_terminal/extensions.py +733 -0
- calc_terminal/fallback_cli.py +1321 -0
- calc_terminal/first_run.py +265 -0
- calc_terminal/fomoji_auth.py +1043 -0
- calc_terminal/formulas.py +82 -0
- calc_terminal/fs_cache.py +121 -0
- calc_terminal/fs_watcher.py +277 -0
- calc_terminal/game.py +193 -0
- calc_terminal/gen1.py +5 -0
- calc_terminal/generators.py +245 -0
- calc_terminal/gestures/__init__.py +42 -0
- calc_terminal/gestures/bindings.py +175 -0
- calc_terminal/gestures/manager.py +477 -0
- calc_terminal/goodbye.py +363 -0
- calc_terminal/gpu3d.py +290 -0
- calc_terminal/graphs.py +358 -0
- calc_terminal/hardware_analyzer.py +440 -0
- calc_terminal/host/__init__.py +30 -0
- calc_terminal/host/browser_manager.py +187 -0
- calc_terminal/host/desktop.py +1386 -0
- calc_terminal/host/launcher.py +395 -0
- calc_terminal/host/terminal.py +279 -0
- calc_terminal/identity.py +216 -0
- calc_terminal/input/__init__.py +54 -0
- calc_terminal/input/capabilities.py +258 -0
- calc_terminal/input/focus.py +87 -0
- calc_terminal/input/gestures.py +64 -0
- calc_terminal/input/pointer.py +114 -0
- calc_terminal/input/touch.py +345 -0
- calc_terminal/keys.py +84 -0
- calc_terminal/live_automation.py +165 -0
- calc_terminal/mathtext.py +433 -0
- calc_terminal/mcp.py +386 -0
- calc_terminal/memory.py +337 -0
- calc_terminal/memory_v2.py +479 -0
- calc_terminal/metrics.py +333 -0
- calc_terminal/mode_detection.py +146 -0
- calc_terminal/model.py +2431 -0
- calc_terminal/model_router.py +665 -0
- calc_terminal/models/__init__.py +0 -0
- calc_terminal/models/active_state.py +187 -0
- calc_terminal/models/dynamic_registry.py +584 -0
- calc_terminal/models/manager.py +781 -0
- calc_terminal/models/model_metadata.json +3526 -0
- calc_terminal/models/profiles.py +194 -0
- calc_terminal/models/registry.py +265 -0
- calc_terminal/models/schema.py +197 -0
- calc_terminal/models/validator.py +287 -0
- calc_terminal/models/verification_engine.py +368 -0
- calc_terminal/native_picker.py +215 -0
- calc_terminal/ollama_catalog.py +279 -0
- calc_terminal/ollama_download.py +233 -0
- calc_terminal/orchestrator.py +304 -0
- calc_terminal/package_research.py +322 -0
- calc_terminal/packages.py +1024 -0
- calc_terminal/pc_specs.py +116 -0
- calc_terminal/permissions.py +334 -0
- calc_terminal/pet.py +106 -0
- calc_terminal/pipeline.py +505 -0
- calc_terminal/platform/__init__.py +491 -0
- calc_terminal/platform/desktop.py +491 -0
- calc_terminal/platform/web.py +781 -0
- calc_terminal/preview/__init__.py +1 -0
- calc_terminal/preview/dev_server.py +303 -0
- calc_terminal/preview/diagnostics.py +131 -0
- calc_terminal/preview/live_reload.py +66 -0
- calc_terminal/preview/manager.py +129 -0
- calc_terminal/project_stats.py +209 -0
- calc_terminal/projects.py +328 -0
- calc_terminal/providers/__init__.py +0 -0
- calc_terminal/providers/adapters/__init__.py +80 -0
- calc_terminal/providers/adapters/anthropic_adapter.py +127 -0
- calc_terminal/providers/adapters/base.py +106 -0
- calc_terminal/providers/adapters/chinese_adapters.py +420 -0
- calc_terminal/providers/adapters/gemini_adapter.py +101 -0
- calc_terminal/providers/adapters/ollama_adapter.py +83 -0
- calc_terminal/providers/adapters/openai_adapter.py +159 -0
- calc_terminal/providers/adapters/other_adapters.py +246 -0
- calc_terminal/providers/anthropic_provider.py +172 -0
- calc_terminal/providers/auto_update.py +416 -0
- calc_terminal/providers/base_provider.py +105 -0
- calc_terminal/providers/discovery_manager.py +207 -0
- calc_terminal/providers/gemini_provider.py +178 -0
- calc_terminal/providers/lifecycle.py +767 -0
- calc_terminal/providers/ollama_adapter.py +707 -0
- calc_terminal/providers/openai_provider.py +254 -0
- calc_terminal/providers/provider_manager.py +1827 -0
- calc_terminal/providers/providers.json +4075 -0
- calc_terminal/reactionsim.py +279 -0
- calc_terminal/registry.py +337 -0
- calc_terminal/report.py +162 -0
- calc_terminal/research/__init__.py +45 -0
- calc_terminal/research/artifact_intel.py +126 -0
- calc_terminal/research/data_lineage.py +123 -0
- calc_terminal/research/experiment_ledger.py +303 -0
- calc_terminal/research/reproducibility.py +131 -0
- calc_terminal/resilience/__init__.py +47 -0
- calc_terminal/resilience/agent_state.py +121 -0
- calc_terminal/resilience/capability_matcher.py +174 -0
- calc_terminal/resilience/circuit_breaker.py +158 -0
- calc_terminal/resilience/failover_engine.py +230 -0
- calc_terminal/resilience/health_monitor.py +192 -0
- calc_terminal/resilience/ollama_adapter.py +125 -0
- calc_terminal/resilience/orchestrator.py +312 -0
- calc_terminal/resilience/types.py +134 -0
- calc_terminal/sandbox.py +98 -0
- calc_terminal/scires.py +558 -0
- calc_terminal/security_scanner.py +126 -0
- calc_terminal/session.py +294 -0
- calc_terminal/sim3d.py +206 -0
- calc_terminal/solver.py +276 -0
- calc_terminal/sound.py +127 -0
- calc_terminal/task_reports.py +287 -0
- calc_terminal/terminal_host.py +201 -0
- calc_terminal/terminal_identity.py +411 -0
- calc_terminal/test_ai_mode_reliability.py +344 -0
- calc_terminal/test_browser.py +368 -0
- calc_terminal/test_code_editor_upgrade.py +485 -0
- calc_terminal/test_customization.py +1148 -0
- calc_terminal/test_customization_ui.py +612 -0
- calc_terminal/test_dynamic_registry.py +304 -0
- calc_terminal/test_extensions.py +436 -0
- calc_terminal/test_overhaul.py +557 -0
- calc_terminal/test_project_detect.py +255 -0
- calc_terminal/test_root_cause_fix.py +527 -0
- calc_terminal/test_stability.py +532 -0
- calc_terminal/test_terminal_identity.py +132 -0
- calc_terminal/test_v079_speed.py +460 -0
- calc_terminal/theme.py +1107 -0
- calc_terminal/timeline.py +139 -0
- calc_terminal/todos.py +246 -0
- calc_terminal/tool_call_normalizer.py +419 -0
- calc_terminal/tui.py +104 -0
- calc_terminal/ui/__init__.py +8 -0
- calc_terminal/ui/activity_panel.py +231 -0
- calc_terminal/ui/activity_stream_panel.py +238 -0
- calc_terminal/ui/animations.py +122 -0
- calc_terminal/ui/app.py +7271 -0
- calc_terminal/ui/attach_panel.py +597 -0
- calc_terminal/ui/attachments.py +424 -0
- calc_terminal/ui/backup_panel.py +810 -0
- calc_terminal/ui/browser_shell.py +887 -0
- calc_terminal/ui/cat_agent.py +357 -0
- calc_terminal/ui/chats_panel.py +899 -0
- calc_terminal/ui/command_palette.py +125 -0
- calc_terminal/ui/command_palette_modal.py +166 -0
- calc_terminal/ui/composer.py +1141 -0
- calc_terminal/ui/context_menu.py +197 -0
- calc_terminal/ui/conversation.py +1435 -0
- calc_terminal/ui/customization_panel.py +1229 -0
- calc_terminal/ui/dashboard.py +404 -0
- calc_terminal/ui/design_system.py +557 -0
- calc_terminal/ui/diff_panel.py +213 -0
- calc_terminal/ui/editor.py +2102 -0
- calc_terminal/ui/empty_state.py +302 -0
- calc_terminal/ui/events.py +487 -0
- calc_terminal/ui/extensions_panel.py +815 -0
- calc_terminal/ui/footer.py +166 -0
- calc_terminal/ui/gestures_panel.py +383 -0
- calc_terminal/ui/goodbye_screen.py +100 -0
- calc_terminal/ui/header.py +1034 -0
- calc_terminal/ui/help_panel.py +254 -0
- calc_terminal/ui/live_activities.py +914 -0
- calc_terminal/ui/mcp_panel.py +570 -0
- calc_terminal/ui/memory_center.py +524 -0
- calc_terminal/ui/mode_colors_panel.py +525 -0
- calc_terminal/ui/nav_screens.py +747 -0
- calc_terminal/ui/ollama_panel.py +536 -0
- calc_terminal/ui/palette.py +221 -0
- calc_terminal/ui/permission_panel.py +269 -0
- calc_terminal/ui/personalization_panel.py +517 -0
- calc_terminal/ui/personalize_center.py +1568 -0
- calc_terminal/ui/preview_panel.py +441 -0
- calc_terminal/ui/resizers.py +402 -0
- calc_terminal/ui/sidebar.py +1285 -0
- calc_terminal/ui/statusbar.py +168 -0
- calc_terminal/ui/theme_css.py +1396 -0
- calc_terminal/ui/thinking.py +226 -0
- calc_terminal/ui/timeline_panel.py +102 -0
- calc_terminal/ui/todo_panel.py +193 -0
- calc_terminal/ui/viewport.py +136 -0
- calc_terminal/ui/vision_panel.py +489 -0
- calc_terminal/ui/welcome_modal.py +343 -0
- calc_terminal/ui/widgets.py +160 -0
- calc_terminal/ui/workspace.py +831 -0
- calc_terminal/viewers/__init__.py +1 -0
- calc_terminal/viewers/document_viewer.py +252 -0
- calc_terminal/viewers/image_viewer.py +241 -0
- calc_terminal/viewers/pdf_viewer.py +203 -0
- calc_terminal/viewers/presentation_viewer.py +164 -0
- calc_terminal/viewers/registry.py +120 -0
- calc_terminal/viewers/spreadsheet_viewer.py +204 -0
- calc_terminal/vision/__init__.py +89 -0
- calc_terminal/vision/analysis.py +194 -0
- calc_terminal/vision/annotations.py +297 -0
- calc_terminal/vision/capture.py +171 -0
- calc_terminal/vision/context.py +231 -0
- calc_terminal/vision/correlation.py +169 -0
- calc_terminal/vision/cursor.py +258 -0
- calc_terminal/vision/events.py +66 -0
- calc_terminal/vision/frame_pipeline.py +259 -0
- calc_terminal/vision/priority.py +218 -0
- calc_terminal/vision/provider.py +180 -0
- calc_terminal/vision/safety.py +149 -0
- calc_terminal/vision/session.py +281 -0
- calc_terminal/vision/verify.py +162 -0
- calc_terminal/vision.py +514 -0
- calc_terminal/vscode_integration.py +113 -0
- calc_terminal/web/__init__.py +8 -0
- calc_terminal/web/cat_runtime.py +710 -0
- calc_terminal/web/server.py +2891 -0
- calc_terminal/web/static/css/app.css +3152 -0
- calc_terminal/web/static/icons/badge-72.png +0 -0
- calc_terminal/web/static/icons/cat.ico +0 -0
- calc_terminal/web/static/icons/icon-128.png +0 -0
- calc_terminal/web/static/icons/icon-144.png +0 -0
- calc_terminal/web/static/icons/icon-152.png +0 -0
- calc_terminal/web/static/icons/icon-192.png +0 -0
- calc_terminal/web/static/icons/icon-384.png +0 -0
- calc_terminal/web/static/icons/icon-512.png +0 -0
- calc_terminal/web/static/icons/icon-72.png +0 -0
- calc_terminal/web/static/icons/icon-96.png +0 -0
- calc_terminal/web/static/icons/icon.svg +34 -0
- calc_terminal/web/static/icons/new-project.png +0 -0
- calc_terminal/web/static/icons/open-project.png +0 -0
- calc_terminal/web/static/index.html +734 -0
- calc_terminal/web/static/js/app.js +2403 -0
- calc_terminal/web/static/manifest.json +88 -0
- calc_terminal/web/static/sw.js +230 -0
- calc_terminal/workflow_engine.py +769 -0
- calc_terminal/workspace.py +593 -0
- calc_terminal/workspace_index.py +385 -0
- cct_cli-0.7.9.0.dist-info/METADATA +210 -0
- cct_cli-0.7.9.0.dist-info/RECORD +325 -0
- cct_cli-0.7.9.0.dist-info/WHEEL +5 -0
- cct_cli-0.7.9.0.dist-info/entry_points.txt +4 -0
- cct_cli-0.7.9.0.dist-info/licenses/LICENSE +21 -0
- cct_cli-0.7.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
"""Dynamic model manager — the future-proof model management core.
|
|
2
|
+
|
|
3
|
+
Priority order for any provider:
|
|
4
|
+
|
|
5
|
+
1. Fetch models from the provider's official API (live).
|
|
6
|
+
2. If fetching fails, use the on-disk cache (cache/models/).
|
|
7
|
+
3. If no cache exists, use the provider's built-in fallback list
|
|
8
|
+
(providers.json -> fallback_models).
|
|
9
|
+
|
|
10
|
+
Every successful live fetch is written to the disk cache with a
|
|
11
|
+
timestamp. Opening /model triggers an automatic background refresh when
|
|
12
|
+
the cache is older than the configured interval, so newly released
|
|
13
|
+
models show up without an application update.
|
|
14
|
+
|
|
15
|
+
Everything is data-driven: provider metadata lives in
|
|
16
|
+
providers/providers.json and curated model metadata lives in
|
|
17
|
+
models/model_metadata.json — no model names are hardcoded in Python
|
|
18
|
+
source code.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import threading
|
|
25
|
+
import time
|
|
26
|
+
from datetime import datetime, timedelta, timezone
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
# ── Paths ──────────────────────────────────────────────────────────────────
|
|
30
|
+
# _PKG_DIR points inside the installed package (site-packages or source
|
|
31
|
+
# checkout) and is correct for READ-ONLY bundled data (providers.json,
|
|
32
|
+
# model_metadata.json). The writable model cache MUST live in the user's
|
|
33
|
+
# app-data dir — never inside site-packages (read-only when installed) and
|
|
34
|
+
# never tied to the git checkout path. first_run.data_dir() is the single
|
|
35
|
+
# source of truth (CCT_DATA_DIR override + OS conventions); _LEGACY_CACHE_DIR
|
|
36
|
+
# preserves reads from older source-checkout runs.
|
|
37
|
+
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
38
|
+
_PKG_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # calc_terminal/
|
|
39
|
+
PROVIDERS_FILE = os.path.join(_PKG_DIR, "providers", "providers.json")
|
|
40
|
+
MODEL_METADATA_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
41
|
+
"model_metadata.json")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _user_cache_dir() -> str:
|
|
45
|
+
try:
|
|
46
|
+
from ..first_run import cache_dir as _app_cache
|
|
47
|
+
|
|
48
|
+
return os.path.join(_app_cache(), "models")
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
base = os.environ.get("CCT_DATA_DIR", "").strip()
|
|
52
|
+
if base:
|
|
53
|
+
return os.path.join(os.path.abspath(base), "cache", "models")
|
|
54
|
+
return os.path.join(os.path.expanduser("~"), ".cct", "cache", "models")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
CACHE_DIR = _user_cache_dir()
|
|
58
|
+
_LEGACY_CACHE_DIR = os.path.join(_ROOT, "cache", "models")
|
|
59
|
+
|
|
60
|
+
# ── Sources ────────────────────────────────────────────────────────────────
|
|
61
|
+
SOURCE_LIVE = "live" # freshly fetched from the provider's API
|
|
62
|
+
SOURCE_CACHED = "cached" # read from the local disk cache
|
|
63
|
+
SOURCE_DEFAULT = "default" # built-in fallback list (providers.json)
|
|
64
|
+
|
|
65
|
+
# ── Category filters (UI + /model search) ──────────────────────────────────
|
|
66
|
+
CATEGORIES = [
|
|
67
|
+
"all", "free", "paid", "latest", "reasoning", "coding", "vision",
|
|
68
|
+
"fast", "long_context", "image", "speech", "embedding", "experimental",
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
_lock = threading.Lock()
|
|
72
|
+
_providers: Optional[list[dict]] = None
|
|
73
|
+
_metadata: Optional[dict] = None
|
|
74
|
+
_metadata_index: Optional[dict] = None
|
|
75
|
+
_sources: dict[str, str] = {} # provider_id -> last source used
|
|
76
|
+
_last_refresh: dict[str, str] = {} # provider_id -> ISO timestamp
|
|
77
|
+
|
|
78
|
+
# ── Loading (JSON = single source of truth) ────────────────────────────────
|
|
79
|
+
|
|
80
|
+
def _default_cache_ttl_hours() -> float:
|
|
81
|
+
"""Configured cache interval in hours (default 24)."""
|
|
82
|
+
try:
|
|
83
|
+
from .. import config as _cfg
|
|
84
|
+
return float(_cfg.load_config().get("model_cache_ttl_hours", 24.0) or 24.0)
|
|
85
|
+
except Exception:
|
|
86
|
+
return 24.0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def load_providers() -> list[dict]:
|
|
90
|
+
"""Load provider metadata from providers.json. Never raises — an
|
|
91
|
+
unreadable file degrades to an empty list."""
|
|
92
|
+
global _providers
|
|
93
|
+
if _providers is not None:
|
|
94
|
+
return _providers
|
|
95
|
+
try:
|
|
96
|
+
with open(PROVIDERS_FILE, "r", encoding="utf-8") as f:
|
|
97
|
+
data = json.load(f)
|
|
98
|
+
_providers = list(data.get("providers", []))
|
|
99
|
+
except Exception:
|
|
100
|
+
_providers = []
|
|
101
|
+
return _providers
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def reload_providers() -> list[dict]:
|
|
105
|
+
"""Force reload of providers.json (used by import/export flows)."""
|
|
106
|
+
global _providers
|
|
107
|
+
_providers = None
|
|
108
|
+
return load_providers()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_provider(provider_id: str) -> Optional[dict]:
|
|
112
|
+
"""Return provider metadata dict or None."""
|
|
113
|
+
for p in load_providers():
|
|
114
|
+
if p.get("id") == provider_id:
|
|
115
|
+
return p
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def default_model_for(provider_id: str, fallback: str = "") -> str:
|
|
120
|
+
"""Data-driven default model for a provider: providers.json
|
|
121
|
+
'default_model' field, else first fallback model, else `fallback`."""
|
|
122
|
+
try:
|
|
123
|
+
provider = get_provider(provider_id)
|
|
124
|
+
if provider:
|
|
125
|
+
dflt = str(provider.get("default_model") or "").strip()
|
|
126
|
+
if dflt:
|
|
127
|
+
return dflt
|
|
128
|
+
fb = [str(m) for m in provider.get("fallback_models", []) if str(m).strip()]
|
|
129
|
+
if fb:
|
|
130
|
+
return fb[0]
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
return fallback
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def list_providers() -> list[dict]:
|
|
137
|
+
return sorted(load_providers(), key=lambda p: p.get("name", "").lower())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _load_metadata() -> dict:
|
|
141
|
+
"""{model_id: metadata_dict} merged from model_metadata.json."""
|
|
142
|
+
global _metadata
|
|
143
|
+
if _metadata is not None:
|
|
144
|
+
return _metadata
|
|
145
|
+
out = {}
|
|
146
|
+
try:
|
|
147
|
+
with open(MODEL_METADATA_FILE, "r", encoding="utf-8") as f:
|
|
148
|
+
data = json.load(f)
|
|
149
|
+
for m in data.get("models", []):
|
|
150
|
+
if m.get("id"):
|
|
151
|
+
out[str(m["id"])] = m
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
_metadata = out
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def get_model_meta(model_id: str, provider_id: str = "") -> Optional[dict]:
|
|
159
|
+
"""Curated metadata for a model. Falls back to matching any provider
|
|
160
|
+
if provider_id is unknown."""
|
|
161
|
+
try:
|
|
162
|
+
from .dynamic_registry import get_registry
|
|
163
|
+
reg = get_registry()
|
|
164
|
+
m = reg.get_model(model_id, provider_id)
|
|
165
|
+
if m:
|
|
166
|
+
return {
|
|
167
|
+
"id": m.model_id,
|
|
168
|
+
"provider": m.provider,
|
|
169
|
+
"display_name": m.display_name,
|
|
170
|
+
"family": m.family,
|
|
171
|
+
"context_length": m.context_window,
|
|
172
|
+
"reasoning": m.has_capability("reasoning"),
|
|
173
|
+
"coding": m.has_capability("coding"),
|
|
174
|
+
"vision": m.has_capability("vision"),
|
|
175
|
+
"embedding": m.has_capability("embedding"),
|
|
176
|
+
"tools": m.has_capability("tools"),
|
|
177
|
+
"streaming": m.has_capability("streaming"),
|
|
178
|
+
"free": m.availability in ("free_api", "free_tier") or m.pricing.get("input_price_per_1m", 0.0) == 0.0,
|
|
179
|
+
"paid": m.pricing.get("input_price_per_1m", 0.0) > 0.0,
|
|
180
|
+
"deprecated": m.status == "deprecated",
|
|
181
|
+
"preview": m.status in ("preview", "beta"),
|
|
182
|
+
"input_price_per_1m": m.pricing.get("input_price_per_1m", 0.0),
|
|
183
|
+
"output_price_per_1m": m.pricing.get("output_price_per_1m", 0.0),
|
|
184
|
+
"reasoning_levels": m.reasoning_levels,
|
|
185
|
+
"recommendation_badges": m.recommendation_badges,
|
|
186
|
+
}
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
meta = _load_metadata()
|
|
190
|
+
entry = meta.get(model_id)
|
|
191
|
+
if entry and (not provider_id or entry.get("provider") == provider_id):
|
|
192
|
+
return entry
|
|
193
|
+
if provider_id and entry and entry.get("provider") != provider_id:
|
|
194
|
+
return None
|
|
195
|
+
return entry
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── Disk cache (cache/models/{provider_id}.json) ───────────────────────────
|
|
199
|
+
|
|
200
|
+
def get_cache_path(provider_id: str) -> str:
|
|
201
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", str(provider_id))
|
|
202
|
+
return os.path.join(CACHE_DIR, f"{safe}.json")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def get_cached(provider_id: str) -> Optional[tuple[list[str], str]]:
|
|
206
|
+
"""(models, fetched_at_iso) from disk, or None.
|
|
207
|
+
|
|
208
|
+
Reads the user app-data cache first, then the legacy source-checkout
|
|
209
|
+
location (so caches written before the pip-installable layout still
|
|
210
|
+
work). Writes always go to the user location (see write_cache)."""
|
|
211
|
+
candidates = [get_cache_path(provider_id)]
|
|
212
|
+
try:
|
|
213
|
+
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", str(provider_id))
|
|
214
|
+
legacy = os.path.join(_LEGACY_CACHE_DIR, f"{safe}.json")
|
|
215
|
+
if legacy not in candidates:
|
|
216
|
+
candidates.append(legacy)
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
for path in candidates:
|
|
220
|
+
try:
|
|
221
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
222
|
+
data = json.load(f)
|
|
223
|
+
models = data.get("models")
|
|
224
|
+
if not isinstance(models, list) or not models:
|
|
225
|
+
continue
|
|
226
|
+
return [str(m) for m in models], str(data.get("fetched_at", ""))
|
|
227
|
+
except Exception:
|
|
228
|
+
continue
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def write_cache(provider_id: str, models: list[str]):
|
|
233
|
+
"""Persist a fetched model list with a timestamp. Never raises."""
|
|
234
|
+
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
235
|
+
payload = {
|
|
236
|
+
"provider": provider_id,
|
|
237
|
+
"fetched_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
238
|
+
"models": sorted(set(models)),
|
|
239
|
+
}
|
|
240
|
+
try:
|
|
241
|
+
tmp = get_cache_path(provider_id) + ".tmp"
|
|
242
|
+
with open(tmp, "w", encoding="utf-8") as f:
|
|
243
|
+
json.dump(payload, f, indent=2)
|
|
244
|
+
os.replace(tmp, get_cache_path(provider_id))
|
|
245
|
+
except Exception:
|
|
246
|
+
pass
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def delete_cache(provider_id: str):
|
|
250
|
+
try:
|
|
251
|
+
path = get_cache_path(provider_id)
|
|
252
|
+
if os.path.exists(path):
|
|
253
|
+
os.remove(path)
|
|
254
|
+
except Exception:
|
|
255
|
+
pass
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def cache_age_hours(provider_id: str) -> Optional[float]:
|
|
259
|
+
"""How old the cached list is, in hours. None if no cache."""
|
|
260
|
+
entry = get_cached(provider_id)
|
|
261
|
+
if not entry:
|
|
262
|
+
return None
|
|
263
|
+
try:
|
|
264
|
+
fetched = datetime.fromisoformat(entry[1])
|
|
265
|
+
if fetched.tzinfo is None:
|
|
266
|
+
fetched = fetched.replace(tzinfo=timezone.utc)
|
|
267
|
+
return max(0.0, (datetime.now(timezone.utc) - fetched).total_seconds() / 3600.0)
|
|
268
|
+
except Exception:
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def cache_is_fresh(provider_id: str) -> bool:
|
|
273
|
+
age = cache_age_hours(provider_id)
|
|
274
|
+
if age is None:
|
|
275
|
+
return False
|
|
276
|
+
return age <= _default_cache_ttl_hours()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# ── Live fetching ──────────────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
def _fetch_openai_compatible(config: dict, timeout: int = 12) -> list[str]:
|
|
282
|
+
"""GET {base_url}/models → {"data": [{"id": ...}]}."""
|
|
283
|
+
import requests
|
|
284
|
+
base_url = (config.get("base_url") or config.get("api_url") or "").rstrip("/")
|
|
285
|
+
if not base_url:
|
|
286
|
+
return []
|
|
287
|
+
headers = {"Content-Type": "application/json"}
|
|
288
|
+
if config.get("api_key"):
|
|
289
|
+
headers["Authorization"] = f"Bearer {config['api_key']}"
|
|
290
|
+
resp = requests.get(f"{base_url}/models", headers=headers, timeout=timeout)
|
|
291
|
+
if resp.status_code >= 400:
|
|
292
|
+
return []
|
|
293
|
+
data = resp.json()
|
|
294
|
+
items = data.get("data", [])
|
|
295
|
+
return sorted({str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")})
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _fetch_gemini(config: dict, timeout: int = 12) -> list[str]:
|
|
299
|
+
"""GET {base}/models?key=... → {"models": [{"name": "models/..."}]}."""
|
|
300
|
+
import requests
|
|
301
|
+
base_url = (config.get("base_url") or "").rstrip("/")
|
|
302
|
+
if not base_url:
|
|
303
|
+
return []
|
|
304
|
+
api_key = config.get("api_key", "")
|
|
305
|
+
resp = requests.get(f"{base_url}/models", params={"key": api_key}, timeout=timeout)
|
|
306
|
+
if resp.status_code >= 400:
|
|
307
|
+
return []
|
|
308
|
+
out = []
|
|
309
|
+
for m in resp.json().get("models", []):
|
|
310
|
+
methods = m.get("supportedGenerationMethods") or []
|
|
311
|
+
if not methods or "generateContent" in methods:
|
|
312
|
+
name = str(m.get("name", ""))
|
|
313
|
+
if "/" in name:
|
|
314
|
+
name = name.split("/")[-1]
|
|
315
|
+
if name:
|
|
316
|
+
out.append(name)
|
|
317
|
+
return sorted(set(out))
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _fetch_anthropic(config: dict, timeout: int = 12) -> list[str]:
|
|
321
|
+
"""GET {base}/models → {"data": [{"id": ...}]} (Anthropic models API)."""
|
|
322
|
+
import requests
|
|
323
|
+
base_url = (config.get("base_url") or "").rstrip("/")
|
|
324
|
+
if not base_url:
|
|
325
|
+
return []
|
|
326
|
+
headers = {"Content-Type": "application/json"}
|
|
327
|
+
if config.get("api_key"):
|
|
328
|
+
headers["x-api-key"] = config["api_key"]
|
|
329
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
330
|
+
resp = requests.get(f"{base_url}/models", headers=headers, timeout=timeout)
|
|
331
|
+
if resp.status_code >= 400:
|
|
332
|
+
return []
|
|
333
|
+
data = resp.json()
|
|
334
|
+
items = data.get("data", [])
|
|
335
|
+
return sorted({str(m.get("id")) for m in items if isinstance(m, dict) and m.get("id")})
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _fetch_ollama(config: dict, timeout: int = 8) -> list[str]:
|
|
339
|
+
"""GET {base}/api/tags → {"models": [{"name": ...}]}."""
|
|
340
|
+
import requests
|
|
341
|
+
base_url = (config.get("base_url") or config.get("ollama_url") or
|
|
342
|
+
"http://localhost:11434").rstrip("/")
|
|
343
|
+
if base_url.endswith("/v1"):
|
|
344
|
+
return _fetch_openai_compatible(config, timeout)
|
|
345
|
+
resp = requests.get(f"{base_url}/api/tags", timeout=timeout)
|
|
346
|
+
if resp.status_code >= 400:
|
|
347
|
+
return []
|
|
348
|
+
models = []
|
|
349
|
+
for m in resp.json().get("models", []):
|
|
350
|
+
name = m.get("name") if isinstance(m, dict) else None
|
|
351
|
+
if name:
|
|
352
|
+
models.append(str(name))
|
|
353
|
+
return sorted(set(models))
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def fetch_live(provider_id: str, config: dict = None) -> list[str]:
|
|
357
|
+
"""Try the official models API for a provider. Prefers registered
|
|
358
|
+
provider SDK classes, falls back to a generic fetch by api_style.
|
|
359
|
+
Returns [] on any failure (callers then fall back to cache/default).
|
|
360
|
+
"""
|
|
361
|
+
cfg = dict(config or {})
|
|
362
|
+
cfg.setdefault("provider", provider_id)
|
|
363
|
+
provider = get_provider(provider_id) or {}
|
|
364
|
+
cfg.setdefault("base_url", provider.get("api_endpoint", ""))
|
|
365
|
+
cfg.setdefault("api_style", provider.get("api_style", "openai"))
|
|
366
|
+
api_style = cfg.get("api_style", "openai")
|
|
367
|
+
|
|
368
|
+
# Only gate on metadata when the provider is actually known — custom
|
|
369
|
+
# providers (arbitrary base_url/api_style from user config) always try.
|
|
370
|
+
if provider and not provider.get("supports_model_listing", False):
|
|
371
|
+
return []
|
|
372
|
+
|
|
373
|
+
try:
|
|
374
|
+
from ..providers.provider_manager import get_provider_class
|
|
375
|
+
cls = get_provider_class(provider_id)
|
|
376
|
+
if cls is not None:
|
|
377
|
+
inst = cls(cfg)
|
|
378
|
+
if hasattr(inst, "fetch_models"):
|
|
379
|
+
models = inst.fetch_models()
|
|
380
|
+
if models:
|
|
381
|
+
return sorted(set(str(m) for m in models))
|
|
382
|
+
return []
|
|
383
|
+
except Exception:
|
|
384
|
+
pass # fall through to generic fetch
|
|
385
|
+
|
|
386
|
+
try:
|
|
387
|
+
if api_style == "openai":
|
|
388
|
+
return _fetch_openai_compatible(cfg)
|
|
389
|
+
if api_style == "gemini":
|
|
390
|
+
return _fetch_gemini(cfg)
|
|
391
|
+
if api_style == "anthropic":
|
|
392
|
+
return _fetch_anthropic(cfg)
|
|
393
|
+
if api_style == "ollama":
|
|
394
|
+
return _fetch_ollama(cfg)
|
|
395
|
+
except Exception:
|
|
396
|
+
return []
|
|
397
|
+
return []
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ── Core resolution (the priority chain) ───────────────────────────────────
|
|
401
|
+
|
|
402
|
+
def _fallback_models(provider_id: str) -> list[str]:
|
|
403
|
+
provider = get_provider(provider_id) or {}
|
|
404
|
+
fallback = [str(m) for m in provider.get("fallback_models", [])]
|
|
405
|
+
if not fallback:
|
|
406
|
+
fallback = ["default"]
|
|
407
|
+
return fallback
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def get_models(provider_id: str, config: dict = None,
|
|
411
|
+
force_refresh: bool = False) -> tuple[list[str], str]:
|
|
412
|
+
"""Resolve the model list for a provider. Uses ProviderDiscoveryManager
|
|
413
|
+
and DynamicModelRegistry with safe fallback to cache/default."""
|
|
414
|
+
with _lock:
|
|
415
|
+
if force_refresh:
|
|
416
|
+
try:
|
|
417
|
+
from ..providers.discovery_manager import get_discovery_manager
|
|
418
|
+
dm = get_discovery_manager()
|
|
419
|
+
discovered = dm.discover_provider(provider_id, config)
|
|
420
|
+
if discovered:
|
|
421
|
+
models = [m.model_id for m in discovered]
|
|
422
|
+
write_cache(provider_id, models)
|
|
423
|
+
_sources[provider_id] = SOURCE_LIVE
|
|
424
|
+
_last_refresh[provider_id] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
425
|
+
return models, SOURCE_LIVE
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
models = fetch_live(provider_id, config)
|
|
429
|
+
if models:
|
|
430
|
+
write_cache(provider_id, models)
|
|
431
|
+
_sources[provider_id] = SOURCE_LIVE
|
|
432
|
+
_last_refresh[provider_id] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
433
|
+
return models, SOURCE_LIVE
|
|
434
|
+
# live failed — fall through to cache then defaults
|
|
435
|
+
|
|
436
|
+
cached = get_cached(provider_id)
|
|
437
|
+
if cached:
|
|
438
|
+
models, fetched_at = cached
|
|
439
|
+
_sources[provider_id] = SOURCE_CACHED
|
|
440
|
+
_last_refresh[provider_id] = fetched_at
|
|
441
|
+
return list(models), SOURCE_CACHED
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
from .dynamic_registry import get_registry
|
|
445
|
+
reg = get_registry()
|
|
446
|
+
reg_models = [m.model_id for m in reg.list_models(provider=provider_id)]
|
|
447
|
+
if reg_models:
|
|
448
|
+
return reg_models, SOURCE_CACHED
|
|
449
|
+
except Exception:
|
|
450
|
+
pass
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
from ..providers.discovery_manager import get_discovery_manager
|
|
454
|
+
dm = get_discovery_manager()
|
|
455
|
+
discovered = dm.discover_provider(provider_id, config)
|
|
456
|
+
if discovered:
|
|
457
|
+
models = [m.model_id for m in discovered]
|
|
458
|
+
write_cache(provider_id, models)
|
|
459
|
+
_sources[provider_id] = SOURCE_LIVE
|
|
460
|
+
_last_refresh[provider_id] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
461
|
+
return models, SOURCE_LIVE
|
|
462
|
+
except Exception:
|
|
463
|
+
pass
|
|
464
|
+
|
|
465
|
+
models = fetch_live(provider_id, config)
|
|
466
|
+
if models:
|
|
467
|
+
write_cache(provider_id, models)
|
|
468
|
+
_sources[provider_id] = SOURCE_LIVE
|
|
469
|
+
_last_refresh[provider_id] = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
470
|
+
return models, SOURCE_LIVE
|
|
471
|
+
|
|
472
|
+
fallback = _fallback_models(provider_id)
|
|
473
|
+
_sources[provider_id] = SOURCE_DEFAULT
|
|
474
|
+
_last_refresh[provider_id] = ""
|
|
475
|
+
return fallback, SOURCE_DEFAULT
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def refresh_models(provider_id: str, config: dict = None) -> tuple[list[str], str]:
|
|
479
|
+
"""Force a fresh fetch from the provider's API. Returns
|
|
480
|
+
(models, source) — falls back to cache/default on failure."""
|
|
481
|
+
return get_models(provider_id, config, force_refresh=True)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def ensure_fresh(provider_id: str, config: dict = None) -> tuple[list[str], str]:
|
|
485
|
+
"""Refresh when the cache is stale, otherwise keep the cache.
|
|
486
|
+
Called automatically whenever the model picker opens."""
|
|
487
|
+
if cache_is_fresh(provider_id) and get_cached(provider_id):
|
|
488
|
+
cached = get_cached(provider_id)
|
|
489
|
+
_sources[provider_id] = SOURCE_CACHED
|
|
490
|
+
_last_refresh[provider_id] = cached[1]
|
|
491
|
+
return list(cached[0]), SOURCE_CACHED
|
|
492
|
+
return refresh_models(provider_id, config)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def get_model_source(provider_id: str) -> str:
|
|
496
|
+
"""'live' | 'cached' | 'default' for the last resolution."""
|
|
497
|
+
return _sources.get(provider_id, SOURCE_DEFAULT)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def get_last_refresh(provider_id: str) -> str:
|
|
501
|
+
return _last_refresh.get(provider_id, "")
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# ── New-model detection ────────────────────────────────────────────────────
|
|
505
|
+
# A model is "new" when it appeared in a live fetch but is not part of the
|
|
506
|
+
# built-in fallback list — so brand-new releases get a NEW badge without any
|
|
507
|
+
# application update.
|
|
508
|
+
|
|
509
|
+
def new_models(provider_id: str) -> set[str]:
|
|
510
|
+
provider = get_provider(provider_id) or {}
|
|
511
|
+
fallback = {str(m) for m in provider.get("fallback_models", [])}
|
|
512
|
+
cached = get_cached(provider_id)
|
|
513
|
+
if not cached:
|
|
514
|
+
return set()
|
|
515
|
+
live_set = set(cached[0])
|
|
516
|
+
new = live_set - fallback
|
|
517
|
+
for m in live_set:
|
|
518
|
+
lower = m.lower()
|
|
519
|
+
if any(k in lower for k in ("latest", "-exp", "exp-", "-preview", "preview-")):
|
|
520
|
+
new.add(m)
|
|
521
|
+
return new
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
# ── Enrichment / badges / filtering ────────────────────────────────────────
|
|
525
|
+
|
|
526
|
+
_FAST_HINTS = ("flash", "mini", "nano", "turbo", "lite", "small", "instant",
|
|
527
|
+
"haiku", "-8b", "-7b", "8b-", "7b-", "speed", "fast", "fx")
|
|
528
|
+
_LONG_CTX_HINTS = ("128k", "200k", "1m", "1000000", "1048576", "2097152", "long", "extended")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _pricing_tier(meta: Optional[dict], model_id: str) -> str:
|
|
532
|
+
if meta:
|
|
533
|
+
if meta.get("free"):
|
|
534
|
+
return "free"
|
|
535
|
+
if meta.get("paid"):
|
|
536
|
+
return "paid"
|
|
537
|
+
lower = model_id.lower()
|
|
538
|
+
if any(k in lower for k in ("free", "open-", "gemma", "llama-4-scout")):
|
|
539
|
+
return "free"
|
|
540
|
+
return "unknown"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _status(meta: Optional[dict]) -> str:
|
|
544
|
+
if not meta:
|
|
545
|
+
return "stable"
|
|
546
|
+
if meta.get("deprecated"):
|
|
547
|
+
return "deprecated"
|
|
548
|
+
if meta.get("preview"):
|
|
549
|
+
return "preview"
|
|
550
|
+
return "stable"
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _capabilities(meta: Optional[dict], model_id: str) -> list[str]:
|
|
554
|
+
if meta:
|
|
555
|
+
caps = []
|
|
556
|
+
for key, label in (("reasoning", "reasoning"), ("vision", "vision"),
|
|
557
|
+
("coding", "coding"), ("embedding", "embeddings"),
|
|
558
|
+
("image_generation", "image_gen"), ("speech", "audio"),
|
|
559
|
+
("tools", "tool_use"), ("streaming", "streaming"),
|
|
560
|
+
("chat", "chat")):
|
|
561
|
+
if meta.get(key):
|
|
562
|
+
caps.append(label)
|
|
563
|
+
if caps:
|
|
564
|
+
return caps
|
|
565
|
+
caps = []
|
|
566
|
+
lower = model_id.lower()
|
|
567
|
+
if "vision" in lower or "4o" in lower or "flash" in lower:
|
|
568
|
+
caps.append("vision")
|
|
569
|
+
if "reason" in lower or "thinking" in lower or "o1" in lower or "o3" in lower or "r1" in lower:
|
|
570
|
+
caps.append("reasoning")
|
|
571
|
+
if "embed" in lower:
|
|
572
|
+
caps.append("embeddings")
|
|
573
|
+
if "whisper" in lower or "tts" in lower:
|
|
574
|
+
caps.append("audio")
|
|
575
|
+
return caps
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def build_badges(provider_id: str, model_id: str, meta: Optional[dict] = None,
|
|
579
|
+
new_set: Optional[set[str]] = None) -> list[str]:
|
|
580
|
+
"""FREE/PAID + NEW/LATEST/RECOMMENDED/BETA/PREVIEW/DEPRECATED badges."""
|
|
581
|
+
badges = []
|
|
582
|
+
tier = _pricing_tier(meta, model_id)
|
|
583
|
+
badges.append("[FREE]" if tier == "free" else "[PAID]" if tier == "paid" else "[?]")
|
|
584
|
+
|
|
585
|
+
new_set = new_set if new_set is not None else set()
|
|
586
|
+
if model_id in new_set:
|
|
587
|
+
badges.append("[NEW]")
|
|
588
|
+
if meta and meta.get("recommended"):
|
|
589
|
+
badges.append("[LATEST]")
|
|
590
|
+
if meta and meta.get("preview"):
|
|
591
|
+
badges.append("[BETA]")
|
|
592
|
+
status = _status(meta)
|
|
593
|
+
if status == "preview" and not (meta and meta.get("preview") and model_id in new_set):
|
|
594
|
+
pass
|
|
595
|
+
if status == "deprecated":
|
|
596
|
+
badges.append("[DEPRECATED]")
|
|
597
|
+
if status == "preview":
|
|
598
|
+
badges.append("[PREVIEW]")
|
|
599
|
+
return badges
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def enrich(provider_id: str, model_ids: list[str],
|
|
603
|
+
with_new: bool = True) -> list[dict]:
|
|
604
|
+
"""Merge dynamic model IDs with curated metadata. Returns sorted list:
|
|
605
|
+
{"id", "display_name", "family", "category", "capabilities",
|
|
606
|
+
"context_length", "free", "paid", "pricing_tier", "status",
|
|
607
|
+
"recommended", "preview", "deprecated", "badges", "is_new"}
|
|
608
|
+
"""
|
|
609
|
+
new_set = new_models(provider_id) if with_new else set()
|
|
610
|
+
result = []
|
|
611
|
+
for mid in model_ids:
|
|
612
|
+
meta = get_model_meta(mid, provider_id)
|
|
613
|
+
entry = {
|
|
614
|
+
"id": mid,
|
|
615
|
+
"display_name": (meta or {}).get("display_name") or mid,
|
|
616
|
+
"provider": provider_id,
|
|
617
|
+
"family": (meta or {}).get("family", ""),
|
|
618
|
+
"category": (meta or {}).get("category", "general"),
|
|
619
|
+
"capabilities": _capabilities(meta, mid),
|
|
620
|
+
"context_length": int((meta or {}).get("context_length", 0) or 0),
|
|
621
|
+
"free": _pricing_tier(meta, mid) == "free",
|
|
622
|
+
"paid": _pricing_tier(meta, mid) == "paid",
|
|
623
|
+
"pricing_tier": _pricing_tier(meta, mid),
|
|
624
|
+
"status": _status(meta),
|
|
625
|
+
"recommended": bool((meta or {}).get("recommended", False)),
|
|
626
|
+
"preview": bool((meta or {}).get("preview", False)),
|
|
627
|
+
"deprecated": bool((meta or {}).get("deprecated", False)),
|
|
628
|
+
"release_date": (meta or {}).get("release_date"),
|
|
629
|
+
"input_price_per_1m": float((meta or {}).get("input_price_per_1m", 0.0) or 0.0),
|
|
630
|
+
"output_price_per_1m": float((meta or {}).get("output_price_per_1m", 0.0) or 0.0),
|
|
631
|
+
"is_new": mid in new_set,
|
|
632
|
+
"badges": [],
|
|
633
|
+
}
|
|
634
|
+
entry["badges"] = build_badges(provider_id, mid, meta, new_set)
|
|
635
|
+
result.append(entry)
|
|
636
|
+
result.sort(key=lambda e: e["id"].lower())
|
|
637
|
+
return result
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _is_latest(entry: dict, new_set: set[str]) -> bool:
|
|
641
|
+
if entry["id"] in new_set:
|
|
642
|
+
return True
|
|
643
|
+
if entry.get("recommended"):
|
|
644
|
+
return True
|
|
645
|
+
if entry.get("status") in ("preview", "experimental"):
|
|
646
|
+
return True
|
|
647
|
+
return False
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _is_fast(entry: dict) -> bool:
|
|
651
|
+
lower = entry["id"].lower()
|
|
652
|
+
return any(h in lower for h in _FAST_HINTS)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _is_long_context(entry: dict) -> bool:
|
|
656
|
+
if entry.get("context_length", 0) >= 100000:
|
|
657
|
+
return True
|
|
658
|
+
lower = entry["id"].lower()
|
|
659
|
+
return any(h in lower for h in _LONG_CTX_HINTS)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def apply_filter(entries: list[dict], filter_id: str,
|
|
663
|
+
new_set: Optional[set[str]] = None) -> list[dict]:
|
|
664
|
+
"""Filter enriched entries by category id (see CATEGORIES)."""
|
|
665
|
+
if not filter_id or filter_id == "all":
|
|
666
|
+
return list(entries)
|
|
667
|
+
new_set = new_set if new_set is not None else set()
|
|
668
|
+
out = []
|
|
669
|
+
for e in entries:
|
|
670
|
+
if filter_id == "free" and e["free"]:
|
|
671
|
+
out.append(e)
|
|
672
|
+
elif filter_id == "paid" and e["paid"]:
|
|
673
|
+
out.append(e)
|
|
674
|
+
elif filter_id == "latest" and _is_latest(e, new_set):
|
|
675
|
+
out.append(e)
|
|
676
|
+
elif filter_id == "reasoning" and "reasoning" in e["capabilities"]:
|
|
677
|
+
out.append(e)
|
|
678
|
+
elif filter_id == "coding" and "coding" in e["capabilities"]:
|
|
679
|
+
out.append(e)
|
|
680
|
+
elif filter_id == "vision" and "vision" in e["capabilities"]:
|
|
681
|
+
out.append(e)
|
|
682
|
+
elif filter_id == "fast" and _is_fast(e):
|
|
683
|
+
out.append(e)
|
|
684
|
+
elif filter_id == "long_context" and _is_long_context(e):
|
|
685
|
+
out.append(e)
|
|
686
|
+
elif filter_id == "image" and "image_gen" in e["capabilities"]:
|
|
687
|
+
out.append(e)
|
|
688
|
+
elif filter_id == "speech" and "audio" in e["capabilities"]:
|
|
689
|
+
out.append(e)
|
|
690
|
+
elif filter_id == "embedding" and "embeddings" in e["capabilities"]:
|
|
691
|
+
out.append(e)
|
|
692
|
+
elif filter_id == "experimental" and e.get("status") in ("preview", "experimental"):
|
|
693
|
+
out.append(e)
|
|
694
|
+
return out
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def search(provider_id: str, query: str, filter_id: str = "all",
|
|
698
|
+
model_ids: Optional[list[str]] = None) -> list[dict]:
|
|
699
|
+
"""Search models by name, family, capability, or provider name.
|
|
700
|
+
Optionally apply a category filter. Returns enriched entries."""
|
|
701
|
+
q = (query or "").strip().lower()
|
|
702
|
+
if model_ids is None:
|
|
703
|
+
models, _ = get_models(provider_id)
|
|
704
|
+
else:
|
|
705
|
+
models = model_ids
|
|
706
|
+
entries = enrich(provider_id, models)
|
|
707
|
+
if q:
|
|
708
|
+
provider = get_provider(provider_id) or {}
|
|
709
|
+
provider_name = (provider.get("name") or "").lower()
|
|
710
|
+
out = []
|
|
711
|
+
for e in entries:
|
|
712
|
+
hay = " ".join([
|
|
713
|
+
e["id"].lower(),
|
|
714
|
+
e.get("family", "").lower(),
|
|
715
|
+
e.get("category", "").lower(),
|
|
716
|
+
" ".join(e["capabilities"]),
|
|
717
|
+
provider_name,
|
|
718
|
+
e.get("display_name", "").lower(),
|
|
719
|
+
])
|
|
720
|
+
if q in hay:
|
|
721
|
+
out.append(e)
|
|
722
|
+
entries = out
|
|
723
|
+
return apply_filter(entries, filter_id)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# ── Status ─────────────────────────────────────────────────────────────────
|
|
727
|
+
|
|
728
|
+
def get_status(provider_id: str, model_id: str = "") -> dict:
|
|
729
|
+
"""Everything /status needs:
|
|
730
|
+
provider, country, current model, model source, last refresh,
|
|
731
|
+
cache age, model count, provider capability flags."""
|
|
732
|
+
provider = get_provider(provider_id) or {"id": provider_id}
|
|
733
|
+
source = get_model_source(provider_id)
|
|
734
|
+
last_refresh = get_last_refresh(provider_id)
|
|
735
|
+
if not last_refresh:
|
|
736
|
+
cached = get_cached(provider_id)
|
|
737
|
+
if cached:
|
|
738
|
+
last_refresh = cached[1]
|
|
739
|
+
source = SOURCE_CACHED
|
|
740
|
+
models, _ = get_models(provider_id)
|
|
741
|
+
age = cache_age_hours(provider_id)
|
|
742
|
+
return {
|
|
743
|
+
"provider_id": provider_id,
|
|
744
|
+
"provider_name": provider.get("name", provider_id),
|
|
745
|
+
"country": provider.get("country", ""),
|
|
746
|
+
"current_model": model_id or "",
|
|
747
|
+
"model_source": source,
|
|
748
|
+
"last_refresh": last_refresh,
|
|
749
|
+
"cache_age_hours": age,
|
|
750
|
+
"model_count": len(models),
|
|
751
|
+
"supports_model_listing": bool(provider.get("supports_model_listing", False)),
|
|
752
|
+
"supports_streaming": bool(provider.get("supports_streaming", False)),
|
|
753
|
+
"supports_vision": bool(provider.get("supports_vision", False)),
|
|
754
|
+
"supports_embeddings": bool(provider.get("supports_embeddings", False)),
|
|
755
|
+
"supports_audio": bool(provider.get("supports_audio", False)),
|
|
756
|
+
"supports_reasoning": bool(provider.get("supports_reasoning", False)),
|
|
757
|
+
"openai_compatible": bool(provider.get("openai_compatible", False)),
|
|
758
|
+
"free_models_available": bool(provider.get("free_models_available", False)),
|
|
759
|
+
"paid_models_available": bool(provider.get("paid_models_available", False)),
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
def provider_badges(provider_id: str) -> list[str]:
|
|
764
|
+
"""Capability badges for a provider row in the picker UI."""
|
|
765
|
+
provider = get_provider(provider_id) or {}
|
|
766
|
+
badges = []
|
|
767
|
+
if provider.get("supports_vision"):
|
|
768
|
+
badges.append("[VISION]")
|
|
769
|
+
if provider.get("supports_embeddings"):
|
|
770
|
+
badges.append("[EMBED]")
|
|
771
|
+
if provider.get("supports_audio"):
|
|
772
|
+
badges.append("[AUDIO]")
|
|
773
|
+
if provider.get("supports_reasoning"):
|
|
774
|
+
badges.append("[REASONING]")
|
|
775
|
+
if provider.get("free_models_available"):
|
|
776
|
+
badges.append("[FREE]")
|
|
777
|
+
if provider.get("paid_models_available"):
|
|
778
|
+
badges.append("[PAID]")
|
|
779
|
+
if not provider.get("needs_key", True):
|
|
780
|
+
badges.append("[NO KEY]")
|
|
781
|
+
return badges
|