Spaces:
Running
Running
| import argparse | |
| import base64 | |
| import json | |
| import os | |
| import re | |
| import tempfile | |
| import time | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| from typing import Any | |
| from urllib.error import HTTPError, URLError | |
| from urllib.request import Request, urlopen | |
| DEFAULT_OUTPUT = Path("config/usable-models.json") | |
| def utc_now() -> str: | |
| return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") | |
| def normalize_base_url(value: str) -> str: | |
| base_url = value.strip().rstrip("/") | |
| if not base_url: | |
| raise ValueError("LiteLLM base URL is required.") | |
| if not base_url.endswith("/v1"): | |
| base_url = f"{base_url}/v1" | |
| return base_url | |
| VISION_TEST_IMAGE_URL = "https://images.unsplash.com/photo-1574158622682-e40e69881006?w=800" | |
| def get_vision_test_image(vision_image_path: str | None) -> str: | |
| """Download and encode test image as base64.""" | |
| if vision_image_path and os.path.exists(vision_image_path): | |
| with open(vision_image_path, "rb") as f: | |
| return base64.b64encode(f.read()).decode() | |
| # Download test image | |
| import tempfile | |
| tmp_dir = tempfile.gettempdir() | |
| cached_path = Path(tmp_dir) / "litellm_vision_test.jpg" | |
| if not cached_path.exists(): | |
| print(f"Downloading vision test image from {VISION_TEST_IMAGE_URL}...") | |
| with urlopen(Request(VISION_TEST_IMAGE_URL, headers={"User-Agent": "Mozilla/5.0"}), timeout=30) as resp: | |
| cached_path.write_bytes(resp.read()) | |
| with open(cached_path, "rb") as f: | |
| return base64.b64encode(f.read()).decode() | |
| def is_vision_model_entry(raw_model: dict[str, Any]) -> bool: | |
| """Check if model supports vision/image analysis via metadata.""" | |
| for mapping in raw_model_dicts(raw_model): | |
| capabilities = mapping.get("capabilities") | |
| if isinstance(capabilities, list): | |
| if any(normalize_label(str(item)) in {"vision", "image", "multimodal", "image-input", "imageanalysis"} for item in capabilities): | |
| return True | |
| elif isinstance(capabilities, dict): | |
| for capability, capability_enabled in capabilities.items(): | |
| if enabled(capability_enabled) and normalize_label(str(capability)) in { | |
| "vision", "image", "multimodal", "image-input", "imageanalysis" | |
| }: | |
| return True | |
| # Check for vision-related terms in model ID/name | |
| for key in ("id", "model", "name", "title", "task", "task_name", "type", "mode"): | |
| value = mapping.get(key) | |
| if isinstance(value, dict): | |
| value = value.get("name") | |
| if value and isinstance(value, str): | |
| v = value.lower() | |
| if any(term in v for term in ("vision", "vl-", "visual", "multimodal", "qwen2.5-vl", "qwen3-vl", "llava", "pixtral", "gemini-2.5-flash", "gemini-3-flash", "gpt-4o", "gpt-4v", "claude-3", "sonnet")): | |
| return True | |
| # Check input modalities | |
| input_modalities = mapping.get("input_modalities") | |
| if isinstance(input_modalities, list) and any(str(item).strip().lower() == "image" for item in input_modalities): | |
| return True | |
| return False | |
| def ping_vision_model_once( | |
| model_id: str, | |
| *, | |
| base_url: str, | |
| api_key: str | None, | |
| prompt: str, | |
| image_b64: str, | |
| timeout: float, | |
| ) -> dict[str, Any]: | |
| started = time.perf_counter() | |
| payload = { | |
| "model": model_id, | |
| "messages": [{ | |
| "role": "user", | |
| "content": [ | |
| {"type": "text", "text": prompt}, | |
| {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}} | |
| ] | |
| }], | |
| "max_tokens": 100, | |
| "temperature": 0, | |
| "stream": False, | |
| } | |
| try: | |
| status, response = request_json( | |
| "POST", | |
| f"{base_url}/chat/completions", | |
| api_key=api_key, | |
| payload=payload, | |
| timeout=timeout, | |
| ) | |
| elapsed_ms = round((time.perf_counter() - started) * 1000) | |
| choices = response.get("choices") if isinstance(response, dict) else None | |
| ok = 200 <= status < 300 and isinstance(choices, list) and len(choices) > 0 | |
| content = "" | |
| if ok and choices: | |
| content = choices[0].get("message", {}).get("content", "") or "" | |
| # Check if model actually saw the image (not just text-only response) | |
| content_lower = content.lower() | |
| refused = any(kw in content_lower for kw in ["unable", "cannot", "don't", "sorry", "no image", "text only", "can't see", "cannot see"]) | |
| return { | |
| "id": model_id, | |
| "ok": ok and not refused, | |
| "http_status": status, | |
| "latency_ms": elapsed_ms, | |
| "error": None if (ok and not refused) else ("refused vision" if refused else "missing choices in successful response"), | |
| } | |
| except Exception as exc: | |
| elapsed_ms = round((time.perf_counter() - started) * 1000) | |
| message, status = short_error(exc) | |
| return { | |
| "id": model_id, | |
| "ok": False, | |
| "http_status": status, | |
| "latency_ms": elapsed_ms, | |
| "error": message, | |
| } | |
| def ping_vision_model( | |
| model_id: str, | |
| *, | |
| base_url: str, | |
| api_key: str | None, | |
| prompt: str, | |
| image_b64: str, | |
| timeout: float, | |
| retries: int, | |
| retry_sleep: float, | |
| ) -> dict[str, Any]: | |
| attempts = max(1, retries + 1) | |
| last_result: dict[str, Any] | None = None | |
| for attempt in range(1, attempts + 1): | |
| result = ping_vision_model_once( | |
| model_id, | |
| base_url=base_url, | |
| api_key=api_key, | |
| prompt=prompt, | |
| image_b64=image_b64, | |
| timeout=timeout, | |
| ) | |
| result["attempts"] = attempt | |
| if result.get("ok"): | |
| return result | |
| last_result = result | |
| if attempt < attempts and is_retryable_result(result): | |
| time.sleep(max(0, retry_sleep)) | |
| continue | |
| return result | |
| return last_result or {"id": model_id, "ok": False, "error": "unknown"} | |
| def vision_output_payload( | |
| *, | |
| checked_at: str, | |
| base_url: str, | |
| prompt: str, | |
| total_models: int, | |
| targets: list[str], | |
| results: list[dict[str, Any]], | |
| complete: bool, | |
| ) -> dict[str, Any]: | |
| usable = sorted( | |
| [ | |
| { | |
| "id": str(result["id"]), | |
| "http_status": result.get("http_status"), | |
| "latency_ms": result.get("latency_ms"), | |
| "attempts": result.get("attempts"), | |
| } | |
| for result in results | |
| if result.get("ok") | |
| ], | |
| key=lambda item: item["id"], | |
| ) | |
| failures = [result for result in results if not result.get("ok")] | |
| return { | |
| "vision_checked_at": checked_at, | |
| "vision_base_url": base_url, | |
| "vision_prompt": prompt, | |
| "vision_complete": complete, | |
| "vision_total_models": total_models, | |
| "vision_tested_models": len(results), | |
| "vision_target_models": len(targets), | |
| "vision_usable_count": len(usable), | |
| "vision_failure_count": len(failures), | |
| "vision_usable_model_ids": [model["id"] for model in usable], | |
| "vision_models": usable, | |
| "vision_failure_summary": error_summary(results), | |
| "vision_failure_class_summary": error_class_summary(results), | |
| "vision_failure_models": [compact_failure(result) for result in sorted(failures, key=lambda item: str(item["id"]))], | |
| "vision_failure_samples": [compact_failure(result) for result in sorted(failures, key=lambda item: str(item["id"]))[:50]], | |
| } | |
| def merge_vision_output(path: Path, vision_payload: dict[str, Any]) -> dict[str, Any]: | |
| try: | |
| existing = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| existing = {} | |
| if not isinstance(existing, dict): | |
| existing = {} | |
| merged = dict(existing) | |
| merged.update(vision_payload) | |
| return merged | |
| def request_json( | |
| method: str, | |
| url: str, | |
| *, | |
| api_key: str | None, | |
| payload: dict[str, Any] | None = None, | |
| timeout: float, | |
| ) -> tuple[int, Any]: | |
| data = None | |
| headers = {"Accept": "application/json"} | |
| if payload is not None: | |
| data = json.dumps(payload).encode("utf-8") | |
| headers["Content-Type"] = "application/json" | |
| if api_key: | |
| headers["Authorization"] = f"Bearer {api_key}" | |
| request = Request(url, data=data, headers=headers, method=method) | |
| with urlopen(request, timeout=timeout) as response: | |
| body = response.read() | |
| if not body: | |
| return response.status, None | |
| content_type = response.headers.get("Content-Type", "") | |
| if "json" not in content_type.lower(): | |
| return response.status, { | |
| "_raw_content_type": content_type, | |
| "_raw_bytes": len(body), | |
| } | |
| return response.status, json.loads(body.decode("utf-8")) | |
| def fetch_model_ids(base_url: str, api_key: str | None, timeout: float) -> list[str]: | |
| return [entry["id"] for entry in fetch_model_entries(base_url, api_key, timeout)] | |
| def fetch_model_entries( | |
| base_url: str, | |
| api_key: str | None, | |
| timeout: float, | |
| ) -> list[dict[str, Any]]: | |
| _, payload = request_json( | |
| "GET", | |
| f"{base_url}/models", | |
| api_key=api_key, | |
| timeout=timeout, | |
| ) | |
| raw_models = payload.get("data") if isinstance(payload, dict) else payload | |
| if not isinstance(raw_models, list): | |
| return [] | |
| entries: dict[str, dict[str, Any]] = {} | |
| for model in raw_models: | |
| if isinstance(model, str): | |
| model_id = model | |
| elif isinstance(model, dict): | |
| model_id = model.get("id") or model.get("model") or model.get("name") | |
| else: | |
| continue | |
| if isinstance(model_id, str) and model_id.strip(): | |
| key = model_id.strip() | |
| raw = dict(model) if isinstance(model, dict) else {"id": key, "name": key} | |
| raw["id"] = key | |
| entries.setdefault(key, raw) | |
| return [entries[key] for key in sorted(entries)] | |
| def short_error(exc: BaseException) -> tuple[str, int | None]: | |
| if isinstance(exc, HTTPError): | |
| try: | |
| body = exc.read(500).decode("utf-8", errors="replace") | |
| except Exception: | |
| body = "" | |
| message = f"HTTP {exc.code}" | |
| if body: | |
| message = f"{message}: {body[:220]}" | |
| return message, exc.code | |
| if isinstance(exc, URLError): | |
| return f"{type(exc.reason).__name__}: {exc.reason}", None | |
| return f"{type(exc).__name__}: {exc}", None | |
| def is_transient_error(error: str | None) -> bool: | |
| if not error: | |
| return False | |
| needles = ( | |
| "getaddrinfo failed", | |
| "ConnectionResetError", | |
| "timed out", | |
| "temporarily unavailable", | |
| "forcibly closed", | |
| "unreachable host", | |
| ) | |
| return any(needle.lower() in error.lower() for needle in needles) | |
| def is_retryable_result(result: dict[str, Any]) -> bool: | |
| status = result.get("http_status") | |
| if isinstance(status, int) and status in {408, 409, 425, 429, 500, 502, 503, 504}: | |
| return True | |
| return is_transient_error(str(result.get("error") or "")) | |
| def ping_model_once( | |
| model_id: str, | |
| *, | |
| base_url: str, | |
| api_key: str | None, | |
| prompt: str, | |
| max_tokens: int, | |
| timeout: float, | |
| ) -> dict[str, Any]: | |
| started = time.perf_counter() | |
| payload = { | |
| "model": model_id, | |
| "messages": [{"role": "user", "content": prompt}], | |
| "max_tokens": max_tokens, | |
| "temperature": 0, | |
| "stream": False, | |
| } | |
| try: | |
| status, response = request_json( | |
| "POST", | |
| f"{base_url}/chat/completions", | |
| api_key=api_key, | |
| payload=payload, | |
| timeout=timeout, | |
| ) | |
| elapsed_ms = round((time.perf_counter() - started) * 1000) | |
| choices = response.get("choices") if isinstance(response, dict) else None | |
| ok = 200 <= status < 300 and isinstance(choices, list) | |
| return { | |
| "id": model_id, | |
| "ok": ok, | |
| "http_status": status, | |
| "latency_ms": elapsed_ms, | |
| "error": None if ok else "missing choices in successful response", | |
| } | |
| except Exception as exc: | |
| elapsed_ms = round((time.perf_counter() - started) * 1000) | |
| message, status = short_error(exc) | |
| return { | |
| "id": model_id, | |
| "ok": False, | |
| "http_status": status, | |
| "latency_ms": elapsed_ms, | |
| "error": message, | |
| } | |
| def ping_model( | |
| model_id: str, | |
| *, | |
| base_url: str, | |
| api_key: str | None, | |
| prompt: str, | |
| max_tokens: int, | |
| timeout: float, | |
| retries: int, | |
| retry_sleep: float, | |
| ) -> dict[str, Any]: | |
| attempts = max(1, retries + 1) | |
| last_result: dict[str, Any] | None = None | |
| for attempt in range(1, attempts + 1): | |
| result = ping_model_once( | |
| model_id, | |
| base_url=base_url, | |
| api_key=api_key, | |
| prompt=prompt, | |
| max_tokens=max_tokens, | |
| timeout=timeout, | |
| ) | |
| result["attempts"] = attempt | |
| if result.get("ok"): | |
| return result | |
| last_result = result | |
| if attempt < attempts and is_retryable_result(result): | |
| time.sleep(max(0, retry_sleep)) | |
| continue | |
| return result | |
| return last_result or {"id": model_id, "ok": False, "error": "unknown"} | |
| def raw_model_dicts(raw_model: dict[str, Any]) -> list[dict[str, Any]]: | |
| result: list[dict[str, Any]] = [] | |
| seen: set[int] = set() | |
| def visit(value: Any, depth: int = 0) -> None: | |
| if depth > 4 or not isinstance(value, dict): | |
| return | |
| identity = id(value) | |
| if identity in seen: | |
| return | |
| seen.add(identity) | |
| result.append(value) | |
| for key in ("info", "meta", "model_info"): | |
| visit(value.get(key), depth + 1) | |
| visit(raw_model) | |
| return result | |
| def normalize_label(value: str) -> str: | |
| return re.sub(r"[^a-z0-9]+", "", value.lower()) | |
| def enabled(value: Any) -> bool: | |
| if isinstance(value, bool): | |
| return value | |
| if isinstance(value, (int, float)): | |
| return value != 0 | |
| if isinstance(value, str): | |
| return value.strip().lower() in {"1", "true", "yes", "y", "on"} | |
| return False | |
| def image_capability_from_task(value: Any) -> bool: | |
| if not isinstance(value, str) or not value.strip(): | |
| return False | |
| normalized = normalize_label(value) | |
| return normalized in {"texttoimage", "imagegeneration", "text2image", "t2i"} | |
| def raw_image_metadata(raw_model: dict[str, Any]) -> bool: | |
| for mapping in raw_model_dicts(raw_model): | |
| for key in ("task", "task_name", "type", "mode", "sample_spec"): | |
| value = mapping.get(key) | |
| if isinstance(value, dict): | |
| value = value.get("name") | |
| if image_capability_from_task(value): | |
| return True | |
| capabilities = mapping.get("capabilities") | |
| if isinstance(capabilities, list): | |
| if any(normalize_label(str(item)) in {"image", "imagegeneration", "texttoimage", "generatesimages"} for item in capabilities): | |
| return True | |
| elif isinstance(capabilities, dict): | |
| for capability, capability_enabled in capabilities.items(): | |
| if enabled(capability_enabled) and normalize_label(str(capability)) in { | |
| "image", | |
| "imagegeneration", | |
| "texttoimage", | |
| "generatesimages", | |
| }: | |
| return True | |
| for key in ( | |
| "image_generation", | |
| "supports_image_generation", | |
| "supports_image_output", | |
| ): | |
| if enabled(mapping.get(key)): | |
| return True | |
| output_modalities = mapping.get("output_modalities") | |
| if isinstance(output_modalities, list) and any( | |
| str(item).strip().lower() == "image" for item in output_modalities | |
| ): | |
| return True | |
| return False | |
| IMAGE_GENERATION_TERMS = ( | |
| "black-forest-labs", | |
| "dall-e", | |
| "dalle", | |
| "flux", | |
| "gemini-2.5-flash-image", | |
| "gemini-3-pro-image-preview", | |
| "gpt-image", | |
| "hunyuan-image", | |
| "image", | |
| "image-generation", | |
| "image_generation", | |
| "image-preview", | |
| "imagen", | |
| "ideogram", | |
| "kandinsky", | |
| "kolors", | |
| "midjourney", | |
| "nano-banana", | |
| "playground-v", | |
| "qwen-image", | |
| "recraft", | |
| "sdxl", | |
| "seedream", | |
| "stable-diffusion", | |
| "text-to-image", | |
| "z-image", | |
| "zimage", | |
| ) | |
| IMAGE_GENERATION_LABELS = ( | |
| "t2i", | |
| "texttoimage", | |
| "text2image", | |
| "imagegeneration", | |
| "generatesimages", | |
| ) | |
| IMAGE_NON_GENERATION_TERMS = ( | |
| "animate", | |
| "depth", | |
| "edit", | |
| "first-last-image-to-video", | |
| "i2v", | |
| "image-to-image", | |
| "image-to-video", | |
| "img2img", | |
| "inpaint", | |
| "multi-image-to-video", | |
| "outpaint", | |
| "pose", | |
| "reframe", | |
| "remove-background", | |
| "replace", | |
| "r2v", | |
| "text-to-video", | |
| "upscale", | |
| "video", | |
| "vto", | |
| ) | |
| def raw_model_text(raw_model: dict[str, Any]) -> str: | |
| values: list[str] = [] | |
| for mapping in raw_model_dicts(raw_model): | |
| for key in ("id", "model", "name", "title", "task", "task_name", "type", "mode"): | |
| value = mapping.get(key) | |
| if isinstance(value, dict): | |
| value = value.get("name") | |
| if value is not None: | |
| values.append(str(value)) | |
| return " ".join(values).lower() | |
| def image_candidate_kind(raw_model: dict[str, Any]) -> str | None: | |
| text = raw_model_text(raw_model) | |
| normalized = normalize_label(text) | |
| has_generation_metadata = raw_image_metadata(raw_model) | |
| has_generation_term = any(term in text for term in IMAGE_GENERATION_TERMS) | |
| has_generation_label = any(label in normalized for label in IMAGE_GENERATION_LABELS) | |
| if not has_generation_metadata and not has_generation_term and not has_generation_label: | |
| return None | |
| if any(term in text for term in IMAGE_NON_GENERATION_TERMS): | |
| return "other-image" | |
| return "generation" | |
| def is_image_model_entry(raw_model: dict[str, Any], *, scope: str = "generation") -> bool: | |
| kind = image_candidate_kind(raw_model) | |
| if scope == "all": | |
| return kind is not None | |
| return kind == "generation" | |
| def classify_failure(result: dict[str, Any]) -> str | None: | |
| if result.get("ok"): | |
| return None | |
| status = result.get("http_status") | |
| error = str(result.get("error") or "").lower() | |
| if status == 402: | |
| return "payment_required" | |
| if status == 403 and any(term in error for term in ("funds", "balance", "payment method", "top up")): | |
| return "provider_funds_or_billing" | |
| if status == 403: | |
| return "provider_forbidden" | |
| if status == 404: | |
| return "model_not_found" | |
| if status == 429: | |
| return "rate_limited" | |
| if status in {500, 502, 503, 504}: | |
| return "provider_server_error" | |
| if status == 400 and any(term in error for term in ("invalid payload", "required", "multipart", "image")): | |
| return "wrong_image_payload_or_endpoint" | |
| if status == 400: | |
| return "bad_request" | |
| if is_transient_error(error): | |
| return "network_transient" | |
| if result.get("error") == "missing image data in successful response": | |
| return "unexpected_response_shape" | |
| return "unknown" | |
| def sanitize_failure_error(error: Any) -> str | None: | |
| if error is None: | |
| return None | |
| text = str(error).replace("\n", " ") | |
| text = re.sub(r"\$[0-9]+(?:\.[0-9]+)?", "$[amount]", text) | |
| text = re.sub( | |
| r"(?i)(available balance(?: is| of)? )[-+]?[0-9]+(?:\.[0-9]+)?", | |
| r"\1[amount]", | |
| text, | |
| ) | |
| text = re.sub( | |
| r"(?i)(balance(?: of)? )[-+]?[0-9]+(?:\.[0-9]+)?", | |
| r"\1[amount]", | |
| text, | |
| ) | |
| text = re.sub( | |
| r"(?i)(costs? ~?)[-+]?[0-9]+(?:\.[0-9]+)?", | |
| r"\1[amount]", | |
| text, | |
| ) | |
| if len(text) > 260: | |
| return f"{text[:257]}..." | |
| return text | |
| def compact_failure(result: dict[str, Any]) -> dict[str, Any]: | |
| compact = { | |
| "id": result.get("id"), | |
| "ok": False, | |
| "http_status": result.get("http_status"), | |
| "latency_ms": result.get("latency_ms"), | |
| "attempts": result.get("attempts"), | |
| "error_class": classify_failure(result), | |
| "error": sanitize_failure_error(result.get("error")), | |
| } | |
| return {key: value for key, value in compact.items() if value is not None} | |
| def error_class_summary(results: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| counts: dict[str, int] = {} | |
| for result in results: | |
| failure_class = classify_failure(result) | |
| if failure_class: | |
| counts[failure_class] = counts.get(failure_class, 0) + 1 | |
| return [ | |
| {"error_class": error_class, "count": count} | |
| for error_class, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) | |
| ] | |
| IMAGE_CARRYABLE_FAILURE_CLASSES = { | |
| "network_transient", | |
| "provider_server_error", | |
| "rate_limited", | |
| } | |
| def carry_previous_image_models( | |
| existing: dict[str, Any], | |
| image_payload: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| current_models = [ | |
| model for model in image_payload.get("image_models", []) if isinstance(model, dict) | |
| ] | |
| current_ids = {str(model.get("id")) for model in current_models if model.get("id")} | |
| failures = { | |
| str(model.get("id")): model | |
| for model in image_payload.get("image_failure_models", []) | |
| if isinstance(model, dict) and model.get("id") | |
| } | |
| carried: list[dict[str, Any]] = [] | |
| for previous in existing.get("image_models", []): | |
| if not isinstance(previous, dict) or not previous.get("id"): | |
| continue | |
| model_id = str(previous["id"]) | |
| if model_id in current_ids: | |
| continue | |
| failure = failures.get(model_id) | |
| if not failure: | |
| continue | |
| failure_class = str(failure.get("error_class") or "") | |
| if failure_class not in IMAGE_CARRYABLE_FAILURE_CLASSES: | |
| continue | |
| carried_model = dict(previous) | |
| carried_model["carried_from_previous"] = True | |
| carried_model["last_probe_error_class"] = failure_class | |
| carried_model["last_probe_http_status"] = failure.get("http_status") | |
| carried_model["last_probe_checked_at"] = image_payload.get("image_checked_at") | |
| carried.append(carried_model) | |
| combined = sorted( | |
| current_models + carried, | |
| key=lambda item: str(item.get("id") or ""), | |
| ) | |
| updated = dict(image_payload) | |
| updated["image_current_usable_count"] = len(current_models) | |
| updated["image_carried_usable_count"] = len(carried) | |
| updated["image_usable_count"] = len(combined) | |
| updated["image_usable_model_ids"] = [str(model["id"]) for model in combined] | |
| updated["image_models"] = combined | |
| return updated | |
| def ping_image_model_once( | |
| model_id: str, | |
| *, | |
| base_url: str, | |
| api_key: str | None, | |
| prompt: str, | |
| image_size: str | None, | |
| timeout: float, | |
| ) -> dict[str, Any]: | |
| started = time.perf_counter() | |
| payload: dict[str, Any] = { | |
| "model": model_id, | |
| "prompt": prompt, | |
| "n": 1, | |
| } | |
| if image_size: | |
| payload["size"] = image_size | |
| try: | |
| status, response = request_json( | |
| "POST", | |
| f"{base_url}/images/generations", | |
| api_key=api_key, | |
| payload=payload, | |
| timeout=timeout, | |
| ) | |
| elapsed_ms = round((time.perf_counter() - started) * 1000) | |
| ok = False | |
| if isinstance(response, dict): | |
| data = response.get("data") | |
| raw_content_type = str(response.get("_raw_content_type") or "") | |
| ok = ( | |
| isinstance(data, list) | |
| and bool(data) | |
| or raw_content_type.lower().startswith("image/") | |
| ) | |
| return { | |
| "id": model_id, | |
| "ok": 200 <= status < 300 and ok, | |
| "http_status": status, | |
| "latency_ms": elapsed_ms, | |
| "error": None if ok else "missing image data in successful response", | |
| } | |
| except Exception as exc: | |
| elapsed_ms = round((time.perf_counter() - started) * 1000) | |
| message, status = short_error(exc) | |
| return { | |
| "id": model_id, | |
| "ok": False, | |
| "http_status": status, | |
| "latency_ms": elapsed_ms, | |
| "error": message, | |
| } | |
| def ping_image_model( | |
| model_id: str, | |
| *, | |
| base_url: str, | |
| api_key: str | None, | |
| prompt: str, | |
| image_size: str | None, | |
| timeout: float, | |
| retries: int, | |
| retry_sleep: float, | |
| ) -> dict[str, Any]: | |
| attempts = max(1, retries + 1) | |
| last_result: dict[str, Any] | None = None | |
| for attempt in range(1, attempts + 1): | |
| result = ping_image_model_once( | |
| model_id, | |
| base_url=base_url, | |
| api_key=api_key, | |
| prompt=prompt, | |
| image_size=image_size, | |
| timeout=timeout, | |
| ) | |
| result["attempts"] = attempt | |
| if result.get("ok"): | |
| return result | |
| last_result = result | |
| if attempt < attempts and is_retryable_result(result): | |
| time.sleep(max(0, retry_sleep)) | |
| continue | |
| return result | |
| return last_result or {"id": model_id, "ok": False, "error": "unknown"} | |
| def write_json_atomic(path: Path, payload: dict[str, Any]) -> None: | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| with tempfile.NamedTemporaryFile( | |
| "w", | |
| encoding="utf-8", | |
| delete=False, | |
| dir=path.parent, | |
| suffix=".tmp", | |
| ) as handle: | |
| json.dump(payload, handle, indent=2, sort_keys=True) | |
| handle.write("\n") | |
| temp_name = handle.name | |
| Path(temp_name).replace(path) | |
| def error_summary(results: list[dict[str, Any]]) -> list[dict[str, Any]]: | |
| counts: dict[str, int] = {} | |
| for result in results: | |
| if result.get("ok"): | |
| continue | |
| error = str(result.get("error") or "unknown") | |
| if error.startswith("HTTP "): | |
| error = error.split(":", 1)[0] | |
| counts[error] = counts.get(error, 0) + 1 | |
| return [ | |
| {"error": error, "count": count} | |
| for error, count in sorted(counts.items(), key=lambda item: (-item[1], item[0])) | |
| ] | |
| def output_payload( | |
| *, | |
| checked_at: str, | |
| base_url: str, | |
| prompt: str, | |
| max_tokens: int, | |
| total_models: int, | |
| targets: list[str], | |
| results: list[dict[str, Any]], | |
| complete: bool, | |
| ) -> dict[str, Any]: | |
| usable = sorted( | |
| [ | |
| { | |
| "id": str(result["id"]), | |
| "http_status": result.get("http_status"), | |
| "latency_ms": result.get("latency_ms"), | |
| "attempts": result.get("attempts"), | |
| } | |
| for result in results | |
| if result.get("ok") | |
| ], | |
| key=lambda item: item["id"], | |
| ) | |
| failures = [result for result in results if not result.get("ok")] | |
| return { | |
| "version": 1, | |
| "checked_at": checked_at, | |
| "base_url": base_url, | |
| "prompt": prompt, | |
| "max_tokens": max(1, max_tokens), | |
| "complete": complete, | |
| "total_models": total_models, | |
| "tested_models": len(results), | |
| "target_models": len(targets), | |
| "usable_count": len(usable), | |
| "failure_count": len(failures), | |
| "usable_model_ids": [model["id"] for model in usable], | |
| "models": usable, | |
| "failure_summary": error_summary(results), | |
| "failure_samples": sorted(failures, key=lambda item: str(item["id"]))[:50], | |
| } | |
| def image_output_payload( | |
| *, | |
| checked_at: str, | |
| base_url: str, | |
| prompt: str, | |
| image_size: str | None, | |
| total_models: int, | |
| targets: list[str], | |
| results: list[dict[str, Any]], | |
| complete: bool, | |
| ) -> dict[str, Any]: | |
| usable = sorted( | |
| [ | |
| { | |
| "id": str(result["id"]), | |
| "http_status": result.get("http_status"), | |
| "latency_ms": result.get("latency_ms"), | |
| "attempts": result.get("attempts"), | |
| } | |
| for result in results | |
| if result.get("ok") | |
| ], | |
| key=lambda item: item["id"], | |
| ) | |
| failures = [result for result in results if not result.get("ok")] | |
| compact_failures = [compact_failure(result) for result in sorted(failures, key=lambda item: str(item["id"]))] | |
| return { | |
| "image_checked_at": checked_at, | |
| "image_base_url": base_url, | |
| "image_prompt": prompt, | |
| "image_size": image_size, | |
| "image_complete": complete, | |
| "image_total_models": total_models, | |
| "image_tested_models": len(results), | |
| "image_target_models": len(targets), | |
| "image_usable_count": len(usable), | |
| "image_failure_count": len(failures), | |
| "image_usable_model_ids": [model["id"] for model in usable], | |
| "image_models": usable, | |
| "image_failure_summary": error_summary(results), | |
| "image_failure_class_summary": error_class_summary(results), | |
| "image_failure_models": compact_failures, | |
| "image_failure_samples": compact_failures[:50], | |
| } | |
| def merge_image_output(path: Path, image_payload: dict[str, Any]) -> dict[str, Any]: | |
| try: | |
| existing = json.loads(path.read_text(encoding="utf-8")) | |
| except (OSError, json.JSONDecodeError): | |
| existing = {} | |
| if not isinstance(existing, dict): | |
| existing = {} | |
| merged = dict(existing) | |
| image_payload = carry_previous_image_models(existing, image_payload) | |
| merged.update(image_payload) | |
| return merged | |
| def filtered_models( | |
| model_ids: list[str], | |
| *, | |
| include_pattern: str | None, | |
| exclude_patterns: list[str], | |
| max_models: int | None, | |
| ) -> list[str]: | |
| filtered = model_ids | |
| if include_pattern: | |
| include_re = re.compile(include_pattern, re.IGNORECASE) | |
| filtered = [model_id for model_id in filtered if include_re.search(model_id)] | |
| for pattern in exclude_patterns: | |
| exclude_re = re.compile(pattern, re.IGNORECASE) | |
| filtered = [model_id for model_id in filtered if not exclude_re.search(model_id)] | |
| if max_models: | |
| filtered = filtered[: max(0, max_models)] | |
| return filtered | |
| def main() -> int: | |
| parser = argparse.ArgumentParser( | |
| description="Verify which LiteLLM models can answer a tiny chat or image request." | |
| ) | |
| parser.add_argument( | |
| "--base-url", | |
| default=os.environ.get("LITELLM_BASE_URL") or os.environ.get("LITELLM_API_BASE_URL") or "http://127.0.0.1:7860", | |
| help="LiteLLM gateway base URL. May include or omit /v1.", | |
| ) | |
| parser.add_argument("--api-key", default=os.environ.get("LITELLM_API_KEY")) | |
| parser.add_argument("--api-key-env", default=None) | |
| parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) | |
| parser.add_argument( | |
| "--probe", | |
| choices=("chat", "image", "vision"), | |
| default="chat", | |
| help="Probe chat completions, image generations, or vision/image-analysis models.", | |
| ) | |
| parser.add_argument("--prompt", default="hi") | |
| parser.add_argument( | |
| "--image-prompt", | |
| default="a tiny blue square on a white background", | |
| ) | |
| parser.add_argument( | |
| "--vision-prompt", | |
| default="What is in this image? One sentence.", | |
| ) | |
| parser.add_argument( | |
| "--image-size", | |
| default=None, | |
| help="Optional image size to send. Omit by default for provider compatibility.", | |
| ) | |
| parser.add_argument( | |
| "--vision-image", | |
| default=None, | |
| help="Path to image file for vision testing. Downloads a test image if not provided.", | |
| ) | |
| parser.add_argument( | |
| "--image-scope", | |
| choices=("generation", "all"), | |
| default="generation", | |
| help="Image candidate scope. generation keeps prompt-to-image models; all also includes edit/video/upscale-like image entries for diagnosis.", | |
| ) | |
| parser.add_argument("--max-tokens", type=int, default=1) | |
| parser.add_argument("--timeout", type=float, default=25) | |
| parser.add_argument("--concurrency", type=int, default=8) | |
| parser.add_argument("--retries", type=int, default=2) | |
| parser.add_argument("--retry-sleep", type=float, default=1) | |
| parser.add_argument("--checkpoint-every", type=int, default=25) | |
| parser.add_argument("--max-models", type=int, default=None) | |
| parser.add_argument("--include-pattern", default=None) | |
| parser.add_argument("--exclude-pattern", action="append", default=[]) | |
| parser.add_argument("--no-filter", action="store_true", help="Skip pre-filtering and test all models (use with --probe vision to test all models for vision capability)") | |
| parser.add_argument("--yes", action="store_true", help="Actually ping models. Without this, only prints the plan.") | |
| args = parser.parse_args() | |
| api_key = args.api_key | |
| if args.api_key_env: | |
| api_key = os.environ.get(args.api_key_env) | |
| base_url = normalize_base_url(args.base_url) | |
| model_entries = fetch_model_entries(base_url, api_key, args.timeout) | |
| if not args.no_filter: | |
| if args.probe == "image": | |
| model_entries = [ | |
| entry | |
| for entry in model_entries | |
| if is_image_model_entry(entry, scope=args.image_scope) | |
| ] | |
| elif args.probe == "vision": | |
| model_entries = [ | |
| entry | |
| for entry in model_entries | |
| if is_vision_model_entry(entry) | |
| ] | |
| model_ids = [entry["id"] for entry in model_entries] | |
| targets = filtered_models( | |
| model_ids, | |
| include_pattern=args.include_pattern, | |
| exclude_patterns=args.exclude_pattern, | |
| max_models=args.max_models, | |
| ) | |
| print( | |
| f"Fetched {len(model_entries)} {args.probe} candidate models " | |
| f"from {base_url}; selected {len(targets)} targets." | |
| ) | |
| if not args.yes: | |
| print("Dry run only. Re-run with --yes to ping models.") | |
| return 0 | |
| if not targets: | |
| raise SystemExit("No models selected.") | |
| checked_at = utc_now() | |
| results: list[dict[str, Any]] = [] | |
| completed = 0 | |
| # Load vision test image if needed | |
| vision_image_b64 = None | |
| if args.probe == "vision": | |
| vision_image_b64 = get_vision_test_image(args.vision_image) | |
| print(f"Vision test image ready ({len(vision_image_b64)} chars base64)") | |
| with ThreadPoolExecutor(max_workers=max(1, args.concurrency)) as executor: | |
| if args.probe == "vision": | |
| futures = [ | |
| executor.submit( | |
| ping_vision_model, | |
| model_id, | |
| base_url=base_url, | |
| api_key=api_key, | |
| prompt=args.vision_prompt, | |
| image_b64=vision_image_b64, | |
| timeout=args.timeout, | |
| retries=max(0, args.retries), | |
| retry_sleep=args.retry_sleep, | |
| ) | |
| for model_id in targets | |
| ] | |
| else: | |
| futures = [ | |
| executor.submit( | |
| ping_image_model if args.probe == "image" else ping_model, | |
| model_id, | |
| base_url=base_url, | |
| api_key=api_key, | |
| prompt=args.image_prompt if args.probe == "image" else args.prompt, | |
| **( | |
| {"image_size": args.image_size} | |
| if args.probe == "image" | |
| else {"max_tokens": max(1, args.max_tokens)} | |
| ), | |
| timeout=args.timeout, | |
| retries=max(0, args.retries), | |
| retry_sleep=args.retry_sleep, | |
| ) | |
| for model_id in targets | |
| ] | |
| for index, future in enumerate(as_completed(futures), start=1): | |
| result = future.result() | |
| results.append(result) | |
| completed += 1 | |
| if result.get("ok"): | |
| print(f"[{index}/{len(targets)}] ok {result['id']}") | |
| elif index == 1 or index % 25 == 0: | |
| print(f"[{index}/{len(targets)}] failures so far: {index - sum(1 for item in results if item.get('ok'))}") | |
| if args.checkpoint_every and completed % max(1, args.checkpoint_every) == 0: | |
| if args.probe == "image": | |
| checkpoint_payload = merge_image_output( | |
| args.output, | |
| image_output_payload( | |
| checked_at=checked_at, | |
| base_url=base_url, | |
| prompt=args.image_prompt, | |
| image_size=args.image_size, | |
| total_models=len(model_entries), | |
| targets=targets, | |
| results=results, | |
| complete=False, | |
| ), | |
| ) | |
| elif args.probe == "vision": | |
| checkpoint_payload = merge_vision_output( | |
| args.output, | |
| vision_output_payload( | |
| checked_at=checked_at, | |
| base_url=base_url, | |
| prompt=args.vision_prompt, | |
| total_models=len(model_entries), | |
| targets=targets, | |
| results=results, | |
| complete=False, | |
| ), | |
| ) | |
| else: | |
| checkpoint_payload = output_payload( | |
| checked_at=checked_at, | |
| base_url=base_url, | |
| prompt=args.prompt, | |
| max_tokens=args.max_tokens, | |
| total_models=len(model_entries), | |
| targets=targets, | |
| results=results, | |
| complete=False, | |
| ) | |
| write_json_atomic(args.output, checkpoint_payload) | |
| if args.probe == "image": | |
| output = merge_image_output( | |
| args.output, | |
| image_output_payload( | |
| checked_at=checked_at, | |
| base_url=base_url, | |
| prompt=args.image_prompt, | |
| image_size=args.image_size, | |
| total_models=len(model_entries), | |
| targets=targets, | |
| results=results, | |
| complete=True, | |
| ), | |
| ) | |
| elif args.probe == "vision": | |
| output = merge_vision_output( | |
| args.output, | |
| vision_output_payload( | |
| checked_at=checked_at, | |
| base_url=base_url, | |
| prompt=args.vision_prompt, | |
| total_models=len(model_entries), | |
| targets=targets, | |
| results=results, | |
| complete=True, | |
| ), | |
| ) | |
| else: | |
| output = output_payload( | |
| checked_at=checked_at, | |
| base_url=base_url, | |
| prompt=args.prompt, | |
| max_tokens=args.max_tokens, | |
| total_models=len(model_entries), | |
| targets=targets, | |
| results=results, | |
| complete=True, | |
| ) | |
| write_json_atomic(args.output, output) | |
| if args.probe == "image": | |
| print(f"Wrote {args.output} with {output['image_usable_count']} image-usable models.") | |
| elif args.probe == "vision": | |
| print(f"Wrote {args.output} with {output['vision_usable_count']} vision-usable models.") | |
| else: | |
| print(f"Wrote {args.output} with {output['usable_count']} usable models.") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |