File size: 10,304 Bytes
116524e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
"""Model registry — discovery, validation, and provider detection.



Delegates entirely to LiteLLM for provider detection, environment

validation, and model discovery. No external API calls for discovery —

only ``validate_connection`` makes a real (tiny) LLM call.

"""

from __future__ import annotations

import logging
import os
import time
from dataclasses import dataclass
from typing import Any

logger = logging.getLogger(__name__)


def _litellm():
    """Return the litellm module, importing it on first call."""
    global _litellm_mod
    try:
        return _litellm_mod  # type: ignore[name-defined]
    except NameError:
        pass
    try:
        import litellm as _mod

        _litellm_mod = _mod
        return _mod
    except ImportError:
        _litellm_mod = None
        return None


# Example model strings per provider (for user guidance in the CLI)
PROVIDER_MODEL_EXAMPLES: dict[str, str] = {
    "openai": "gpt-4o-mini",
    "anthropic": "claude-sonnet-4-20250514",
    "gemini": "gemini/gemini-2.0-flash",
    "deepseek": "deepseek/deepseek-chat",
    "groq": "groq/llama-3.1-70b",
    "bedrock": "us.anthropic.claude-haiku-4-5-20251001-v1:0",
    "ollama": "ollama/llama2",
    "azure": "azure/gpt-4",
    "openrouter": "openrouter/anthropic/claude-3.5-sonnet",
}


# ---------------------------------------------------------------------------
# Data types
# ---------------------------------------------------------------------------


@dataclass
class ValidationResult:
    """Result of a model + key validation."""

    success: bool
    model: str = ""
    provider: str = ""
    latency_ms: int = 0
    error: str = ""


@dataclass
class ModelInfo:
    """Metadata about a model from LiteLLM's registry."""

    model: str
    provider: str
    max_input_tokens: int | None = None
    max_output_tokens: int | None = None
    input_cost_per_m: float | None = None  # per million tokens
    output_cost_per_m: float | None = None
    key_found: bool = False


# ---------------------------------------------------------------------------
# Provider detection (delegated to LiteLLM)
# ---------------------------------------------------------------------------


def get_provider(model: str) -> str:
    """Return the provider name for a model string, or 'unknown'."""
    ll = _litellm()
    if ll is None:
        raise ImportError("LiteLLM is required for model validation.")

    try:
        _, provider, _, _ = ll.get_llm_provider(model)
    except Exception as e:
        logger.debug(
            "Could not detect provider for %r (%s): %s", model, type(e).__name__, e
        )
        provider = "unknown"

    return provider


def get_missing_keys(model: str) -> list[str]:
    """Return env var names that LiteLLM says are missing for *model*."""
    ll = _litellm()
    if ll is None:
        return []

    try:
        result = ll.validate_environment(model=model)
        return result.get("missing_keys", [])
    except Exception as e:
        logger.debug(
            "Could not validate environment for %r (%s): %s", model, type(e).__name__, e
        )
        return []


def keys_are_set(model: str) -> bool:
    """Check whether the required keys for *model* are in the environment."""
    return len(get_missing_keys(model)) == 0


# ---------------------------------------------------------------------------
# Connection validation
# ---------------------------------------------------------------------------


def validate_connection(model: str, api_key: str | None = None) -> ValidationResult:
    """Make a minimal LLM call to verify model + key work.



    Sends a 3-token request ("Say 'ok'") to confirm authentication,

    model availability, and network connectivity.



    Args:

        model: LiteLLM model string.

        api_key: Explicit key, or None to use environment.

    """
    ll = _litellm()
    if ll is None:
        return ValidationResult(
            success=False, model=model, error="LiteLLM is not installed."
        )

    call_params: dict[str, Any] = {
        "model": model,
        "messages": [{"role": "user", "content": "Say 'ok'"}],
        "max_tokens": 3,
        "temperature": 0.0,
        "timeout": 15,
    }
    if api_key:
        call_params["api_key"] = api_key

    # Suppress LiteLLM's noisy debug output during validation
    prev_verbose = getattr(ll, "suppress_debug_info", False)
    ll.suppress_debug_info = True

    start = time.monotonic()
    try:
        response = ll.completion(**call_params)
        elapsed_ms = int((time.monotonic() - start) * 1000)

        provider = "unknown"
        if hasattr(response, "_hidden_params"):
            provider = response._hidden_params.get("custom_llm_provider", "unknown")

        return ValidationResult(
            success=True,
            model=model,
            provider=provider,
            latency_ms=elapsed_ms,
        )
    except ll.AuthenticationError:
        return ValidationResult(success=False, model=model, error="Invalid API key.")
    except ll.NotFoundError:
        return ValidationResult(
            success=False,
            model=model,
            error=f"Model '{model}' not found at the provider.",
        )
    except ll.APIConnectionError:
        return ValidationResult(
            success=False,
            model=model,
            error="Could not connect to the provider.",
        )
    except Exception as e:
        return ValidationResult(success=False, model=model, error=str(e))
    finally:
        ll.suppress_debug_info = prev_verbose


