1"""OpenRouter — public model catalogue `https://openrouter.ai/api/v1/models` (one JSON document, ≈ 440 entries). Tier 2: official2provider pages win conflicts.34Each entry: `id` = `vendor/slug[:variant]`, `name` ("Vendor: Model"), `description`, `context_length`, `architecture` (input/output5modalities, tokenizer), `pricing` (USD per token as strings → ×1e6 = USD per 1M tokens), `top_provider.max_completion_tokens`,6`supported_parameters`, `created` (epoch), `hugging_face_id`. Entries whose id starts with `~` are alias rows (`alias_target`) and are7skipped; `-1` prices (dynamic routing) are skipped.89Model identity: `{"openrouter": "<vendor/slug>"}` (variant suffix stripped) + `{"hf_repo": …}` when the catalogue gives it; aliases and the10developer organisation come from `connectors/_identity.py` (never a vendor `*_model_id` — OpenRouter slugs differ from the labs' API ids).11`created` is the OpenRouter *listing* date (`openrouter_listed_at`), never a release date.12Pricing: every price is booked on the **OpenRouter provider entity only** (`provider_ref("openrouter")`) with `features.upstream_provider` =13the registry provider key of the routed vendor when it is one — never on the lab's own provider entity, which would create a second14"current" price per model × provider. `provider_model_id` = the full OpenRouter id (variants `:free` / `:thinking` / `:nitro` are separate15price rows of the same model, `features.variant`).16"""17from __future__ import annotations1819import re20from datetime import UTC, datetime21from typing import Any2223from aiatlas.connectors._identity import family_ref, model_identity, org_key_for_vendor, org_ref_in24from aiatlas.ontology.taxonomy import normalize_modalities25from aiatlas.registry import provider_by_openrouter, provider_ref, providers26from aiatlas.sdk.connector import BaseConnector, Parsed, RunContext27from aiatlas.sdk.facts import EntityRef, Facts, Target28from aiatlas.sdk.fetch import FetchResult2930URL = "https://openrouter.ai/api/v1/models"31VENDOR_PROVIDER = {"x-ai": "xai", "mistralai": "mistral"}32VARIANTS = {"free", "extended", "nitro", "floor", "online", "thinking", "beta", "exacto", "fast"}333435class OpenRouterConnector(BaseConnector):36 name = "openrouter"37 label = "OpenRouter — model catalogue with routed pricing"38 description = "Public JSON catalogue: context, modalities, supported parameters and per-token prices of every model routed by OpenRouter."39 source_key = "openrouter.ai"40 version = "2"41 parser_version = "2"42 interval_seconds = 3 * 360043 min_interval_seconds = 360044 max_interval_seconds = 8640045 rate_per_min = 1046 tier = 247 priority = 148 expected_min_records = 15049 concurrency = 15051 async def discover(self, ctx: RunContext) -> list[Target]:52 return [Target(url=URL, doc_type="catalogue", key="models", min_bytes=50000, meta={"content_type": "application/json"}, priority=1)]5354 async def extract(self, ctx: RunContext, target: Target, res: FetchResult, parsed: Parsed) -> Facts:55 facts = Facts()56 data = parsed.json if parsed.kind == "json" else None57 items = data.get("data") if isinstance(data, dict) else None58 if not isinstance(items, list):59 return facts60 aggregator = provider_ref("openrouter")61 facts.entities.append(aggregator)62 for item in items:63 try:64 self._model(facts, item, aggregator)65 except Exception as exc: # noqa: BLE001 — one malformed row must not sink the catalogue66 ctx.log.warning("openrouter row skipped", extra={"id": item.get("id") if isinstance(item, dict) else None, "error": str(exc)})67 facts.document_entity = aggregator68 facts.document_title = "OpenRouter models"69 return facts7071 def _model(self, facts: Facts, item: dict[str, Any], aggregator: EntityRef) -> None:72 full_id = item.get("id") or ""73 if not full_id or full_id.startswith("~") or item.get("alias_target"):74 return75 vendor, _, rest = full_id.partition("/")76 if not rest:77 return78 base_slug, _, variant = rest.partition(":")79 variant = variant.lower() if variant else None80 base_id = f"{vendor}/{base_slug}"81 name = _display_name(item.get("name") or base_slug, vendor)82 org, provider_key = self._vendor(facts, vendor, item.get("name") or "", base_slug)83 ids = {"openrouter": base_id}84 hf = item.get("hugging_face_id")85 if isinstance(hf, str) and re.fullmatch(r"[\w.-]+/[\w.-]+", hf):86 ids["hf_repo"] = hf87 ref = next((e for e in facts.entities if e.entity_type == "model" and e.identifiers.get("openrouter") == base_id), None)88 if ref is None:89 ident = model_identity(base_id) # untrusted: aliases only (OpenRouter slugs ≠ vendor API ids)90 aliases = [a for a in dict.fromkeys([item.get("name"), base_id, base_slug, *(ident.aliases if ident else [])]) if a and a != name]91 ref = facts.entity("model", name, identifiers=ids, organization=org, aliases=aliases, family=family_ref(name, org), identity_confidence="medium")92 if org:93 facts.relate(org, "develops", ref)94 facts.claim(ref, "openrouter_id", base_id)95 if "hf_repo" in ids:96 facts.claim(ref, "hf_repo", hf)97 if not variant:98 self._claims(facts, ref, item)99 elif not variant:100 self._claims(facts, ref, item)101 price = self._price(facts, ref, item, provider_key, vendor, aggregator, full_id, variant)102 if price is None and not variant:103 # still note the listing so `available_through` exists even without a usable price104 facts.claim(ref, "openrouter_listed", True)105106 def _claims(self, facts: Facts, ref: EntityRef, item: dict[str, Any]) -> None:107 arch = item.get("architecture") or {}108 top = item.get("top_provider") or {}109 facts.claim(ref, "description", (item.get("description") or "").strip()[:2000] or None)110 facts.claim(ref, "context_length", _int(item.get("context_length")), unit="tokens")111 facts.claim(ref, "max_output_tokens", _int(top.get("max_completion_tokens")), unit="tokens")112 raw_in = [m for m in arch.get("input_modalities") or [] if isinstance(m, str)]113 raw_out = [m for m in arch.get("output_modalities") or [] if isinstance(m, str)]114 mi, mo = normalize_modalities(raw_in), normalize_modalities(raw_out) # "file" → document (ontology)115 if "file" in raw_in:116 facts.claim(ref, "file_input", True)117 facts.claim(ref, "modalities_input", mi)118 facts.claim(ref, "modalities_output", mo)119 facts.claim(ref, "modalities", sorted(set(mi) | set(mo)))120 tok = arch.get("tokenizer")121 facts.claim(ref, "tokenizer", tok if isinstance(tok, str) and tok.lower() not in ("router", "other") else None)122 facts.claim(ref, "instruct_type", arch.get("instruct_type"))123 params = sorted({p for p in item.get("supported_parameters") or [] if isinstance(p, str)})124 facts.claim(ref, "supported_parameters", params)125 if params:126 facts.claim(ref, "tool_calling", "tools" in params)127 facts.claim(ref, "structured_output", "structured_outputs" in params or "response_format" in params)128 reasoning = item.get("reasoning")129 if isinstance(reasoning, dict) or "reasoning" in params:130 facts.claim(ref, "reasoning", True)131 if "image" in mi:132 facts.claim(ref, "vision", True)133 created = item.get("created")134 if isinstance(created, (int, float)) and created > 1_000_000_000:135 # the catalogue's `created` is when OpenRouter listed the model — a listing date, never the model's release date136 facts.claim(ref, "openrouter_listed_at", datetime.fromtimestamp(created, tz=UTC).date().isoformat())137 facts.claim(ref, "knowledge_cutoff", _month(item.get("knowledge_cutoff")))138 facts.claim(ref, "openrouter_expiration_date", item.get("expiration_date") if isinstance(item.get("expiration_date"), str) else None)139140 def _price(self, facts: Facts, ref: EntityRef, item: dict[str, Any], provider_key: str | None, vendor: str, aggregator: EntityRef, full_id: str,141 variant: str | None) -> Any:142 pricing = item.get("pricing") or {}143 prompt, completion = _per_mtok(pricing.get("prompt")), _per_mtok(pricing.get("completion"))144 if prompt is None and completion is None:145 return None146 if (prompt is not None and prompt < 0) or (completion is not None and completion < 0):147 return None148 top = item.get("top_provider") or {}149 features: dict[str, Any] = {"via": "openrouter", "upstream_vendor": vendor}150 if provider_key:151 features["upstream_provider"] = provider_key # registry provider key of the routed lab — the row still belongs to OpenRouter152 if variant:153 features["variant"] = variant154 for key in ("web_search", "internal_reasoning", "audio", "audio_output", "image_output", "input_cache_write_1h"):155 v = pricing.get(key)156 if isinstance(v, str) and v not in ("0", "-1", ""):157 features[key] = v158 if pricing.get("overrides"):159 features["tiered_pricing"] = pricing["overrides"]160 per_image = _float(pricing.get("image"))161 per_request = _float(pricing.get("request"))162 return facts.price(model=ref, provider=aggregator, provider_model_id=full_id, input_per_mtok=prompt, output_per_mtok=completion,163 cached_input_per_mtok=_per_mtok(pricing.get("input_cache_read")), cache_write_per_mtok=_per_mtok(pricing.get("input_cache_write")),164 per_image=per_image if per_image else None, per_request=per_request if per_request else None,165 context_length=_int(top.get("context_length") or item.get("context_length")), max_output_tokens=_int(top.get("max_completion_tokens")),166 features=features, source_url=f"https://openrouter.ai/{full_id}", meta={"is_moderated": top.get("is_moderated")})167168 def _vendor(self, facts: Facts, vendor: str, name: str, base_slug: str) -> tuple[EntityRef | None, str | None]:169 provider_key = provider_by_openrouter(vendor) or VENDOR_PROVIDER.get(vendor)170 if provider_key and provider_key not in providers():171 provider_key = None172 org_key = org_key_for_vendor(vendor)173 if not org_key and provider_key:174 org_key = providers()[provider_key].get("organization")175 if not org_key:176 ident = model_identity(base_slug)177 org_key = ident.org_key if ident else None178 org = org_ref_in(facts, org_key)179 if org is None:180 display = name.split(":", 1)[0].strip() if ":" in name else vendor181 org = next((e for e in facts.entities if e.identifiers.get("openrouter_vendor") == vendor), None)182 if org is None:183 org = EntityRef(entity_type="company", name=display or vendor, identifiers={"openrouter_vendor": vendor}, aliases=[vendor], identity_confidence="medium")184 facts.entities.append(org)185 return org, provider_key186187188def _display_name(name: str, vendor: str) -> str:189 if ":" in name:190 prefix, rest = name.split(":", 1)191 if rest.strip() and (prefix.strip().lower().replace(" ", "-") in (vendor, vendor.replace("-", "")) or len(prefix) <= 24):192 return rest.strip()193 return name.strip()194195196def _per_mtok(v: Any) -> float | None:197 f = _float(v)198 if f is None:199 return None200 if f < 0:201 return -1.0202 return round(f * 1_000_000, 6)203204205def _float(v: Any) -> float | None:206 try:207 return float(v) if v not in (None, "") else None208 except (TypeError, ValueError):209 return None210211212def _int(v: Any) -> int | None:213 try:214 return int(v) if v is not None else None215 except (TypeError, ValueError):216 return None217218219def _month(v: Any) -> str | None:220 if not isinstance(v, str):221 return None222 m = re.match(r"(\d{4})-(\d{2})", v)223 return f"{m.group(1)}-{m.group(2)}" if m else None224225226CONNECTORS = [OpenRouterConnector]227