"""Sync the together_ai entries of model_prices_and_context_window.json with Together's live serverless catalog. Pulls ``GET https://api.together.ai/v1/models?serverless`` plus the deprecations doc, maps API fields onto registry fields, merges the reviewed capability rules below for everything the API cannot express, and diffs the result against the registry. Dry run (the default) prints the diff summary and the generated PR body; ``--write`` applies the changes to the root cost map and its ``litellm/`` backup copy. Policy highlights: - Prices arrive per 1M tokens with float artifacts and are normalized to clean per-token values. - A registry entry absent from the serverless catalog is marked with ``deprecation_date`` from the docs deprecation table, never deleted; absences with no docs date are surfaced for a human call. - Availability comes from the API: a model the docs list as removed but the API still serves stays live, with the conflict surfaced as a warning. - Manually curated values the API cannot express (``metadata.successor``, ``max_output_tokens`` on existing entries, capability flags no rule covers) are never overwritten; conflicts are surfaced instead. """ import argparse import json import os import re import sys from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path from types import MappingProxyType from typing import Final import httpx from pydantic import BaseModel, TypeAdapter, ValidationError MODELS_URL: Final = "https://api.together.ai/v1/models?serverless" DEPRECATIONS_URL: Final = "https://docs.together.ai/docs/deprecations.md" PROVIDER: Final = "together_ai" PREFIX: Final = "together_ai/" SOURCE_URL: Final = "https://docs.together.ai/docs/serverless-models" COST_MAP_RELPATHS: Final = ( "model_prices_and_context_window.json", "litellm/model_prices_and_context_window_backup.json", ) TYPE_TO_MODE: Final = MappingProxyType({"chat": "chat", "embedding": "embedding", "moderation": "chat"}) class SyncError(RuntimeError): pass class CatalogPricing(BaseModel): input: float output: float cached_input: float | None = None class CatalogModel(BaseModel): id: str type: str context_length: int | None = None pricing: CatalogPricing CATALOG_ADAPTER: Final = TypeAdapter(list[CatalogModel]) RegistryEntry = dict[str, object] CostMap = dict[str, object] @dataclass(frozen=True, slots=True) class CapabilityRule: model_id: str fields: Mapping[str, bool | int] provenance: str def _rule(model_id: str, provenance: str, **fields: bool | int) -> CapabilityRule: return CapabilityRule(model_id=model_id, fields=MappingProxyType(dict(fields)), provenance=provenance) _TOOLS: Final = MappingProxyType( { "supports_function_calling": True, "supports_parallel_function_calling": True, "supports_response_schema": True, "supports_tool_choice": True, } ) CAPABILITY_RULES: Final = ( _rule( "MiniMaxAI/MiniMax-M3", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/minimax-m3", **_TOOLS, supports_reasoning=True, supports_vision=True, ), _rule("Prism-ML/Ternary-Bonsai-27B", "reviewed for the LIT-5968 backfill; no tool or vision support documented"), _rule( "Qwen/Qwen3.5-9B", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/qwen3-5-9b", **_TOOLS, supports_reasoning=True, supports_vision=True, ), _rule( "Qwen/Qwen3.6-Plus", "reviewed for the LIT-5968 backfill; hybrid reasoning model without a documented tools contract", supports_reasoning=True, ), _rule("Qwen/Qwen3.7-Max", "reviewed for the LIT-5968 backfill; no tool or vision support documented"), _rule("Qwen/Qwen3.7-Plus", "reviewed for the LIT-5968 backfill; no tool or vision support documented"), _rule("Qwen/Qwen3.8-2.4T-A95B", "reviewed for the LIT-5968 backfill; no tool or vision support documented"), _rule("arize-ai/qwen-2-1.5b-instruct", "reviewed for the LIT-5968 backfill; no tool or vision support documented"), _rule( "deepseek-ai/DeepSeek-V4-Flash-0731", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/deepseek-v4-flash", **_TOOLS, ), _rule( "deepseek-ai/DeepSeek-V4-Pro", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/deepseek-v4-pro", **_TOOLS, supports_reasoning=True, ), _rule( "deepseek-ai/DeepSeek-V4-Pro-0813", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/deepseek-v4-pro", **_TOOLS, ), _rule("google/gemma-3n-E4B-it", "reviewed for the LIT-5968 backfill; no tool or vision support documented"), _rule( "google/gemma-4-31B-it", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/gemma-4-31b-it", **_TOOLS, supports_vision=True, ), _rule( "intfloat/multilingual-e5-large-instruct", "embedding dims per https://huggingface.co/intfloat/multilingual-e5-large-instruct", output_vector_size=1024, ), _rule( "meta-llama/Llama-3.3-70B-Instruct-Turbo", "reviewed for the LIT-5968 backfill against https://docs.together.ai/docs/function-calling", **_TOOLS, ), _rule( "meta-llama/Llama-Guard-4-12B", "moderation classifier with a chat-shaped API; no tools per the LIT-5968 backfill review", ), _rule("meta-models/Muse-Glimmer-30B", "reviewed for the LIT-5968 backfill; no tool or vision support documented"), _rule( "moonshotai/Kimi-K2.7-Code", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/kimi-k2-7-code", **_TOOLS, supports_vision=True, ), _rule( "moonshotai/Kimi-K3", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/kimi-k3", **_TOOLS, supports_reasoning=True, supports_vision=True, ), _rule( "nvidia/nemotron-3-ultra-550b-a55b", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/nemotron-3-ultra", **_TOOLS, supports_reasoning=True, ), _rule( "openai/gpt-oss-120b", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/gpt-oss-120b", **_TOOLS, supports_reasoning=True, ), _rule( "openai/gpt-oss-20b", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/gpt-oss-20b", **_TOOLS, ), _rule("pearl-ai/gemma-4-31b-it", "reviewed for the LIT-5968 backfill; no tool or vision support documented"), _rule( "thinkingmachines/Inkling", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/inkling", **_TOOLS, ), _rule("thinkingmachines/Inkling-Small", "reviewed for the LIT-5968 backfill; no tool or vision support documented"), _rule( "zai-org/GLM-5.2", "reviewed for the LIT-5968 backfill against https://www.together.ai/models/glm-5-2", **_TOOLS, supports_reasoning=True, ), ) RULES_BY_ID: Final = MappingProxyType({rule.model_id: rule for rule in CAPABILITY_RULES}) @dataclass(frozen=True, slots=True) class DeprecationDoc: removal_dates: Mapping[str, str] redirects: Mapping[str, str] _REDIRECT_ROW: Final = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*`([^`]+)`\s*\|") _REMOVAL_ROW: Final = re.compile(r"^\|\s*(\d{4}-\d{2}-\d{2})\s*\|\s*`([^`]+)`\s*\|") def _section(markdown: str, heading: str) -> str: level: Final = heading.split(" ", 1)[0] start: Final = markdown.find(f"\n{heading}\n") if start < 0: return "" body: Final = markdown[start + 1 + len(heading) :] next_heading: Final = re.search(rf"^{re.escape(level)} ", body, flags=re.MULTILINE) return body[: next_heading.start()] if next_heading else body def parse_deprecations(markdown: str) -> DeprecationDoc: redirect_rows: Final = tuple( m.groups() for m in (_REDIRECT_ROW.match(line) for line in _section(markdown, "## Active model redirects").splitlines()) if m ) inference: Final = _section(_section(markdown, "## Deprecation history"), "### Inference") removal_rows: Final = tuple(m.groups() for m in (_REMOVAL_ROW.match(line) for line in inference.splitlines()) if m) if not redirect_rows or not removal_rows: raise SyncError( "deprecations doc parsed to zero redirect or removal rows; the table format at " f"{DEPRECATIONS_URL} changed and the parser needs updating" ) removal_dates: Final = {model: date for date, model in reversed(removal_rows)} return DeprecationDoc( removal_dates=MappingProxyType(dict(reversed(removal_dates.items()))), redirects=MappingProxyType({original: target for original, target in redirect_rows}), ) def per_token(price_per_million: float) -> float: return float(f"{price_per_million / 1e6:.6g}") def _resolve_name(name: str, universe: frozenset[str]) -> str | None: if name in universe: return name suffix_matches: Final = tuple(candidate for candidate in universe if candidate.endswith(f"/{name}")) return suffix_matches[0] if len(suffix_matches) == 1 else None def resolve_successor(model_id: str, doc: DeprecationDoc, live_ids: frozenset[str]) -> str | None: canonical: Final = live_ids | frozenset(doc.removal_dates) redirects: Final = { (_resolve_name(raw_source, canonical) or raw_source): (_resolve_name(raw_target, canonical) or raw_target) for raw_source, raw_target in doc.redirects.items() } seen: Final = set() current = model_id # rebind-ok: walks the redirect chain while current in redirects and current not in seen: seen.add(current) current = redirects[current] # rebind-ok: walks the redirect chain return current if current != model_id and current in live_ids else None @dataclass(frozen=True, slots=True) class SyncOutcome: cost_map: CostMap added: tuple[str, ...] = () updated: tuple[str, ...] = () deprecated: tuple[str, ...] = () reappeared: tuple[str, ...] = () warnings: tuple[str, ...] = () skipped_types: Mapping[str, int] = field(default_factory=dict) @property def has_changes(self) -> bool: return bool(self.added or self.updated or self.deprecated or self.reappeared) def _api_fields(model: CatalogModel) -> RegistryEntry: cached: Final = model.pricing.cached_input return { "input_cost_per_token": per_token(model.pricing.input), "output_cost_per_token": per_token(model.pricing.output), **({"cache_read_input_token_cost": per_token(cached), "supports_prompt_caching": True} if cached else {}), **({"max_input_tokens": model.context_length} if model.context_length is not None else {}), } def _new_entry(model: CatalogModel, mode: str) -> RegistryEntry: rule: Final = RULES_BY_ID.get(model.id) length_fields: Final = ( {} if model.context_length is None else {"max_input_tokens": model.context_length, "max_tokens": model.context_length} | ({"max_output_tokens": model.context_length} if mode == "chat" else {}) ) merged: Final = { **_api_fields(model), **length_fields, "litellm_provider": PROVIDER, "mode": mode, "source": SOURCE_URL, **(dict(rule.fields) if rule else {}), } return dict(sorted(merged.items())) def _updated_entry(entry: RegistryEntry, model: CatalogModel) -> tuple[RegistryEntry, tuple[str, ...]]: rule: Final = RULES_BY_ID.get(model.id) desired: Final = {**_api_fields(model), **(dict(rule.fields) if rule else {})} dropped: Final = () if model.pricing.cached_input else ("cache_read_input_token_cost", "supports_prompt_caching") changes: Final = tuple( f"{name}: {entry.get(name)!r} -> {value!r}" for name, value in desired.items() if entry.get(name) != value ) + tuple( f"{name}: {entry[name]!r} removed (no longer in the catalog pricing)" for name in dropped if name in entry ) merged: Final = {name: value for name, value in {**entry, **desired}.items() if name not in dropped} return dict(sorted(merged.items())), changes def _with_new_keys_in_block(original: CostMap, result: CostMap, new_keys: Sequence[str]) -> CostMap: provider_keys: Final = tuple(key for key in original if key.startswith(PREFIX)) if not new_keys or not provider_keys: return result block_end: Final = provider_keys[-1] return { key: value for existing in original for key, value in ( (existing, result[existing]), *((new, result[new]) for new in sorted(new_keys) if existing == block_end), ) } def compute_sync(cost_map: CostMap, catalog: Sequence[CatalogModel], doc: DeprecationDoc) -> SyncOutcome: live_ids: Final = frozenset(model.id for model in catalog) token_models: Final = {model.id: model for model in catalog if model.type in TYPE_TO_MODE} skipped: Final = { model.type: sum(1 for m in catalog if m.type == model.type) for model in catalog if model.type not in TYPE_TO_MODE } registry_ids: Final = {key.removeprefix(PREFIX): key for key in cost_map if key.startswith(PREFIX)} added: Final[list[str]] = [] updated: Final[list[str]] = [] deprecated: Final[list[str]] = [] reappeared: Final[list[str]] = [] warnings: Final[list[str]] = [] result: Final[CostMap] = dict(cost_map) for model_id, model in sorted(token_models.items()): mode: Final = TYPE_TO_MODE[model.type] key: Final = f"{PREFIX}{model_id}" if model_id in doc.removal_dates: warnings.append( f"`{key}` is listed as removed on {doc.removal_dates[model_id]} in the docs but the serverless " "catalog still serves it; availability kept from the API" ) entry = result.get(key) if not isinstance(entry, dict): result[key] = _new_entry(model, mode) added.append(key) if model.type == "chat" and model_id not in RULES_BY_ID: warnings.append( f"`{key}` added without a capability rule; review its tools/vision/reasoning support and add one" ) continue if entry.get("mode") != mode: warnings.append( f"`{key}` has curated mode {entry.get('mode')!r} but the catalog maps to {mode!r}; left unchanged" ) new_entry, changes = _updated_entry(entry, model) if "deprecation_date" in new_entry: new_entry.pop("deprecation_date") reappeared.append(key) if changes: updated.append(f"{key}: " + "; ".join(changes)) if changes or key in reappeared: result[key] = new_entry for model_id, key in sorted(registry_ids.items()): if model_id in token_models: continue entry = result.get(key) if not isinstance(entry, dict): continue removal_date: Final = doc.removal_dates.get(model_id) successor: Final = resolve_successor(model_id, doc, live_ids) metadata = entry.get("metadata") curated_successor: Final = metadata.get("successor") if isinstance(metadata, dict) else None new_entry = dict(entry) if removal_date is not None and entry.get("deprecation_date") != removal_date: if "deprecation_date" in entry: warnings.append( f"`{key}` has curated deprecation_date {entry.get('deprecation_date')!r} but the docs list " f"{removal_date!r}; left unchanged" ) else: new_entry["deprecation_date"] = removal_date if removal_date is None and "deprecation_date" not in entry: warnings.append( f"`{key}` is absent from the serverless catalog with no removal date in the docs; " "needs a human deprecation call" ) if successor is not None: desired_successor: Final = f"{PREFIX}{successor}" if curated_successor is None: new_entry["metadata"] = dict( sorted({**(metadata if isinstance(metadata, dict) else {}), "successor": desired_successor}.items()) ) elif curated_successor != desired_successor: warnings.append( f"`{key}` has curated successor {curated_successor!r} but the docs redirects resolve to " f"{desired_successor!r}; left unchanged" ) if new_entry != entry: result[key] = dict(sorted(new_entry.items())) deprecated.append(f"{key}: " + ", ".join(sorted(set(new_entry) - set(entry)) or ["updated"])) return SyncOutcome( cost_map=_with_new_keys_in_block(cost_map, result, tuple(added)), added=tuple(added), updated=tuple(updated), deprecated=tuple(deprecated), reappeared=tuple(reappeared), warnings=tuple(warnings), skipped_types=MappingProxyType(skipped), ) def _section_block(title: str, lines: Sequence[str], backtick: bool) -> str: bullets: Final = "\n".join(f"- `{line}`" if backtick else f"- {line}" for line in lines) or "- none" return f"### {title} ({len(lines)})\n{bullets}\n" def render_pr_body(outcome: SyncOutcome) -> str: skipped: Final = ", ".join(f"{kind} ({count})" for kind, count in sorted(outcome.skipped_types.items())) or "none" return ( "Automated daily sync of the together_ai entries in model_prices_and_context_window.json against " f"`GET {MODELS_URL}` and {DEPRECATIONS_URL} by scripts/sync_together_ai_models.py.\n" "\n" f"{_section_block('Added', outcome.added, backtick=True)}" "\n" f"{_section_block('Updated', outcome.updated, backtick=True)}" "\n" f"{_section_block('Marked deprecated', outcome.deprecated, backtick=True)}" "\n" f"{_section_block('Returned to the catalog', outcome.reappeared, backtick=True)}" "\n" f"{_section_block('Warnings needing a human call', outcome.warnings, backtick=False)}" "\n" f"Catalog model types outside the sync's token-pricing scope, skipped: {skipped}\n" ) def render_summary(outcome: SyncOutcome) -> str: return ( f"added={len(outcome.added)} updated={len(outcome.updated)} deprecated={len(outcome.deprecated)} " f"reappeared={len(outcome.reappeared)} warnings={len(outcome.warnings)}" ) def load_catalog(raw: bytes) -> list[CatalogModel]: parsed: Final = json.loads(raw) entries: Final = parsed.get("data") if isinstance(parsed, dict) else parsed try: catalog: Final = CATALOG_ADAPTER.validate_python(entries) except ValidationError as error: raise SyncError(f"the catalog response no longer matches the expected shape: {error}") from error if not any(model.type in TYPE_TO_MODE for model in catalog): raise SyncError( "the catalog response contains no token-priced models; refusing to mark the whole registry deprecated" ) return catalog def _fetch(url: str, headers: Mapping[str, str]) -> bytes: response: Final = httpx.get(url, headers=dict(headers), timeout=30, follow_redirects=True) if response.status_code != 200: raise SyncError(f"GET {url} returned {response.status_code}") return response.content def _serialize(cost_map: CostMap) -> str: return json.dumps(cost_map, indent=4, ensure_ascii=False) + "\n" def main(argv: Sequence[str]) -> int: parser: Final = argparse.ArgumentParser(description=__doc__) parser.add_argument("--write", action="store_true", help="apply the sync to the cost map files (default: dry run)") parser.add_argument("--models-json", type=Path, help="recorded catalog response to use instead of the live API") parser.add_argument( "--deprecations-md", type=Path, help="recorded deprecations doc to use instead of the live docs" ) parser.add_argument("--pr-body-file", type=Path, help="write the generated PR body to this path") parser.add_argument("--repo-root", type=Path, default=Path(__file__).resolve().parent.parent) args: Final = parser.parse_args(argv) if args.models_json is not None: catalog_raw: Final = args.models_json.read_bytes() else: api_key: Final = os.environ.get("TOGETHER_API_KEY") if not api_key: raise SyncError("TOGETHER_API_KEY is not set and --models-json was not given") catalog_raw = _fetch(MODELS_URL, {"Authorization": f"Bearer {api_key}"}) # rebind-ok: branch-dependent source catalog: Final = load_catalog(catalog_raw) markdown: Final = ( args.deprecations_md.read_text() if args.deprecations_md is not None else _fetch(DEPRECATIONS_URL, {}).decode() ) doc: Final = parse_deprecations(markdown) cost_map_path: Final = args.repo_root / COST_MAP_RELPATHS[0] cost_map: Final = json.loads(cost_map_path.read_text()) outcome: Final = compute_sync(cost_map, catalog, doc) body: Final = render_pr_body(outcome) if args.pr_body_file is not None: args.pr_body_file.write_text(body) if args.write and outcome.has_changes: for relpath in COST_MAP_RELPATHS: (args.repo_root / relpath).write_text(_serialize(outcome.cost_map)) print(render_summary(outcome)) print() print(body) if not args.write: print("dry run: no files were touched") elif not outcome.has_changes: print("registry already in sync: no files were touched") return 0 if __name__ == "__main__": try: raise SystemExit(main(sys.argv[1:])) except SyncError as error: print(f"SYNC FAILED: {error}", file=sys.stderr) raise SystemExit(1) from error