# ---------------------------------------------------------------------------
# Model search / discovery
# ---------------------------------------------------------------------------


PROVIDER_KEY_ENV: dict[str, str | list[str]] = {
    "openai": "OPENAI_API_KEY",
    "anthropic": "ANTHROPIC_API_KEY",
    "azure": "AZURE_API_KEY",
    "gemini": "GEMINI_API_KEY",
    "deepseek": "DEEPSEEK_API_KEY",
    "groq": "GROQ_API_KEY",
    "bedrock": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_REGION_NAME"],
    "bedrock_converse": [
        "AWS_ACCESS_KEY_ID",
        "AWS_SECRET_ACCESS_KEY",
        "AWS_REGION_NAME",
    ],
    "vertex_ai": "GOOGLE_APPLICATION_CREDENTIALS",
    "cohere": "COHERE_API_KEY",
    "mistral": "MISTRAL_API_KEY",
    "openrouter": "OPENROUTER_API_KEY",
    "together_ai": "TOGETHERAI_API_KEY",
    "fireworks_ai": "FIREWORKS_AI_API_KEY",
    "replicate": "REPLICATE_API_KEY",
    "huggingface": "HUGGINGFACE_API_KEY",
    "perplexity": "PERPLEXITYAI_API_KEY",
    "anyscale": "ANYSCALE_API_KEY",
}


_PROVIDER_ALT_KEYS: dict[str, list[str]] = {
    "bedrock_converse": ["AWS_BEARER_TOKEN_BEDROCK"],
}


def _quick_key_check(provider: str) -> bool:
    """Fast check: are the required env vars set for this provider?"""
    env_var = PROVIDER_KEY_ENV.get(provider)
    if env_var is not None:
        if isinstance(env_var, list):
            if all(bool(os.environ.get(v)) for v in env_var):
                return True
        elif bool(os.environ.get(env_var)):
            return True

    # Alternative auth (e.g. bearer token for Bedrock)
    alt_vars = _PROVIDER_ALT_KEYS.get(provider)
    if alt_vars:
        return any(bool(os.environ.get(v)) for v in alt_vars)

    return False


def search_models(

    query: str = "",

    provider: str | None = None,

    chat_only: bool = True,

    limit: int = 20,

) -> tuple[list[ModelInfo], int]:
    """Search LiteLLM's model registry.



    Args:

        query: Substring to match against model names.

        provider: Filter to a specific provider.

        chat_only: Only return chat/completion models.

        limit: Maximum results.



    Returns:

        (results, total_matches) — results capped at *limit*,

        total_matches is the full count of matching models.

    """
    ll = _litellm()
    if ll is None:
        return [], 0

    results: list[ModelInfo] = []
    total = 0
    terms = query.lower().split() if query else []
    for model_id, info in ll.model_cost.items():
        if chat_only and info.get("mode") != "chat":
            continue
        if provider and info.get("litellm_provider") != provider:
            continue
        model_lower = model_id.lower()
        if terms and not all(t in model_lower for t in terms):
            continue

        total += 1

        if len(results) >= limit:
            continue  # keep counting total

        prov = info.get("litellm_provider", "unknown")
        input_cost = info.get("input_cost_per_token")
        output_cost = info.get("output_cost_per_token")

        # Fast key check — just look for the provider's standard env var.
        # We avoid litellm.validate_environment() here because it's slow
        # and some providers (e.g. GitHub Copilot) trigger interactive auth.
        key_found = _quick_key_check(prov)

        results.append(
            ModelInfo(
                model=model_id,
                provider=prov,
                max_input_tokens=info.get("max_input_tokens"),
                max_output_tokens=info.get("max_output_tokens"),
                input_cost_per_m=input_cost * 1_000_000 if input_cost else None,
                output_cost_per_m=output_cost * 1_000_000 if output_cost else None,
                key_found=key_found,
            )
        )

    return results, total


def suggest_models(typo: str, limit: int = 5) -> list[str]:
    """Return model names similar to *typo* (simple substring matching)."""
    ll = _litellm()
    if ll is None:
        return []

    candidates: list[str] = []
    typo_lower = typo.lower()

    for model_id, info in ll.model_cost.items():
        if info.get("mode") != "chat":
            continue
        if model_id.lower().startswith(typo_lower):
            candidates.append(model_id)
        elif typo_lower in model_id.lower():
            candidates.append(model_id)
        if len(candidates) >= limit:
            break

    return candidates