OpenRouter routers are products, not models; official-org quantisations are artifacts with a same-org canonical (None when unsure)
`openrouter/*` ids (auto, auto-beta, pareto-code, free, fusion…) → `product` entities (kind router) operated by OpenRouter, price booked against the product when not dynamic. Hugging Face: a quant/precision token in the repo name makes the repo an artifact even under the developer's own org (nvidia/Gemma-4-31B-IT-NVFP4, tencent/HY-MT1.5-1.8B-FP8, LiquidAI/LFM2.5-230M-GGUF, black-forest-labs/FLUX.2-klein-4b-fp8); canonical = the model built from the stripped name under the family's developer (medium confidence), or None when nothing can be stripped or the developer is unknown — never an invented model. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
4 changed files +62 −3
modified
src/aiatlas/connectors/hub/huggingface.py
+9 −3
@@ -393,13 +393,17 @@ class HuggingFaceConnector(BaseConnector): | ||
| 393 | 393 | facts.claim(ref, "hf_repo", base_id) |
| 394 | 394 | return ref |
| 395 | 395 | |
| 396 | − def _canonical_ref(self, facts: Facts, repo_id: str, analysis: NameAnalysis, bases: list[tuple[str, str | None]]) -> EntityRef: | |
| 397 | − """The model an artifact packages: the `base_model` repo when present, otherwise the analysed base name (medium confidence).""" | |
| 396 | + def _canonical_ref(self, facts: Facts, repo_id: str, analysis: NameAnalysis, bases: list[tuple[str, str | None]]) -> EntityRef | None: | |
| 397 | + """The model an artifact packages: the `base_model` repo when present, otherwise the analysed base name (medium confidence) — | |
| 398 | + `nvidia/Gemma-4-31B-IT-NVFP4` → same-org model "Gemma-4-31B-IT". None when the base cannot be named confidently (no token to strip | |
| 399 | + from the name, or a redistributor repo whose family is unknown): never an invented model.""" | |
| 398 | 400 | base = next((b for b, k in bases if k == "quantized" and "/" in b), None) or next((b for b, _ in bases if "/" in b), None) |
| 399 | 401 | if base and base.lower() != repo_id.lower(): |
| 400 | 402 | return self._base_ref(facts, base) |
| 401 | 403 | org_slug, _, repo_name = repo_id.partition("/") |
| 402 | 404 | name = canonical_name(repo_name) |
| 405 | + if not name or name.lower() == repo_name.lower() or not re.search(r"[a-z]", name, re.IGNORECASE): | |
| 406 | + return None | |
| 403 | 407 | org: EntityRef | None = None |
| 404 | 408 | family_orgs = _family_orgs(repo_name) |
| 405 | 409 | if family_orgs and org_slug.lower() in family_orgs: |
@@ -408,7 +412,9 @@ class HuggingFaceConnector(BaseConnector): | ||
| 408 | 412 | hf_org = next((o for o in sorted(family_orgs) if org_by_hf(o)), None) |
| 409 | 413 | org = self._org_ref(facts, org_by_hf(hf_org)["hf_org"], None) if hf_org else None # type: ignore[index] |
| 410 | 414 | elif org_slug.lower() not in REDISTRIBUTORS: |
| 411 | − org = self._org_ref(facts, org_slug, None) | |
| 415 | + org = self._org_ref(facts, org_slug, None) # the official developer's own quantisation / conversion → its own model | |
| 416 | + if org is None: | |
| 417 | + return None | |
| 412 | 418 | for e in facts.entities: |
| 413 | 419 | if e.entity_type == "model" and e.name.lower() == name.lower() and (org is None or e.organization is None or e.organization.identifiers == org.identifiers): |
| 414 | 420 | return e |
modified
src/aiatlas/connectors/providers/openrouter.py
+17 −0
@@ -79,6 +79,9 @@ class OpenRouterConnector(BaseConnector): | ||
| 79 | 79 | variant = variant.lower() if variant else None |
| 80 | 80 | base_id = f"{vendor}/{base_slug}" |
| 81 | 81 | name = _display_name(item.get("name") or base_slug, vendor) |
| 82 | + if vendor == "openrouter": | |
| 83 | + self._router_product(facts, item, aggregator, full_id, base_id, name, variant) | |
| 84 | + return | |
| 82 | 85 | org, provider_key = self._vendor(facts, vendor, item.get("name") or "", base_slug) |
| 83 | 86 | ids = {"openrouter": base_id} |
| 84 | 87 | hf = item.get("hugging_face_id") |
@@ -103,6 +106,20 @@ class OpenRouterConnector(BaseConnector): | ||
| 103 | 106 | # still note the listing so `available_through` exists even without a usable price |
| 104 | 107 | facts.claim(ref, "openrouter_listed", True) |
| 105 | 108 | |
| 109 | + def _router_product(self, facts: Facts, item: dict[str, Any], aggregator: EntityRef, full_id: str, base_id: str, name: str, variant: str | None) -> None: | |
| 110 | + """`openrouter/auto`, `openrouter/pareto-code`, `openrouter/free`… are OpenRouter's own routing products, not models: a `product` | |
| 111 | + entity (kind router) operated by OpenRouter. Their price (when not dynamic `-1`) is booked against the product.""" | |
| 112 | + ref = next((e for e in facts.entities if e.entity_type == "product" and e.identifiers.get("openrouter") == base_id), None) | |
| 113 | + if ref is None: | |
| 114 | + org = org_ref_in(facts, "openrouter") | |
| 115 | + 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], | |
| 116 | + attributes={"kind": "router"}, identity_confidence="high") | |
| 117 | + facts.claim(ref, "openrouter_id", base_id) | |
| 118 | + facts.claim(ref, "description", (item.get("description") or "").strip()[:2000] or None) | |
| 119 | + facts.claim(ref, "context_length", _int(item.get("context_length")), unit="tokens") | |
| 120 | + facts.relate(aggregator, "operates", ref) | |
| 121 | + self._price(facts, ref, item, "openrouter", "openrouter", aggregator, full_id, variant) | |
| 122 | + | |
| 106 | 123 | def _claims(self, facts: Facts, ref: EntityRef, item: dict[str, Any]) -> None: |
| 107 | 124 | arch = item.get("architecture") or {} |
| 108 | 125 | top = item.get("top_provider") or {} |
modified
tests/test_huggingface.py
+26 −0
@@ -114,6 +114,32 @@ def test_identity_helpers(): | ||
| 114 | 114 | assert pipeline_modalities("text-to-image") == (["text"], ["image"]) and pipeline_modalities("unknown-tag") == ([], []) |
| 115 | 115 | |
| 116 | 116 | |
| 117 | +def test_official_org_quantisations_are_artifacts(connector): | |
| 118 | + """A quant/precision token makes the repo an artifact even under the developer's own org; canonical = the same-org model from the stripped | |
| 119 | + name; None when the base cannot be named confidently.""" | |
| 120 | + from aiatlas.sdk.facts import Facts | |
| 121 | + | |
| 122 | + cases = {"nvidia/Gemma-4-31B-IT-NVFP4": ("quantization", "Gemma-4-31B-IT", "Google"), # Gemma is Google's family: NVIDIA quantises it | |
| 123 | + "tencent/HY-MT1.5-1.8B-FP8": ("quantization", "HY-MT1.5-1.8B", "Tencent"), | |
| 124 | + "LiquidAI/LFM2.5-230M-GGUF": ("quantization", "LFM2.5-230M", "Liquid AI"), "black-forest-labs/FLUX.2-klein-4b-fp8": ("quantization", "FLUX.2-klein-4b", "Black Forest Labs"), | |
| 125 | + "Qwen/Qwen3-8B-MLX-bf16": ("conversion", "Qwen3-8B", "Qwen")} | |
| 126 | + for repo, (kind, base, org_name) in cases.items(): | |
| 127 | + facts = Facts() | |
| 128 | + org = connector._org_ref(facts, repo.split("/")[0], None) | |
| 129 | + ref = connector._model_ref(facts, repo, org) | |
| 130 | + assert ref.entity_type == "artifact" and ref.artifact_kind == kind and ref.name == repo, repo | |
| 131 | + assert ref.canonical is not None and ref.canonical.entity_type == "model" and ref.canonical.name == base, repo | |
| 132 | + assert ref.canonical.organization.name == org_name and ref.canonical.identity_confidence == "medium", repo | |
| 133 | + assert not any(r.predicate == "develops" and r.object is ref for r in facts.relations) | |
| 134 | + # unsure → no invented model | |
| 135 | + facts = Facts() | |
| 136 | + ref = connector._model_ref(facts, "mradermacher/FooBar-GGUF", connector._org_ref(facts, "mradermacher", None)) | |
| 137 | + assert ref.entity_type == "artifact" and ref.canonical is None # redistributor, unknown family | |
| 138 | + facts = Facts() | |
| 139 | + ref = connector._model_ref(facts, "Qwen/Qwen3-8B", connector._org_ref(facts, "Qwen", None), quant_format="gguf") | |
| 140 | + assert ref.entity_type == "artifact" and ref.canonical is None # quantised per tags only: nothing to strip from the name | |
| 141 | + | |
| 142 | + | |
| 117 | 143 | async def test_daily_papers(connector): |
| 118 | 144 | facts = await extract_from_fixture(connector, Target(url="https://huggingface.co/papers", doc_type="listing", key="papers"), fixture_path("huggingface", "papers.html")) |
| 119 | 145 | papers = [e for e in facts.entities if e.entity_type == "paper"] |
modified
tests/test_openrouter.py
+10 −0
@@ -35,6 +35,16 @@ async def test_catalogue(): | ||
| 35 | 35 | assert not any(p.input_per_mtok is not None and p.input_per_mtok < 0 for p in facts.prices) # dynamic "-1" prices skipped |
| 36 | 36 | assert any("hf_repo" in e.identifiers for e in models) |
| 37 | 37 | assert not any(c.property == "release_date" for c in facts.claims) |
| 38 | + # OpenRouter's own routers are products, never models | |
| 39 | + assert not any(e.identifiers.get("openrouter", "").startswith("openrouter/") for e in models) | |
| 40 | + routers = [e for e in facts.entities if e.entity_type == "product"] | |
| 41 | + assert {e.identifiers["openrouter"] for e in routers} >= {"openrouter/auto", "openrouter/pareto-code", "openrouter/free"} | |
| 42 | + auto = next(e for e in routers if e.identifiers["openrouter"] == "openrouter/auto") | |
| 43 | + assert auto.name == "Auto Router" and auto.attributes == {"kind": "router"} and auto.organization.name == "OpenRouter" | |
| 44 | + assert any(r.predicate == "operates" and r.object is auto for r in facts.relations) | |
| 45 | + assert not any(p.provider_model_id == "openrouter/auto" for p in facts.prices) # dynamic (-1) price skipped | |
| 46 | + free = next(p for p in facts.prices if p.provider_model_id == "openrouter/free") | |
| 47 | + assert free.model.entity_type == "product" and free.input_per_mtok == 0.0 | |
| 38 | 48 | |
| 39 | 49 | |
| 40 | 50 | def test_helpers(): |
| 41 | 51 | |