"""OpenRouter — public model catalogue `https://openrouter.ai/api/v1/models` (one JSON document, ≈ 440 entries). Tier 2: official provider pages win conflicts. Each entry: `id` = `vendor/slug[:variant]`, `name` ("Vendor: Model"), `description`, `context_length`, `architecture` (input/output modalities, tokenizer), `pricing` (USD per token as strings → ×1e6 = USD per 1M tokens), `top_provider.max_completion_tokens`, `supported_parameters`, `created` (epoch), `hugging_face_id`. Entries whose id starts with `~` are alias rows (`alias_target`) and are skipped; `-1` prices (dynamic routing) are skipped. Model identity: `{"openrouter": ""}` (variant suffix stripped) + `{"hf_repo": …}` when the catalogue gives it; aliases and the developer organisation come from `connectors/_identity.py` (never a vendor `*_model_id` — OpenRouter slugs differ from the labs' API ids). `created` is the OpenRouter *listing* date (`openrouter_listed_at`), never a release date. Pricing: every price is booked on the **OpenRouter provider entity only** (`provider_ref("openrouter")`) with `features.upstream_provider` = the registry provider key of the routed vendor when it is one — never on the lab's own provider entity, which would create a second "current" price per model × provider. `provider_model_id` = the full OpenRouter id (variants `:free` / `:thinking` / `:nitro` are separate price rows of the same model, `features.variant`). """ from __future__ import annotations import re from datetime import UTC, datetime from typing import Any from aiatlas.connectors._identity import family_ref, model_identity, org_key_for_vendor, org_ref_in from aiatlas.ontology.taxonomy import normalize_modalities from aiatlas.registry import provider_by_openrouter, provider_ref, providers from aiatlas.sdk.connector import BaseConnector, Parsed, RunContext from aiatlas.sdk.facts import EntityRef, Facts, Target from aiatlas.sdk.fetch import FetchResult URL = "https://openrouter.ai/api/v1/models" VENDOR_PROVIDER = {"x-ai": "xai", "mistralai": "mistral"} VARIANTS = {"free", "extended", "nitro", "floor", "online", "thinking", "beta", "exacto", "fast"} class OpenRouterConnector(BaseConnector): name = "openrouter" label = "OpenRouter — model catalogue with routed pricing" description = "Public JSON catalogue: context, modalities, supported parameters and per-token prices of every model routed by OpenRouter." source_key = "openrouter.ai" version = "2" parser_version = "2" interval_seconds = 3 * 3600 min_interval_seconds = 3600 max_interval_seconds = 86400 rate_per_min = 10 tier = 2 priority = 1 expected_min_records = 150 concurrency = 1 async def discover(self, ctx: RunContext) -> list[Target]: return [Target(url=URL, doc_type="catalogue", key="models", min_bytes=50000, meta={"content_type": "application/json"}, priority=1)] async def extract(self, ctx: RunContext, target: Target, res: FetchResult, parsed: Parsed) -> Facts: facts = Facts() data = parsed.json if parsed.kind == "json" else None items = data.get("data") if isinstance(data, dict) else None if not isinstance(items, list): return facts aggregator = provider_ref("openrouter") facts.entities.append(aggregator) for item in items: try: self._model(facts, item, aggregator) except Exception as exc: # noqa: BLE001 — one malformed row must not sink the catalogue ctx.log.warning("openrouter row skipped", extra={"id": item.get("id") if isinstance(item, dict) else None, "error": str(exc)}) facts.document_entity = aggregator facts.document_title = "OpenRouter models" return facts def _model(self, facts: Facts, item: dict[str, Any], aggregator: EntityRef) -> None: full_id = item.get("id") or "" if not full_id or full_id.startswith("~") or item.get("alias_target"): return vendor, _, rest = full_id.partition("/") if not rest: return base_slug, _, variant = rest.partition(":") variant = variant.lower() if variant else None base_id = f"{vendor}/{base_slug}" name = _display_name(item.get("name") or base_slug, vendor) if vendor == "openrouter": self._router_product(facts, item, aggregator, full_id, base_id, name, variant) return org, provider_key = self._vendor(facts, vendor, item.get("name") or "", base_slug) ids = {"openrouter": base_id} hf = item.get("hugging_face_id") if isinstance(hf, str) and re.fullmatch(r"[\w.-]+/[\w.-]+", hf): ids["hf_repo"] = hf ref = next((e for e in facts.entities if e.entity_type == "model" and e.identifiers.get("openrouter") == base_id), None) if ref is None: ident = model_identity(base_id) # untrusted: aliases only (OpenRouter slugs ≠ vendor API ids) aliases = [a for a in dict.fromkeys([item.get("name"), base_id, base_slug, *(ident.aliases if ident else [])]) if a and a != name] ref = facts.entity("model", name, identifiers=ids, organization=org, aliases=aliases, family=family_ref(name, org), identity_confidence="medium") if org: facts.relate(org, "develops", ref) facts.claim(ref, "openrouter_id", base_id) if "hf_repo" in ids: facts.claim(ref, "hf_repo", hf) if not variant: self._claims(facts, ref, item) elif not variant: self._claims(facts, ref, item) price = self._price(facts, ref, item, provider_key, vendor, aggregator, full_id, variant) if price is None and not variant: # still note the listing so `available_through` exists even without a usable price facts.claim(ref, "openrouter_listed", True) def _router_product(self, facts: Facts, item: dict[str, Any], aggregator: EntityRef, full_id: str, base_id: str, name: str, variant: str | None) -> None: """`openrouter/auto`, `openrouter/pareto-code`, `openrouter/free`… are OpenRouter's own routing products, not models: a `product` entity (kind router) operated by OpenRouter. Their price (when not dynamic `-1`) is booked against the product.""" ref = next((e for e in facts.entities if e.entity_type == "product" and e.identifiers.get("openrouter") == base_id), None) if ref is None: org = org_ref_in(facts, "openrouter") ref = facts.entity("product", name, identifiers={"openrouter": base_id}, organization=org, aliases=[a for a in {item.get("name"), base_id} if a and a != name], attributes={"kind": "router"}, identity_confidence="high") facts.claim(ref, "openrouter_id", base_id) facts.claim(ref, "description", (item.get("description") or "").strip()[:2000] or None) facts.claim(ref, "context_length", _int(item.get("context_length")), unit="tokens") facts.relate(aggregator, "operates", ref) self._price(facts, ref, item, "openrouter", "openrouter", aggregator, full_id, variant) def _claims(self, facts: Facts, ref: EntityRef, item: dict[str, Any]) -> None: arch = item.get("architecture") or {} top = item.get("top_provider") or {} facts.claim(ref, "description", (item.get("description") or "").strip()[:2000] or None) facts.claim(ref, "context_length", _int(item.get("context_length")), unit="tokens") facts.claim(ref, "max_output_tokens", _int(top.get("max_completion_tokens")), unit="tokens") raw_in = [m for m in arch.get("input_modalities") or [] if isinstance(m, str)] raw_out = [m for m in arch.get("output_modalities") or [] if isinstance(m, str)] mi, mo = normalize_modalities(raw_in), normalize_modalities(raw_out) # "file" → document (ontology) if "file" in raw_in: facts.claim(ref, "file_input", True) facts.claim(ref, "modalities_input", mi) facts.claim(ref, "modalities_output", mo) facts.claim(ref, "modalities", sorted(set(mi) | set(mo))) tok = arch.get("tokenizer") facts.claim(ref, "tokenizer", tok if isinstance(tok, str) and tok.lower() not in ("router", "other") else None) facts.claim(ref, "instruct_type", arch.get("instruct_type")) params = sorted({p for p in item.get("supported_parameters") or [] if isinstance(p, str)}) facts.claim(ref, "supported_parameters", params) if params: facts.claim(ref, "tool_calling", "tools" in params) facts.claim(ref, "structured_output", "structured_outputs" in params or "response_format" in params) reasoning = item.get("reasoning") if isinstance(reasoning, dict) or "reasoning" in params: facts.claim(ref, "reasoning", True) if "image" in mi: facts.claim(ref, "vision", True) created = item.get("created") if isinstance(created, (int, float)) and created > 1_000_000_000: # the catalogue's `created` is when OpenRouter listed the model — a listing date, never the model's release date facts.claim(ref, "openrouter_listed_at", datetime.fromtimestamp(created, tz=UTC).date().isoformat()) facts.claim(ref, "knowledge_cutoff", _month(item.get("knowledge_cutoff"))) facts.claim(ref, "openrouter_expiration_date", item.get("expiration_date") if isinstance(item.get("expiration_date"), str) else None) def _price(self, facts: Facts, ref: EntityRef, item: dict[str, Any], provider_key: str | None, vendor: str, aggregator: EntityRef, full_id: str, variant: str | None) -> Any: pricing = item.get("pricing") or {} prompt, completion = _per_mtok(pricing.get("prompt")), _per_mtok(pricing.get("completion")) if prompt is None and completion is None: return None if (prompt is not None and prompt < 0) or (completion is not None and completion < 0): return None top = item.get("top_provider") or {} features: dict[str, Any] = {"via": "openrouter", "upstream_vendor": vendor} if provider_key: features["upstream_provider"] = provider_key # registry provider key of the routed lab — the row still belongs to OpenRouter if variant: features["variant"] = variant for key in ("web_search", "internal_reasoning", "audio", "audio_output", "image_output", "input_cache_write_1h"): v = pricing.get(key) if isinstance(v, str) and v not in ("0", "-1", ""): features[key] = v if pricing.get("overrides"): features["tiered_pricing"] = pricing["overrides"] per_image = _float(pricing.get("image")) per_request = _float(pricing.get("request")) return facts.price(model=ref, provider=aggregator, provider_model_id=full_id, input_per_mtok=prompt, output_per_mtok=completion, cached_input_per_mtok=_per_mtok(pricing.get("input_cache_read")), cache_write_per_mtok=_per_mtok(pricing.get("input_cache_write")), per_image=per_image if per_image else None, per_request=per_request if per_request else None, context_length=_int(top.get("context_length") or item.get("context_length")), max_output_tokens=_int(top.get("max_completion_tokens")), features=features, source_url=f"https://openrouter.ai/{full_id}", meta={"is_moderated": top.get("is_moderated")}) def _vendor(self, facts: Facts, vendor: str, name: str, base_slug: str) -> tuple[EntityRef | None, str | None]: provider_key = provider_by_openrouter(vendor) or VENDOR_PROVIDER.get(vendor) if provider_key and provider_key not in providers(): provider_key = None org_key = org_key_for_vendor(vendor) if not org_key and provider_key: org_key = providers()[provider_key].get("organization") if not org_key: ident = model_identity(base_slug) org_key = ident.org_key if ident else None org = org_ref_in(facts, org_key) if org is None: display = name.split(":", 1)[0].strip() if ":" in name else vendor org = next((e for e in facts.entities if e.identifiers.get("openrouter_vendor") == vendor), None) if org is None: org = EntityRef(entity_type="company", name=display or vendor, identifiers={"openrouter_vendor": vendor}, aliases=[vendor], identity_confidence="medium") facts.entities.append(org) return org, provider_key def _display_name(name: str, vendor: str) -> str: if ":" in name: prefix, rest = name.split(":", 1) if rest.strip() and (prefix.strip().lower().replace(" ", "-") in (vendor, vendor.replace("-", "")) or len(prefix) <= 24): return rest.strip() return name.strip() def _per_mtok(v: Any) -> float | None: f = _float(v) if f is None: return None if f < 0: return -1.0 return round(f * 1_000_000, 6) def _float(v: Any) -> float | None: try: return float(v) if v not in (None, "") else None except (TypeError, ValueError): return None def _int(v: Any) -> int | None: try: return int(v) if v is not None else None except (TypeError, ValueError): return None def _month(v: Any) -> str | None: if not isinstance(v, str): return None m = re.match(r"(\d{4})-(\d{2})", v) return f"{m.group(1)}-{m.group(2)}" if m else None CONNECTORS = [OpenRouterConnector]