Files
comet-ml--opik/scripts/sync_provider_models.py
wehub-resource-sync 5a558eb09e
Backend Tests / discover-tests (push) Waiting to run
Backend Tests / ${{ matrix.name }} (push) Blocked by required conditions
Build and Publish SDK / build-and-publish (push) Waiting to run
Build Opik Docker Images / set-version (push) Waiting to run
Build Opik Docker Images / build-backend (push) Blocked by required conditions
Build Opik Docker Images / build-sandbox-executor-python (push) Blocked by required conditions
Build Opik Docker Images / build-python-backend (push) Blocked by required conditions
Build Opik Docker Images / build-frontend (push) Blocked by required conditions
Build Opik Docker Images / create-git-tag (push) Blocked by required conditions
ClickHouse Migration Cluster Check / validate-clickhouse-migrations (push) Waiting to run
Docs - Publish / run (push) Waiting to run
E2E Tests - Post Merge (v2) / 🧪 E2E v2 Tests (${{ github.event.inputs.tier || 't1' }}) (push) Waiting to run
E2E Tests - Post Merge (v2) / 📢 Slack Notification (push) Blocked by required conditions
Frontend Unit Tests / Test on Node 20 (push) Waiting to run
Guardrails E2E Tests / Select Python version matrix (push) Waiting to run
Guardrails E2E Tests / Guardrails E2E Tests ${{matrix.python_version}} (push) Blocked by required conditions
Guardrails E2E Tests / 📢 Slack Notification (push) Blocked by required conditions
Guardrails Backend Unit Tests / Guardrails Backend Unit Tests (push) Waiting to run
Guardrails Backend Unit Tests / 📢 Slack Notification (push) Blocked by required conditions
SDK Library Integration Tests Runner / Check Secrets (push) Waiting to run
SDK Library Integration Tests Runner / Missed OpenAI API Key Warning (push) Blocked by required conditions
SDK Library Integration Tests Runner / Build (push) Blocked by required conditions
SDK Library Integration Tests Runner / openai_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / langchain_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / langchain_legacy_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / llama_index_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / anthropic_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / mistral_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / groq_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / aisuite_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / haystack_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / dspy_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / crewai_v0_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / crewai_v1_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / genai_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / adk_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / adk_legacy_1_3_0_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / evaluation_metrics_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / bedrock_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / litellm_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / harbor_tests (push) Blocked by required conditions
SDK Library Integration Tests Runner / Slack Notification (push) Blocked by required conditions
Lint Opik Helm Chart / lint-helm-chart (Helm v3.21.0) (push) Waiting to run
Lint Opik Helm Chart / lint-helm-chart (Helm v4.2.0) (push) Waiting to run
Lint Opik Helm Chart / unittest-helm-chart (push) Waiting to run
Lint Opik Helm Chart / render-equality (push) Waiting to run
Opik Optimizer - Unit Tests / Opik Optimizer Unit Tests Python ${{matrix.python_version}} (push) Waiting to run
Python BE E2E Tests / Python BE E2E (push) Waiting to run
Python Backend Tests / run-python-backend-tests (push) Waiting to run
Python SDK Unit Tests / Python SDK Unit Tests ${{matrix.python_version}} (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
SDK E2E Libraries Integration Tests / Check Secrets (push) Waiting to run
SDK E2E Libraries Integration Tests / Missed OpenAI API Key Warning (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / build-opik (push) Blocked by required conditions
SDK E2E Libraries Integration Tests / E2E Lib Integration Python ${{matrix.python_version}} (push) Blocked by required conditions
TypeScript SDK Integration Build & Publish / build-and-publish (opik-gemini) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-langchain) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-openai) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-otel) (push) Waiting to run
TypeScript SDK Integration Build & Publish / build-and-publish (opik-vercel) (push) Waiting to run
TypeScript SDK Library Integration Tests / Check Secrets (push) Waiting to run
TypeScript SDK Library Integration Tests / opik-vercel (Vercel AI SDK / eve) (push) Blocked by required conditions
TypeScript SDK Build & Publish / build-and-publish (push) Waiting to run
TypeScript SDK Unit Tests / Test on Node ${{ matrix.node-version }} (push) Waiting to run
TypeScript SDK Compatibility V1.x E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / TypeScript SDK Compatibility V1.x E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
TypeScript SDK E2E Tests / TypeScript SDK E2E Tests Node ${{matrix.node_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
Python SDK E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK E2E Tests / Python SDK E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK E2E Tests / build-opik (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Select Python version matrix (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / Python SDK Compatibility V1.x E2E Tests ${{matrix.python_version}} (push) Has been cancelled
Python SDK Compatibility V1.x E2E Tests / build-opik (push) Has been cancelled
TypeScript SDK E2E Tests / Select Node version matrix (push) Has been cancelled
TypeScript SDK E2E Tests / build-opik (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer E2E Tests Python ${{matrix.python_version}} (push) Has been cancelled
Opik Optimizer - E2E Tests / Opik Optimizer Integration Smoke Tests (push) Has been cancelled
🐙 Code Quality / detect (push) Has been cancelled
🐙 Code Quality / lint (${{ matrix.leg.name }}) (push) Has been cancelled
🐙 Code Quality / summary (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:25:44 +08:00

1327 lines
52 KiB
Python

#!/usr/bin/env python3
"""
Sync LLM provider model definitions across backend Java enums and frontend TypeScript.
Add-only: new models are added automatically, stale models are reported but never
removed (to avoid breaking references across the codebase).
Sources (in priority order):
- OpenRouter: https://openrouter.ai/api/v1/models (public, no key needed)
- OpenAI: https://api.openai.com/v1/models (needs OPENAI_API_KEY)
- Anthropic: https://api.anthropic.com/v1/models (needs ANTHROPIC_API_KEY)
- Gemini: https://generativelanguage.googleapis.com/v1beta/models (needs GEMINI_API_KEY)
- Fallback: model_prices_and_context_window.json (already in repo, no key needed)
Usage:
python scripts/sync_provider_models.py # Apply changes
python scripts/sync_provider_models.py --dry-run # Preview without writing
# With provider API keys for better coverage:
OPENAI_API_KEY=sk-... ANTHROPIC_API_KEY=sk-... GEMINI_API_KEY=... python scripts/sync_provider_models.py --dry-run
"""
import argparse
import json
import os
import re
import sys
from datetime import date
from pathlib import Path
from typing import NamedTuple
import requests
REPO_ROOT = Path(__file__).resolve().parent.parent
# --- File paths (relative to repo root) ---
JAVA_BASE = Path("apps/opik-backend/src/main/java/com/comet/opik/infrastructure/llm")
OPENROUTER_JAVA = JAVA_BASE / "openrouter" / "OpenRouterModelName.java"
OPENAI_JAVA = JAVA_BASE / "openai" / "OpenaiModelName.java"
ANTHROPIC_JAVA = JAVA_BASE / "antropic" / "AnthropicModelName.java"
GEMINI_JAVA = JAVA_BASE / "gemini" / "GeminiModelName.java"
VERTEXAI_JAVA = JAVA_BASE / "vertexai" / "VertexAIModelName.java"
PROVIDERS_TS = Path("apps/opik-frontend/src/types/providers.ts")
MODELS_DATA_TS = Path("apps/opik-frontend/src/constants/providerModels.ts")
MODEL_PRICES_JSON = Path("apps/opik-backend/src/main/resources/model_prices_and_context_window.json")
LLM_MODELS_YAML = Path("apps/opik-backend/src/main/resources/llm-models-default.yaml")
OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models"
# Models from the JSON to exclude per provider
OPENAI_EXCLUDE_PATTERNS = [
r"^ft:",
r"-realtime",
r"-audio",
r"^gpt-audio",
r"^gpt-realtime",
r"^gpt-4-32k",
r"^gpt-4-vision",
r"^gpt-4-1106-vision",
r"^gpt-4\.5-preview",
r"^gpt-5-search",
r"^gpt-4o-.*search",
r"^gpt-4o-mini-search",
r"^openai/",
r"-\d{4}-\d{2}-\d{2}$",
r"^gpt-3\.5-turbo-16k",
r"^gpt-3\.5-turbo-0301$",
r"^gpt-3\.5-turbo-0613$",
r"-tts$",
r"-transcribe$",
r"-search",
]
# The OpenAI /v1/models API returns ALL model types (embeddings, tts, dall-e, whisper, etc).
# Only these prefixes are chat/completion models usable in our playground.
OPENAI_CHAT_PREFIXES = ("gpt-", "o1", "o3", "o4", "chatgpt-")
ANTHROPIC_EXCLUDE_PATTERNS = [
r"-latest$",
r"^claude-3-",
r"^claude-3\.5-",
r"^claude-2",
r"^claude-instant",
r"^claude-4-", # claude-4-opus is old naming, current is claude-opus-4
]
GEMINI_EXCLUDE_PATTERNS = [
r"^gemini-pro$",
r"-thinking-exp",
r"-audio",
r"-native-audio",
r"-live-",
r"^gemini-live-",
r"-image-generation",
r"-computer-use-",
r"^learnlm",
r"^gemini-robotics",
r"^text-embedding",
r"^aqa$",
r"^gemini-1\.0-pro-vision",
r"^gemini-pro-vision$",
r"^gemini-exp-",
r"-preview-\d{2}-\d{2}$",
r"-preview-\d{2}-\d{4}$",
r"-exp-\d{2}-\d{2}$",
r"-exp-\d{4}$",
r"-preview-tts$",
r"-\d{3}$",
r"-customtools$",
r"-latest$",
]
class ModelEntry(NamedTuple):
enum_name: str
value: str
structured_output: bool
label: str
# ─────────────────────────────────────────────────────────────────────────────
# Dropdown filtering — only show useful models in the frontend dropdown.
# Java enums keep all models for backend validation.
# ─────────────────────────────────────────────────────────────────────────────
OPENAI_DROPDOWN_EXCLUDE = [
r"-\d{4}-\d{2}-\d{2}", # dated snapshots (gpt-4o-2024-08-06)
r"-preview$", # old preview aliases
r"^gpt-3\.5-", # very old
r"^gpt-4-0\d{3}", # old GPT-4 snapshots (gpt-4-0314, gpt-4-0613)
r"^gpt-4-1106",
r"^gpt-4-0125",
r"-instruct",
r"-image",
r"^chatgpt-image",
r"^chatgpt-4o-latest$", # deprecated by OpenAI (not in prices JSON)
r"-transcribe",
r"-codex",
r"-pro$", # Responses API only (/v1/responses), we use /v1/chat/completions
r"-deep-research$",
r"-chat-latest$",
r"^o1-preview",
r"^o1-mini",
]
GEMINI_DROPDOWN_EXCLUDE = [
r"^aqa$",
r"^text-embedding",
r"^gemini-pro-vision$",
r"^gemini-1\.0-",
r"-latest$",
r"^nano-banana",
r"-image",
r"-tts",
# Google removed Gemma 2 and Gemma 3 from the AI Studio Gemini API in
# April 2026 (replaced by Gemma 4). The LiteLLM prices JSON still ships
# `gemini/gemma-3-*-it` and `gemini/gemini-gemma-2-*-it` entries, but
# generateContent on /v1beta no longer routes them. Hide from dropdown
# until Gemma 4 (`gemma-4-31b-it`, `gemma-4-26b-a4b-it`) lands in the
# sync sources.
r"^gemma-(2|3)-",
r"^gemini-gemma-",
]
VERTEXAI_DROPDOWN_EXCLUDE = [
r"-exp-", # experimental
r"-preview-\d{2}-\d{2}$", # dated previews
]
def _openai_sort_key(entry: ModelEntry) -> tuple:
"""Sort OpenAI: GPT 5.x → 4.x → 4o → 4 → o-series → chatgpt. Within: base → pro → mini → nano."""
value = entry.value
tier = 1 if '-pro' in value else 2 if '-mini' in value else 3 if '-nano' in value else 0
m = re.match(r'^gpt-(\d+)\.(\d+)', value)
if m:
return (0, -int(m.group(1)), -int(m.group(2)), tier, value)
m = re.match(r'^gpt-(\d+)o', value)
if m:
return (0, -int(m.group(1)), 0.5, tier, value)
m = re.match(r'^gpt-(\d+)', value)
if m:
return (0, -int(m.group(1)), 99, tier, value)
m = re.match(r'^o(\d+)', value)
if m:
return (1, -int(m.group(1)), 0, tier, value)
if value.startswith('chatgpt'):
return (2, 0, 0, 0, value)
return (99, 0, 0, 0, value)
def _anthropic_sort_key(entry: ModelEntry) -> tuple:
"""Sort Anthropic: by generation desc, then Opus → Sonnet → Haiku."""
value = entry.value
family_order = {'opus': 0, 'sonnet': 1, 'haiku': 2}
# New naming: claude-{family}-{major}[-{minor}] (minor is single digit, not a date)
m = re.match(r'^claude-(opus|sonnet|haiku)-(\d+)(?:-(\d)(?!\d))?', value)
if m:
return (-int(m.group(2)), -(int(m.group(3)) if m.group(3) else 0),
family_order.get(m.group(1), 9), value)
# Old naming: claude-{major}-{minor}-{family}
m = re.match(r'^claude-(\d+)-(\d+)-(opus|sonnet|haiku)', value)
if m:
return (-int(m.group(1)), -int(m.group(2)),
family_order.get(m.group(3), 9), value)
return (0, 0, 9, value)
def _gemini_sort_key(entry: ModelEntry) -> tuple:
"""Sort Gemini first by generation, then Gemma by generation/size.
Within Gemini: Pro → Flash → Flash-Lite.
Within Gemma: base `gemma-N-*b-it` (small → large) before `gemma-Nn-*` variants.
"""
value = entry.value.removeprefix("vertex_ai/")
# Gemma family (including legacy `gemini-gemma-*`) — sorts after Gemini.
gemma = re.match(r'^(?:gemini-)?gemma-(\d+)(n)?(?:-(e?\d+)b-)?', value)
if gemma:
major = int(gemma.group(1))
is_3n = gemma.group(2) == "n"
size_token = gemma.group(3) or ""
size_digits = re.search(r"\d+", size_token)
size = int(size_digits.group()) if size_digits else 99
sub = 0 if not is_3n else 1
return (1, -major, sub, size, value)
# Gemini family — existing behaviour.
m = re.match(r'^gemini-(\d+)(?:\.(\d+))?', value)
if not m:
return (1, 99, 0, 99, value)
major, minor = int(m.group(1)), int(m.group(2)) if m.group(2) else 0
if '-pro' in value:
tier = 0
elif '-flash-lite' in value or '-flash-8b' in value:
tier = 2
elif '-flash' in value:
tier = 1
else:
tier = 3
return (0, -major, -minor, tier, value)
def _deduplicate_by_base(entries: list[ModelEntry]) -> list[ModelEntry]:
"""For models with dated variants, keep only the non-dated version for the dropdown."""
groups: dict[str, list[ModelEntry]] = {}
for e in entries:
base = re.sub(r'-\d{8}$', '', e.value)
groups.setdefault(base, []).append(e)
result = []
for group in groups.values():
non_dated = [e for e in group if not re.search(r'-\d{8}$', e.value)]
if non_dated:
result.append(non_dated[0])
else:
result.append(max(group, key=lambda e: re.search(r'-(\d{8})$', e.value).group(1)))
return result
def build_deprecated_set(prices: dict) -> set[str]:
"""Build a set of model values with deprecation_date in the past."""
today = date.today()
deprecated = set()
for key, info in prices.items():
if not isinstance(info, dict):
continue
dep = info.get("deprecation_date")
if not dep:
continue
try:
if date.fromisoformat(dep) <= today:
deprecated.add(key)
for prefix in ("gemini/", "anthropic/", "openai/", "vertex_ai/", "openrouter/"):
if key.startswith(prefix):
deprecated.add(key.removeprefix(prefix))
except ValueError:
pass
return deprecated
def filter_for_dropdown(
entries: list[ModelEntry], provider: str, deprecated: set[str] | None = None,
) -> list[ModelEntry]:
"""Filter and sort model entries for the frontend dropdown."""
exclude = {
"openai": OPENAI_DROPDOWN_EXCLUDE,
"gemini": GEMINI_DROPDOWN_EXCLUDE,
"vertexai": VERTEXAI_DROPDOWN_EXCLUDE,
}.get(provider, [])
sort_fn = {
"openai": _openai_sort_key,
"anthropic": _anthropic_sort_key,
"gemini": _gemini_sort_key,
"vertexai": _gemini_sort_key,
}.get(provider)
filtered = entries
if exclude:
filtered = [e for e in filtered if not matches_any(e.value, exclude)]
if deprecated:
filtered = [e for e in filtered if e.value not in deprecated]
if provider == "anthropic":
filtered = _deduplicate_by_base(filtered)
if sort_fn:
filtered = sorted(filtered, key=sort_fn)
return filtered
# ─────────────────────────────────────────────────────────────────────────────
# Conversion helpers
# ─────────────────────────────────────────────────────────────────────────────
def model_to_enum_name(model_str: str, provider: str) -> str:
"""Convert a model string to a Java/TS enum name."""
s = model_str
if provider == "vertexai":
s = s.removeprefix("vertex_ai/")
if provider == "openai" and re.match(r"^o\d", s):
s = "GPT_" + s
s = s.lstrip("~")
s = re.sub(r"[-.:\/]", "_", s)
return s.upper()
def generate_openai_label(model_str: str) -> str:
if model_str.startswith("chatgpt-"):
rest = model_str[len("chatgpt-"):]
return "ChatGPT " + _label_parts(rest)
if model_str.startswith("gpt-"):
rest = model_str[len("gpt-"):]
return "GPT " + _label_parts(rest)
if re.match(r"^o\d", model_str):
return "GPT " + _label_parts(model_str)
return _label_parts(model_str)
def generate_anthropic_label(model_str: str) -> str:
s = re.sub(r"-\d{8}$", "", model_str)
parts = s.split("-")
result = []
i = 0
while i < len(parts):
if (
parts[i].isdigit()
and i + 1 < len(parts)
and parts[i + 1].isdigit()
and len(parts[i]) <= 2
and len(parts[i + 1]) <= 2
):
result.append(f"{parts[i]}.{parts[i + 1]}")
i += 2
else:
result.append(parts[i].title() if parts[i].isalpha() else parts[i])
i += 1
return " ".join(result)
def generate_gemini_label(model_str: str) -> str:
s = model_str.removeprefix("vertex_ai/")
# Google dropped the "Gemini" prefix from Gemma branding; the prices JSON
# still carries the old `gemini-gemma-*` keys. Collapse to match current
# naming so the dropdown reads "Gemma 2 …" not "Gemini Gemma 2 …".
s = re.sub(r"^gemini-gemma-", "gemma-", s)
parts = s.split("-")
result = []
for p in parts:
if p.isalpha():
result.append(p.title())
else:
result.append(p)
return " ".join(result)
def _label_parts(s: str) -> str:
"""Convert 'something-else-3.5' to 'Something Else 3.5', keeping version dots."""
parts = s.split("-")
result = []
for p in parts:
if p.isalpha():
result.append(p.title())
elif re.match(r"^\d+[a-z]$", p):
result.append(p)
else:
result.append(p)
return " ".join(result)
def matches_any(s: str, patterns: list[str]) -> bool:
return any(re.search(p, s) for p in patterns)
# ─────────────────────────────────────────────────────────────────────────────
# Source fetching
# ─────────────────────────────────────────────────────────────────────────────
def fetch_openrouter_models() -> list[str]:
"""Fetch chat-capable model IDs from OpenRouter API."""
resp = requests.get(OPENROUTER_API_URL, timeout=30)
resp.raise_for_status()
models = resp.json()["data"]
chat_ids = []
for m in models:
modality = (m.get("architecture") or {}).get("modality", "")
if "text" in modality:
chat_ids.append(m["id"])
return sorted(set(chat_ids))
def fetch_openai_models(api_key: str) -> list[str]:
"""Fetch model IDs from OpenAI API. Returns filtered list of chat model IDs."""
resp = requests.get(
"https://api.openai.com/v1/models",
headers={"Authorization": f"Bearer {api_key}"},
timeout=30,
)
resp.raise_for_status()
ids = [m["id"] for m in resp.json()["data"]]
return sorted(
id_ for id_ in set(ids)
if id_.startswith(OPENAI_CHAT_PREFIXES) and not matches_any(id_, OPENAI_EXCLUDE_PATTERNS)
)
def fetch_anthropic_models(api_key: str) -> list[tuple[str, str]]:
"""Fetch models from Anthropic API. Returns list of (model_id, display_name)."""
results = []
after_id = None
while True:
params = {"limit": 1000}
if after_id:
params["after_id"] = after_id
resp = requests.get(
"https://api.anthropic.com/v1/models",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01",
},
params=params,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
for m in data["data"]:
model_id = m["id"]
if not matches_any(model_id, ANTHROPIC_EXCLUDE_PATTERNS):
results.append((model_id, m.get("display_name", "")))
if not data.get("has_more"):
break
after_id = data.get("last_id")
return sorted(results, key=lambda x: x[0])
def fetch_gemini_models(api_key: str) -> list[tuple[str, str]]:
"""Fetch models from Gemini API. Returns list of (base_model_id, display_name)."""
results = []
page_token = None
while True:
params = {"key": api_key}
if page_token:
params["pageToken"] = page_token
resp = requests.get(
"https://generativelanguage.googleapis.com/v1beta/models",
params=params,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
for m in data.get("models", []):
methods = m.get("supportedGenerationMethods", [])
if "generateContent" not in methods:
continue
base_id = m.get("baseModelId") or m["name"].removeprefix("models/")
if not matches_any(base_id, GEMINI_EXCLUDE_PATTERNS):
results.append((base_id, m.get("displayName", "")))
page_token = data.get("nextPageToken")
if not page_token:
break
return sorted(set(results), key=lambda x: x[0])
def load_model_prices() -> dict:
path = REPO_ROOT / MODEL_PRICES_JSON
with open(path, encoding="utf-8") as f:
return json.load(f)
def extract_models_from_prices(
prices: dict, litellm_provider: str, exclude_patterns: list[str], key_prefix: str = ""
) -> list[tuple[str, bool]]:
"""Extract (model_key, supports_structured_output) from the prices JSON."""
results = []
for key, info in prices.items():
if not isinstance(info, dict):
continue
if info.get("litellm_provider") != litellm_provider:
continue
if info.get("mode") != "chat":
continue
model_key = key.removeprefix(key_prefix) if key_prefix else key
if matches_any(model_key, exclude_patterns):
continue
structured = info.get("supports_response_schema", False)
results.append((model_key, bool(structured)))
return sorted(results, key=lambda x: x[0])
# ─────────────────────────────────────────────────────────────────────────────
# Java file parsing
# ─────────────────────────────────────────────────────────────────────────────
def read_file(rel_path: Path) -> str:
return (REPO_ROOT / rel_path).read_text(encoding="utf-8")
def write_file(rel_path: Path, content: str) -> None:
(REPO_ROOT / rel_path).write_text(content, encoding="utf-8")
def parse_java_enum_2arg(content: str) -> dict[str, tuple[str, bool]]:
"""Parse enums like ENUM_NAME("value", true/false)."""
pattern = re.compile(r"^\s+(\w+)\(\s*\"([^\"]+)\"\s*,\s*(true|false)\s*\)", re.MULTILINE)
return {m.group(1): (m.group(2), m.group(3) == "true") for m in pattern.finditer(content)}
def parse_java_enum_1arg(content: str) -> dict[str, str]:
"""Parse enums like ENUM_NAME("value")."""
pattern = re.compile(r"^\s+(\w+)\(\s*\"([^\"]+)\"\s*\)", re.MULTILINE)
return {m.group(1): m.group(2) for m in pattern.finditer(content)}
def parse_java_enum_3arg(content: str) -> dict[str, tuple[str, str, bool]]:
"""Parse enums like ENUM_NAME("qualified", "value", true/false)."""
pattern = re.compile(
r"^\s+(\w+)\(\s*\"([^\"]+)\"\s*,\s*\"([^\"]+)\"\s*,\s*(true|false)\s*\)",
re.MULTILINE,
)
return {
m.group(1): (m.group(2), m.group(3), m.group(4) == "true")
for m in pattern.finditer(content)
}
def parse_openrouter_structured_set(content: str) -> set[str]:
"""Parse the STRUCTURED_OUTPUT_SUPPORTED_MODELS Set from OpenRouterModelName.java."""
m = re.search(
r"STRUCTURED_OUTPUT_SUPPORTED_MODELS\s*=\s*Set\.of\((.*?)\)",
content,
re.DOTALL,
)
if not m:
return set()
return set(re.findall(r"(\w+)", m.group(1)))
# ─────────────────────────────────────────────────────────────────────────────
# Java file regeneration
# ─────────────────────────────────────────────────────────────────────────────
def _find_enum_body_range(content: str) -> tuple[int, int]:
"""Find the start/end offsets of enum entries.
Returns (body_start, body_end) where:
- body_start is the first char after the enum opening '{' newline
- body_end is the start of the first 'private' line after entries
The caller replaces content[body_start:body_end] with new entries ending in ';\\n\\n'.
"""
enum_open = re.search(r"implements StructuredOutputSupported \{", content)
if not enum_open:
raise ValueError("Could not find enum opening")
body_start = content.index("\n", enum_open.end()) + 1
# The enum entries end before the first 'private' declaration
private_match = re.search(r"^\s+private\s", content[body_start:], re.MULTILINE)
if not private_match:
raise ValueError("Could not find 'private' field after enum entries")
body_end = body_start + private_match.start()
return body_start, body_end
def _format_java_entry_2arg(enum_name: str, value: str, flag: bool) -> str:
"""Format a 2-arg Java enum entry with line splitting if needed."""
line = f" {enum_name}(\"{value}\", {'true' if flag else 'false'}),"
if len(line) <= 120:
return line
return f" {enum_name}(\n \"{value}\", {'true' if flag else 'false'}),"
def _format_java_entry_1arg(enum_name: str, value: str) -> str:
line = f" {enum_name}(\"{value}\"),"
if len(line) <= 120:
return line
return f" {enum_name}(\n \"{value}\"),"
def _format_java_entry_3arg(enum_name: str, qualified: str, value: str, flag: bool) -> str:
line = f" {enum_name}(\"{qualified}\", \"{value}\", {'true' if flag else 'false'}),"
if len(line) <= 120:
return line
return f" {enum_name}(\"{qualified}\",\n \"{value}\", {'true' if flag else 'false'}),"
def _finalize_entries(lines: list[str]) -> str:
"""Turn the last entry's trailing comma into a semicolon."""
if not lines:
return " ;\n"
lines[-1] = lines[-1].rstrip().rstrip(",") + ";"
return "\n".join(lines) + "\n"
def regenerate_openrouter_java(
content: str,
entries: list[tuple[str, str]],
structured_set: set[str],
) -> str:
body_start, body_end = _find_enum_body_range(content)
lines = []
for enum_name, value in entries:
lines.append(_format_java_entry_1arg(enum_name, value))
entry_block = _finalize_entries(lines)
# Rebuild structured output set
so_names = sorted(n for n in structured_set if any(n == e[0] for e in entries))
so_block = ",\n ".join(so_names)
new_content = content[:body_start] + entry_block + content[body_end:]
# Replace the Set.of(...) block
new_content = re.sub(
r"(STRUCTURED_OUTPUT_SUPPORTED_MODELS\s*=\s*Set\.of\().*?(\))",
lambda m: m.group(1) + "\n " + so_block + m.group(2) if so_block else m.group(1) + m.group(2),
new_content,
flags=re.DOTALL,
)
return new_content
def regenerate_java_2arg(content: str, entries: list[tuple[str, str, bool]]) -> str:
body_start, body_end = _find_enum_body_range(content)
lines = [_format_java_entry_2arg(e, v, f) for e, v, f in entries]
return content[:body_start] + _finalize_entries(lines) + content[body_end:]
def regenerate_java_1arg(content: str, entries: list[tuple[str, str]]) -> str:
body_start, body_end = _find_enum_body_range(content)
lines = [_format_java_entry_1arg(e, v) for e, v in entries]
return content[:body_start] + _finalize_entries(lines) + content[body_end:]
def regenerate_java_3arg(content: str, entries: list[tuple[str, str, str, bool]]) -> str:
body_start, body_end = _find_enum_body_range(content)
lines = [_format_java_entry_3arg(e, q, v, f) for e, q, v, f in entries]
return content[:body_start] + _finalize_entries(lines) + content[body_end:]
# ─────────────────────────────────────────────────────────────────────────────
# TypeScript file regeneration
# ─────────────────────────────────────────────────────────────────────────────
_TS_ENUM_SECTION_MARKERS = {
"openai": "// <------ openai",
"anthropic": "// <----- anthropic",
"openrouter": "// <---- OpenRouter",
"gemini": "// <----- gemini",
"vertexai": "// <------ vertex ai",
}
_TS_PROVIDER_ORDER = ["openai", "anthropic", "openrouter", "gemini", "vertexai"]
def regenerate_providers_ts(
content: str,
models_by_provider: dict[str, list[ModelEntry]],
) -> str:
"""Regenerate the PROVIDER_MODEL_TYPE enum sections in providers.ts."""
# Find the enum block
enum_start_m = re.search(r"export enum PROVIDER_MODEL_TYPE \{", content)
if not enum_start_m:
raise ValueError("Could not find PROVIDER_MODEL_TYPE enum")
# Find the opik free line (always first, we preserve it)
opik_free_end = content.index("\n\n", enum_start_m.end()) + 2
# Find the closing brace of the enum
enum_close = content.index("\n}", opik_free_end)
sections = []
for provider in _TS_PROVIDER_ORDER:
marker = _TS_ENUM_SECTION_MARKERS[provider]
entries = models_by_provider.get(provider, [])
lines = [f" {marker}"]
for entry in entries:
lines.append(f' {entry.enum_name} = "{entry.value}",')
sections.append("\n".join(lines))
new_body = "\n\n".join(sections)
return content[:opik_free_end] + new_body + content[enum_close:]
def regenerate_models_data_ts(
content: str,
models_by_provider: dict[str, list[ModelEntry]],
) -> str:
"""Regenerate PROVIDER_MODELS entries in src/constants/providerModels.ts."""
provider_type_map = {
"openai": "OPEN_AI",
"anthropic": "ANTHROPIC",
"openrouter": "OPEN_ROUTER",
"gemini": "GEMINI",
"vertexai": "VERTEX_AI",
}
for provider, entries in models_by_provider.items():
ts_provider = provider_type_map[provider]
section_marker = f"[PROVIDER_TYPE.{ts_provider}]: ["
start_idx = content.index(section_marker)
bracket_start = content.index("[", start_idx + len("[PROVIDER_TYPE."))
depth = 0
pos = bracket_start
while pos < len(content):
if content[pos] == "[":
depth += 1
elif content[pos] == "]":
depth -= 1
if depth == 0:
break
pos += 1
bracket_end = pos
lines = ["["]
for entry in entries:
lines.append(" {")
value_line = f" value: PROVIDER_MODEL_TYPE.{entry.enum_name},"
if len(value_line) > 80:
lines.append(" value:")
lines.append(f" PROVIDER_MODEL_TYPE.{entry.enum_name},")
else:
lines.append(value_line)
lines.append(f' label: "{entry.label}",')
lines.append(" },")
lines.append(" ]")
content = content[:bracket_start] + "\n".join(lines) + content[bracket_end + 1 :]
return content
# ─────────────────────────────────────────────────────────────────────────────
# Per-provider sync logic
# ─────────────────────────────────────────────────────────────────────────────
def sync_openrouter(
api_models: list[str], prices: dict, java_content: str,
) -> tuple[str, list[ModelEntry], list[str], list[str]]:
"""Sync OpenRouter models. Returns (new_java_content, model_entries, added, stale)."""
current = parse_java_enum_1arg(java_content)
current_values = {v for v in current.values()}
current_so = parse_openrouter_structured_set(java_content)
# Build price lookup for structured output
price_so = set()
for key, info in prices.items():
if isinstance(info, dict) and info.get("supports_response_schema"):
price_so.add(key)
# Preserve hand-crafted enum names from existing Java entries
value_to_existing_name = {v: name for name, v in current.items()}
api_set = set(api_models)
# Add-only: keep all existing + add new from API. Report stale for manual review.
all_values = sorted(current_values | api_set)
stale = sorted(current_values - api_set)
entries_for_java = []
model_entries = []
new_so = set()
for value in all_values:
enum_name = value_to_existing_name.get(value) or model_to_enum_name(value, "openrouter")
# Keep existing structured output flags, check prices for new
if enum_name in current_so:
new_so.add(enum_name)
elif value in price_so or f"openrouter/{value}" in price_so:
new_so.add(enum_name)
entries_for_java.append((enum_name, value))
model_entries.append(ModelEntry(
enum_name=enum_name,
value=value,
structured_output=enum_name in new_so,
label=value,
))
added = sorted(api_set - current_values)
new_java = regenerate_openrouter_java(java_content, entries_for_java, new_so)
return new_java, model_entries, added, stale
def sync_simple_provider(
provider: str,
source_models: list[tuple[str, bool]],
java_content: str,
label_fn,
java_format: str = "2arg",
label_overrides: dict[str, str] | None = None,
) -> tuple[str, list[ModelEntry], list[str], list[str]]:
"""Generic add-only sync for OpenAI/Anthropic/Gemini providers.
Never removes models — only adds new ones and reports stale for manual review.
"""
if java_format == "2arg":
current = parse_java_enum_2arg(java_content)
current_values = {v for v, _ in current.values()}
current_so_by_value = {v: so for v, so in current.values()}
elif java_format == "1arg":
current = parse_java_enum_1arg(java_content)
current_values = set(current.values())
current_so_by_value = {}
else:
raise ValueError(f"Unknown format: {java_format}")
source_dict = {k: so for k, so in source_models}
label_overrides = label_overrides or {}
# Preserve hand-crafted enum names from existing Java entries
if java_format == "2arg":
value_to_existing_name = {v: name for name, (v, _) in current.items()}
else:
value_to_existing_name = {v: name for name, v in current.items()}
# Add-only: keep all existing + add new from source. Report stale for manual review.
source_set = set(source_dict.keys())
all_values = current_values | source_set
stale = sorted(current_values - source_set)
entries = []
model_entries = []
used_enum_names: set[str] = set()
skipped_values: set[str] = set()
collisions: list[tuple[str, str]] = []
# Process existing values first to claim their enum names, then new values
existing_first = sorted(v for v in all_values if v in value_to_existing_name)
new_values = sorted(v for v in all_values if v not in value_to_existing_name)
for value in existing_first + new_values:
enum_name = value_to_existing_name.get(value) or model_to_enum_name(value, provider)
if enum_name in used_enum_names:
skipped_values.add(value)
collisions.append((value, enum_name))
continue
used_enum_names.add(enum_name)
if java_format == "2arg":
if value in current_so_by_value:
so = current_so_by_value[value]
else:
so = source_dict.get(value, False)
entries.append((enum_name, value, so))
else:
entries.append((enum_name, value))
label = label_overrides.get(value) or label_fn(value)
model_entries.append(ModelEntry(
enum_name=enum_name,
value=value,
structured_output=current_so_by_value.get(value, source_dict.get(value, False)),
label=label,
))
added = sorted(source_set - current_values - skipped_values)
if java_format == "2arg":
new_java = regenerate_java_2arg(java_content, entries)
else:
new_java = regenerate_java_1arg(java_content, [(e, v) for e, v in entries])
return new_java, model_entries, added, stale, collisions
def sync_vertexai(
source_models: list[tuple[str, bool]],
java_content: str,
label_overrides: dict[str, str] | None = None,
) -> tuple[str, list[ModelEntry], list[str], list[str]]:
"""Add-only sync for VertexAI. Never removes, reports stale for manual review."""
current = parse_java_enum_3arg(java_content)
current_qualified = {q for q, _, _ in current.values()}
current_so_by_qualified = {q: so for q, _, so in current.values()}
source_dict = {k: so for k, so in source_models}
label_overrides = label_overrides or {}
# Preserve hand-crafted enum names from existing Java entries
qualified_to_existing_name = {q: name for name, (q, _, _) in current.items()}
# Add-only: keep all existing + add new from source. Report stale for manual review.
source_set = set(source_dict.keys())
all_qualified = current_qualified | source_set
stale = sorted(current_qualified - source_set)
entries = []
model_entries = []
for qualified in sorted(all_qualified):
value = qualified.removeprefix("vertex_ai/")
enum_name = qualified_to_existing_name.get(qualified) or model_to_enum_name(qualified, "vertexai")
if qualified in current_so_by_qualified:
so = current_so_by_qualified[qualified]
else:
so = source_dict.get(qualified, False)
entries.append((enum_name, qualified, value, so))
label = label_overrides.get(value) or generate_gemini_label(value)
ts_enum_name = enum_name if enum_name.startswith("VERTEX_AI_") else "VERTEX_AI_" + enum_name
model_entries.append(ModelEntry(
enum_name=ts_enum_name,
value=qualified,
structured_output=so,
label=label,
))
added = sorted(source_set - current_qualified)
new_java = regenerate_java_3arg(java_content, entries)
return new_java, model_entries, added, stale
# ─────────────────────────────────────────────────────────────────────────────
# YAML regeneration
# ─────────────────────────────────────────────────────────────────────────────
# Maps internal provider keys → YAML section names
_PROVIDER_TO_YAML_KEY = {
"openai": "openai",
"anthropic": "anthropic",
"gemini": "gemini",
"vertexai": "vertex-ai",
"openrouter": "openrouter",
}
def _parse_yaml_reasoning_flags(yaml_content: str) -> dict[str, dict[str, bool]]:
"""
Parse existing YAML to extract per-provider {model_id: reasoning} flags.
Uses simple line-by-line parsing to avoid requiring pyyaml.
Returns {yaml_section_key: {model_id: True}} for models with reasoning: true.
"""
result: dict[str, dict[str, bool]] = {}
current_provider: str | None = None
current_id: str | None = None
for line in yaml_content.splitlines():
# Top-level provider key (no leading spaces, ends with colon)
provider_match = re.match(r'^(\S[^:]+):\s*$', line)
if provider_match:
current_provider = provider_match.group(1)
current_id = None
continue
if current_provider is None:
continue
# Model id line: " - id: "value""
id_match = re.match(r'^\s+- id:\s+"([^"]+)"', line)
if id_match:
current_id = id_match.group(1)
continue
# reasoning flag line: " reasoning: true"
if current_id and re.match(r'^\s+reasoning:\s+true', line):
result.setdefault(current_provider, {})[current_id] = True
return result
def regenerate_llm_models_yaml(
existing_content: str,
models_by_provider: dict[str, list[ModelEntry]],
dropdown_by_provider: dict[str, list[ModelEntry]] | None = None,
) -> str:
"""
Regenerate llm-models-default.yaml from the synced model entries.
- Replaces sections for providers managed by the sync script.
- Emits the *full* model list per provider so the YAML stays a superset
and remains a complete routing fallback for the backend when the
remote CDN is unavailable. Dropdown-curated entries (when
`dropdown_by_provider` is supplied) lead their provider section in
curated order and carry their human-readable `label`; the remaining
entries follow alphabetically with no label.
- Preserves reasoning flags carried over from the existing file.
- Sections for providers not managed here (bedrock, ollama, opik-free,
custom-llm) are preserved as-is if present.
"""
# Carry over existing reasoning flags by provider/model-id
reasoning_flags = _parse_yaml_reasoning_flags(existing_content)
dropdown_by_provider = dropdown_by_provider or {}
lines: list[str] = []
for provider_key, yaml_key in _PROVIDER_TO_YAML_KEY.items():
entries = models_by_provider.get(provider_key, [])
provider_reasoning = reasoning_flags.get(yaml_key, {})
lines.append(f"{yaml_key}:")
if not entries:
lines.append(" []")
continue
# Build the final ordering: dropdown-curated entries first (in curated
# order, with labels), then the remaining models alphabetically (no
# label, same entry identity as in `entries`).
dropdown_entries = dropdown_by_provider.get(provider_key, [])
dropdown_values = {e.value for e in dropdown_entries}
non_dropdown_entries = sorted(
(e for e in entries if e.value not in dropdown_values),
key=lambda e: e.value,
)
ordered_entries = list(dropdown_entries) + non_dropdown_entries
for entry in ordered_entries:
if provider_key == "vertexai":
# value is qualified name (vertex_ai/gemini-...), id is base name
model_id = entry.value.removeprefix("vertex_ai/")
lines.append(f' - id: "{model_id}"')
lines.append(f' qualifiedName: "{entry.value}"')
else:
model_id = entry.value
lines.append(f' - id: "{model_id}"')
# Emit a label for every dropdown-visible entry, even when it
# equals the id. The FE filter uses label-presence as the
# "is this dropdown-visible?" signal, so dropping labels for
# OpenRouter (where label == id by convention) made every
# OpenRouter model invisible in the picker (OPIK-6360). The
# extra ~30KB on the cold-cached YAML is a non-issue compared
# to the correctness fragility of the omit-when-equal rule.
is_dropdown_entry = entry.value in dropdown_values
if is_dropdown_entry and entry.label:
escaped_label = entry.label.replace('"', '\\"')
lines.append(f' label: "{escaped_label}"')
if entry.structured_output:
lines.append(" structuredOutput: true")
if provider_reasoning.get(model_id):
lines.append(" reasoning: true")
# Preserve any provider sections not managed by the sync script
managed_yaml_keys = set(_PROVIDER_TO_YAML_KEY.values())
unmanaged_lines: list[str] = []
current_section: list[str] = []
current_key: str | None = None
for line in existing_content.splitlines():
provider_match = re.match(r'^(\S[^:]+):\s*$', line)
if provider_match:
key = provider_match.group(1)
if current_key and current_key not in managed_yaml_keys:
unmanaged_lines.extend(current_section)
current_key = key
current_section = [line]
else:
current_section.append(line)
if current_key and current_key not in managed_yaml_keys:
unmanaged_lines.extend(current_section)
if unmanaged_lines:
lines.extend(unmanaged_lines)
return "\n".join(lines) + "\n"
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def _build_structured_output_lookup(prices: dict) -> dict[str, bool]:
"""Build model_key → supports_structured_output from prices JSON."""
lookup = {}
for key, info in prices.items():
if isinstance(info, dict):
lookup[key] = bool(info.get("supports_response_schema", False))
return lookup
def _get_vertexai_models_from_prices(prices: dict) -> list[tuple[str, bool]]:
"""Extract VertexAI models from the prices JSON."""
vertexai_models = extract_models_from_prices(
prices, "vertex_ai-chat-models", [], key_prefix=""
)
vertexai_models_alt = extract_models_from_prices(
prices, "vertex_ai", GEMINI_EXCLUDE_PATTERNS, key_prefix=""
)
vertexai_all = {}
for k, so in vertexai_models + vertexai_models_alt:
if k.startswith("vertex_ai/gemini-"):
vertexai_all[k] = vertexai_all.get(k, False) or so
return sorted(vertexai_all.items(), key=lambda x: x[0])
def main():
parser = argparse.ArgumentParser(description="Sync LLM provider model definitions")
parser.add_argument("--dry-run", action="store_true", help="Preview changes without writing")
parser.add_argument("--force-regen", action="store_true", help="Regenerate and write files even when no new models were found")
args = parser.parse_args()
print("## Provider Model Sync\n")
openai_key = os.environ.get("OPENAI_API_KEY")
anthropic_key = os.environ.get("ANTHROPIC_API_KEY")
gemini_key = os.environ.get("GEMINI_API_KEY")
prices = load_model_prices()
so_lookup = _build_structured_output_lookup(prices)
deprecated = build_deprecated_set(prices)
# 1. Fetch sources — prefer provider APIs when keys are available
# OpenRouter (always from API, no key needed)
print("Fetching OpenRouter models...", file=sys.stderr)
try:
openrouter_api_models = fetch_openrouter_models()
print(f" Found {len(openrouter_api_models)} chat models from API", file=sys.stderr)
except Exception as e:
print(f" WARNING: OpenRouter API fetch failed: {e}", file=sys.stderr)
openrouter_api_models = []
# OpenAI
if openai_key:
print("Fetching OpenAI models from API...", file=sys.stderr)
try:
openai_ids = fetch_openai_models(openai_key)
openai_models = [(id_, so_lookup.get(id_, False)) for id_ in openai_ids]
print(f" Found {len(openai_models)} models from API", file=sys.stderr)
except Exception as e:
print(f" WARNING: OpenAI API fetch failed, falling back to prices JSON: {e}", file=sys.stderr)
openai_models = extract_models_from_prices(prices, "openai", OPENAI_EXCLUDE_PATTERNS)
else:
print(" OpenAI: using prices JSON (no OPENAI_API_KEY)", file=sys.stderr)
openai_models = extract_models_from_prices(prices, "openai", OPENAI_EXCLUDE_PATTERNS)
# Anthropic
anthropic_labels: dict[str, str] = {}
if anthropic_key:
print("Fetching Anthropic models from API...", file=sys.stderr)
try:
anthropic_api = fetch_anthropic_models(anthropic_key)
anthropic_models = [(id_, so_lookup.get(id_, so_lookup.get(f"anthropic/{id_}", False))) for id_, _ in anthropic_api]
anthropic_labels = {id_: name for id_, name in anthropic_api if name}
print(f" Found {len(anthropic_models)} models from API", file=sys.stderr)
except Exception as e:
print(f" WARNING: Anthropic API fetch failed, falling back to prices JSON: {e}", file=sys.stderr)
anthropic_models = extract_models_from_prices(prices, "anthropic", ANTHROPIC_EXCLUDE_PATTERNS)
else:
print(" Anthropic: using prices JSON (no ANTHROPIC_API_KEY)", file=sys.stderr)
anthropic_models = extract_models_from_prices(prices, "anthropic", ANTHROPIC_EXCLUDE_PATTERNS)
# Gemini
gemini_labels: dict[str, str] = {}
if gemini_key:
print("Fetching Gemini models from API...", file=sys.stderr)
try:
gemini_api = fetch_gemini_models(gemini_key)
gemini_models = [(id_, so_lookup.get(f"gemini/{id_}", so_lookup.get(id_, False))) for id_, _ in gemini_api]
gemini_labels = {id_: name for id_, name in gemini_api if name}
print(f" Found {len(gemini_models)} models from API", file=sys.stderr)
except Exception as e:
print(f" WARNING: Gemini API fetch failed, falling back to prices JSON: {e}", file=sys.stderr)
gemini_models = extract_models_from_prices(
prices, "gemini", GEMINI_EXCLUDE_PATTERNS, key_prefix="gemini/"
)
else:
print(" Gemini: using prices JSON (no GEMINI_API_KEY)", file=sys.stderr)
gemini_models = extract_models_from_prices(
prices, "gemini", GEMINI_EXCLUDE_PATTERNS, key_prefix="gemini/"
)
# VertexAI (always from prices JSON, but can use Gemini API labels)
vertexai_models = _get_vertexai_models_from_prices(prices)
# 2. Read current files
or_java = read_file(OPENROUTER_JAVA)
oa_java = read_file(OPENAI_JAVA)
an_java = read_file(ANTHROPIC_JAVA)
ge_java = read_file(GEMINI_JAVA)
va_java = read_file(VERTEXAI_JAVA)
providers_ts = read_file(PROVIDERS_TS)
models_data_ts = read_file(MODELS_DATA_TS)
# 3. Sync each provider
all_changes = {}
new_or_java, or_entries, or_added, or_stale = sync_openrouter(
openrouter_api_models, prices, or_java,
)
all_changes["openrouter"] = {"entries": or_entries, "added": or_added, "stale": or_stale}
new_oa_java, oa_entries, oa_added, oa_stale, oa_collisions = sync_simple_provider(
"openai", openai_models, oa_java, generate_openai_label, "2arg",
)
all_changes["openai"] = {"entries": oa_entries, "added": oa_added, "stale": oa_stale, "collisions": oa_collisions}
new_an_java, an_entries, an_added, an_stale, an_collisions = sync_simple_provider(
"anthropic", anthropic_models, an_java, generate_anthropic_label, "1arg",
label_overrides=anthropic_labels,
)
all_changes["anthropic"] = {"entries": an_entries, "added": an_added, "stale": an_stale, "collisions": an_collisions}
new_ge_java, ge_entries, ge_added, ge_stale, ge_collisions = sync_simple_provider(
"gemini", gemini_models, ge_java, generate_gemini_label, "2arg",
label_overrides=gemini_labels,
)
all_changes["gemini"] = {"entries": ge_entries, "added": ge_added, "stale": ge_stale, "collisions": ge_collisions}
new_va_java, va_entries, va_added, va_stale = sync_vertexai(
vertexai_models, va_java,
label_overrides=gemini_labels,
)
all_changes["vertexai"] = {"entries": va_entries, "added": va_added, "stale": va_stale}
# 4. Regenerate TypeScript files
# TS enum (providers.ts) gets ALL models — same as Java enums
models_by_provider = {k: v["entries"] for k, v in all_changes.items()}
new_providers_ts = regenerate_providers_ts(providers_ts, models_by_provider)
# Dropdown (src/constants/providerModels.ts) gets curated subset — filtered and sorted
dropdown_by_provider = {
provider: filter_for_dropdown(entries, provider, deprecated)
for provider, entries in models_by_provider.items()
}
new_models_data_ts = regenerate_models_data_ts(models_data_ts, dropdown_by_provider)
# Regenerate llm-models-default.yaml with the full model list per provider
# so the YAML stays a superset and the backend's classpath fallback covers
# every routable model. Dropdown-curated entries lead their provider
# section in curated order and carry human-readable labels; the rest
# follow alphabetically without a label.
llm_models_yaml_content = read_file(LLM_MODELS_YAML)
new_llm_models_yaml = regenerate_llm_models_yaml(
llm_models_yaml_content, models_by_provider, dropdown_by_provider,
)
# 5. Print summary
total_added = 0
total_stale = 0
total_deprecated = 0
for provider, changes in all_changes.items():
added = changes["added"]
stale = changes.get("stale", [])
total_added += len(added)
total_stale += len(stale)
entries = changes["entries"]
dropdown = dropdown_by_provider[provider]
dep_in_enum = sorted(e.value for e in entries if e.value in deprecated)
total_deprecated += len(dep_in_enum)
print(f"### {provider.title()}")
if added:
print(f"- Added {len(added)} model(s):")
for m in added:
print(f" + {m}")
if stale:
print(f"- Stale {len(stale)} model(s) (not in source, manual review needed):")
for m in stale:
print(f" ? {m}")
if dep_in_enum:
print(f"- Deprecated {len(dep_in_enum)} model(s) (past deprecation_date, excluded from dropdown):")
for m in dep_in_enum:
print(f" \u2717 {m}")
collisions = changes.get("collisions", [])
if collisions:
print(f"- Skipped {len(collisions)} model(s) (enum name collision with existing entry):")
for value, enum_name in collisions:
print(f" ! {value}{enum_name}")
if not added and not stale and not dep_in_enum:
print(f"- No changes (total: {len(entries)}, dropdown: {len(dropdown)})")
else:
print(f"- Total models: {len(entries)} (dropdown: {len(dropdown)})")
print()
if total_added == 0 and not args.force_regen:
if total_stale > 0:
print(f"No new models found. {total_stale} stale model(s) flagged for manual review.")
else:
print("No changes found.")
sys.exit(1)
if total_added == 0 and args.force_regen:
print("No new models found, but --force-regen set: regenerating files anyway.")
if args.dry_run:
print(f"\n**Dry run**: {total_added} added. No files written.")
if total_stale > 0:
print(f"{total_stale} stale model(s) flagged for manual review.")
if total_deprecated > 0:
print(f"{total_deprecated} deprecated model(s) excluded from dropdown.")
sys.exit(0)
# 6. Write files
write_file(OPENROUTER_JAVA, new_or_java)
write_file(OPENAI_JAVA, new_oa_java)
write_file(ANTHROPIC_JAVA, new_an_java)
write_file(GEMINI_JAVA, new_ge_java)
write_file(VERTEXAI_JAVA, new_va_java)
write_file(PROVIDERS_TS, new_providers_ts)
write_file(MODELS_DATA_TS, new_models_data_ts)
write_file(LLM_MODELS_YAML, new_llm_models_yaml)
print(f"\nWrote changes ({total_added} added) across 8 files.", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()