diff --git a/src/model_capability_readers/__init__.py b/src/model_capability_readers/__init__.py index 15b208163..df5c28581 100644 --- a/src/model_capability_readers/__init__.py +++ b/src/model_capability_readers/__init__.py @@ -160,7 +160,11 @@ def records_from_payload( normalized = tuple(normalized_records) else: normalized_records: list[ModelCapabilityRecord] = [] - for item in shape.items(payload): + catalog_items = shape.items(payload) + select_catalog_items = getattr(reader, "select_catalog_items", None) + if callable(select_catalog_items): + catalog_items = tuple(select_catalog_items(catalog_items)) + for item in catalog_items: item_payload = shape.payload_for_item(payload, item) if shape.item_matches(item): if reader is generic_openai: diff --git a/src/model_capability_readers/base.py b/src/model_capability_readers/base.py index f184ab413..e07b10a55 100644 --- a/src/model_capability_readers/base.py +++ b/src/model_capability_readers/base.py @@ -9,6 +9,7 @@ labels. from __future__ import annotations import hashlib +import math from collections.abc import Iterable, Mapping from dataclasses import dataclass, field from typing import Any, Protocol @@ -139,6 +140,12 @@ def compact_str(value: Any) -> str: return str(value or "").strip() +def identity_str(value: Any) -> str: + """Return a provider identity only when the payload supplied a string.""" + + return value.strip() if isinstance(value, str) else "" + + def _identity_part(value: Any) -> str: text = compact_str(value).lower() out = [] @@ -171,10 +178,7 @@ def stable_model_id_for(vendor: Any, model_id: Any, *, endpoint_id: Any = "", ba def model_id_from(raw: Mapping[str, Any], *keys: str) -> str: for key in keys: - raw_value = raw.get(key) - if not isinstance(raw_value, str): - continue - value = raw_value.strip() + value = identity_str(raw.get(key)) if value: return value.removeprefix("models/") return "" @@ -183,6 +187,10 @@ def model_id_from(raw: Mapping[str, Any], *keys: str) -> str: def int_limit(value: Any) -> int | None: if isinstance(value, bool): return None + if isinstance(value, float) and ( + not math.isfinite(value) or not value.is_integer() + ): + return None try: limit = int(value) except (OverflowError, TypeError, ValueError): diff --git a/src/model_capability_readers/chatgpt_subscription.py b/src/model_capability_readers/chatgpt_subscription.py index a1ddc4399..2b2257049 100644 --- a/src/model_capability_readers/chatgpt_subscription.py +++ b/src/model_capability_readers/chatgpt_subscription.py @@ -2,6 +2,7 @@ from __future__ import annotations +import math from collections.abc import Mapping from typing import Any @@ -12,11 +13,59 @@ from src.model_capability_readers.base import ( as_list, as_mapping, compact_str, + identity_str, stable_model_id_for, ) vendor = VENDOR_CHATGPT_SUBSCRIPTION +_DEFAULT_PRIORITY = 10_000 + + +def _priority_rank(raw: Mapping[str, Any]) -> int | float: + value = raw.get("priority") + if isinstance(value, bool): + return _DEFAULT_PRIORITY + if isinstance(value, int): + return value + if isinstance(value, float) and math.isfinite(value): + return value + return _DEFAULT_PRIORITY + + +def _is_hidden(raw: Mapping[str, Any]) -> bool: + visibility = raw.get("visibility") + return ( + isinstance(visibility, str) + and visibility.strip().lower() in {"hide", "hidden"} + ) + + +def select_catalog_items( + items: tuple[Mapping[str, Any], ...], +) -> tuple[Mapping[str, Any], ...]: + """Apply the provider's visibility, priority, and slug de-duplication.""" + + sortable: list[tuple[int | float, str, int, Mapping[str, Any]]] = [] + passthrough: list[Mapping[str, Any]] = [] + for position, item in enumerate(items): + if _is_hidden(item): + continue + slug = identity_str(item.get("slug")) + if not slug: + passthrough.append(item) + continue + sortable.append((_priority_rank(item), slug, position, item)) + sortable.sort(key=lambda entry: (entry[0], entry[1], entry[2])) + + selected: list[Mapping[str, Any]] = [] + seen: set[str] = set() + for _, slug, _, item in sortable: + if slug not in seen: + selected.append(item) + seen.add(slug) + selected.extend(passthrough) + return tuple(selected) def record_from_model( @@ -25,7 +74,7 @@ def record_from_model( endpoint_id: Any = "", base_url: Any = "", ) -> ModelCapabilityRecord | None: - model_id = compact_str(raw.get("slug")) + model_id = identity_str(raw.get("slug")) if not model_id: return None return ModelCapabilityRecord( @@ -55,7 +104,8 @@ def records_from_payload( values = as_mapping(payload).get("models") return tuple( record - for item in as_list(values) - if isinstance(item, Mapping) + for item in select_catalog_items( + tuple(item for item in as_list(values) if isinstance(item, Mapping)) + ) if (record := record_from_model(item, endpoint_id=endpoint_id, base_url=base_url)) ) diff --git a/src/model_capability_readers/cohere.py b/src/model_capability_readers/cohere.py index e4108fc04..e83b264c7 100644 --- a/src/model_capability_readers/cohere.py +++ b/src/model_capability_readers/cohere.py @@ -19,6 +19,7 @@ from src.model_capability_readers.base import ( build_capability, compact_str, deterministic_controls_from_supported_parameters, + identity_str, int_limit, stable_model_id_for, ) @@ -60,7 +61,7 @@ def record_from_model( endpoint_id: Any = "", base_url: Any = "", ) -> ModelCapabilityRecord | None: - model_id = compact_str(raw.get("name")) + model_id = identity_str(raw.get("name")) if not model_id: return None family = _family(raw) diff --git a/src/model_capability_readers/copilot.py b/src/model_capability_readers/copilot.py index 642cc87ed..6b64b6367 100644 --- a/src/model_capability_readers/copilot.py +++ b/src/model_capability_readers/copilot.py @@ -28,6 +28,18 @@ _SUPPORT_CAPABILITIES = { } +def select_catalog_items( + items: tuple[Mapping[str, Any], ...], +) -> tuple[Mapping[str, Any], ...]: + """Keep picker-enabled models when the catalog advertises any of them.""" + + if any(item.get("model_picker_enabled") is True for item in items): + return tuple( + item for item in items if item.get("model_picker_enabled") is True + ) + return items + + def _supports(raw: Mapping[str, Any]) -> Mapping[str, Any]: return as_mapping(as_mapping(raw.get("capabilities")).get("supports")) @@ -102,7 +114,7 @@ def records_from_payload( base_url: Any = "", ) -> tuple[ModelCapabilityRecord, ...]: records: list[ModelCapabilityRecord] = [] - for item in openai_model_items(payload): + for item in select_catalog_items(openai_model_items(payload)): record = record_from_model(item, endpoint_id=endpoint_id, base_url=base_url) if record: records.append(record) diff --git a/src/model_capability_readers/google_ai_studio_mapping.py b/src/model_capability_readers/google_ai_studio_mapping.py index a6f5dec19..ed06016be 100644 --- a/src/model_capability_readers/google_ai_studio_mapping.py +++ b/src/model_capability_readers/google_ai_studio_mapping.py @@ -13,7 +13,12 @@ from collections.abc import Mapping from typing import Any from src import model_capabilities as mc -from src.model_capability_readers.base import as_list, compact_str, int_limit +from src.model_capability_readers.base import ( + as_list, + compact_str, + identity_str, + int_limit, +) METHOD_GENERATE_CONTENT = "generateContent" @@ -55,7 +60,7 @@ MODEL_FIELD_MAP = { def google_model_id(raw: Mapping[str, Any]) -> str: - value = compact_str(raw.get("baseModelId")) or compact_str(raw.get("name")) + value = identity_str(raw.get("baseModelId")) or identity_str(raw.get("name")) return value.removeprefix("models/") diff --git a/src/model_capability_readers/huggingface.py b/src/model_capability_readers/huggingface.py index c260513ea..f6f5ec6fc 100644 --- a/src/model_capability_readers/huggingface.py +++ b/src/model_capability_readers/huggingface.py @@ -11,7 +11,7 @@ from src.model_capability_readers.base import ( VENDOR_HUGGINGFACE, build_capability, compact_str, - openai_model_items, + model_id_from, stable_model_id_for, ) @@ -105,7 +105,7 @@ def record_from_model( endpoint_id: Any = "", base_url: Any = "", ) -> ModelCapabilityRecord | None: - model_id = compact_str(raw.get("modelId") or raw.get("id")) + model_id = model_id_from(raw, "modelId", "id") if not model_id: return None return ModelCapabilityRecord( @@ -136,11 +136,15 @@ def records_from_payload( endpoint_id: Any = "", base_url: Any = "", ) -> tuple[ModelCapabilityRecord, ...]: - if isinstance(payload, Mapping) and (payload.get("modelId") or payload.get("pipeline_tag")): + if isinstance(payload, Mapping) and "pipeline_tag" in payload: record = record_from_model(payload, endpoint_id=endpoint_id, base_url=base_url) return (record,) if record else () + if not isinstance(payload, (list, tuple)): + return () records: list[ModelCapabilityRecord] = [] - for item in openai_model_items(payload): + for item in payload: + if not isinstance(item, Mapping): + continue record = record_from_model(item, endpoint_id=endpoint_id, base_url=base_url) if record: records.append(record) diff --git a/src/model_capability_readers/llamacpp.py b/src/model_capability_readers/llamacpp.py index 9c3beb5c0..c5a233b79 100644 --- a/src/model_capability_readers/llamacpp.py +++ b/src/model_capability_readers/llamacpp.py @@ -22,6 +22,7 @@ from src.model_capability_readers.base import ( build_capability, compact_str, deterministic_controls_from_supported_parameters, + identity_str, int_limit, merge_unique, model_id_from, @@ -53,10 +54,10 @@ def _server_model_entries(payload: Mapping[str, Any]) -> tuple[Mapping[str, Any] def _model_id_from_props(payload: Mapping[str, Any]) -> str: payload = as_mapping(payload) - model_alias = compact_str(payload.get("model_alias")) + model_alias = identity_str(payload.get("model_alias")) if model_alias: return model_alias - model_path = compact_str(payload.get("model_path")) + model_path = identity_str(payload.get("model_path")) if model_path: return PurePosixPath(model_path).name return "" diff --git a/src/model_capability_readers/ollama.py b/src/model_capability_readers/ollama.py index e9a1fdfeb..2333b3574 100644 --- a/src/model_capability_readers/ollama.py +++ b/src/model_capability_readers/ollama.py @@ -13,6 +13,7 @@ from src.model_capability_readers.base import ( as_mapping, build_capability, compact_str, + identity_str, int_limit, merge_unique, model_id_from, @@ -116,7 +117,7 @@ def record_from_show_payload( endpoint_id: Any = "", base_url: Any = "", ) -> ModelCapabilityRecord | None: - model_id = compact_str(model_id) or model_id_from(payload, "model", "name") + model_id = identity_str(model_id) or model_id_from(payload, "model", "name") if not model_id: return None capability_values = payload.get("capabilities") diff --git a/src/model_capability_readers/sglang.py b/src/model_capability_readers/sglang.py index be1ce69af..713e3e10b 100644 --- a/src/model_capability_readers/sglang.py +++ b/src/model_capability_readers/sglang.py @@ -14,8 +14,8 @@ from src.model_capability_readers.base import ( VENDOR_SGLANG, as_mapping, build_capability, - compact_str, deterministic_controls_from_supported_parameters, + identity_str, int_limit, openai_model_items, stable_model_id_for, @@ -26,7 +26,9 @@ vendor = VENDOR_SGLANG def _model_id(payload: Mapping[str, Any]) -> str: - value = compact_str(payload.get("served_model_name") or payload.get("model_path")) + value = identity_str(payload.get("served_model_name")) or identity_str( + payload.get("model_path") + ) if not value: return "" return PurePosixPath(value).name if value.startswith("/") else value diff --git a/src/provider_capability_schemas.py b/src/provider_capability_schemas.py index 8250ea4f6..71b86162c 100644 --- a/src/provider_capability_schemas.py +++ b/src/provider_capability_schemas.py @@ -229,6 +229,15 @@ GOOGLE_MODELS_SHAPE = ProviderCatalogShape( item_types=(("supportedGenerationMethods", (list, tuple)),), detection_priority=100, ) +GOOGLE_MODEL_SHAPE = ProviderCatalogShape( + shape_id="google.generative-language.model.v1beta", + provider_id="google", + envelope=ENVELOPE_SINGLE, + identity_paths=("baseModelId", "name"), + required_item_paths=("supportedGenerationMethods",), + item_types=(("supportedGenerationMethods", (list, tuple)),), + detection_priority=100, +) OLLAMA_TAGS_SHAPE = ProviderCatalogShape( shape_id="ollama.tags.v1", provider_id="ollama", @@ -290,6 +299,17 @@ LLAMACPP_PROPS_SHAPE = ProviderCatalogShape( item_types=(("default_generation_settings", (Mapping,)),), detection_priority=100, ) +LLAMACPP_MODELS_SHAPE = ProviderCatalogShape( + shape_id="llamacpp.models.native.v1", + provider_id="llamacpp", + envelope=ENVELOPE_MODELS, + identity_paths=("id", "name", "model"), + required_item_paths=("capabilities",), + item_types=(("capabilities", (list, tuple)),), + # Model/capability fields are not globally provider-specific. Interpret + # them only after explicit llama.cpp endpoint/provider selection. + detection_priority=0, +) MISTRAL_MODELS_SHAPE = ProviderCatalogShape( shape_id="mistral.models.rich.v1", provider_id="mistral", @@ -325,7 +345,9 @@ ANTHROPIC_MODELS_SHAPE = ProviderCatalogShape( identity_paths=("id",), required_item_paths=("type", "display_name", "created_at"), item_values=(("type", ("model",)),), - detection_priority=70, + # These model-resource fields are not globally provider-specific. Require + # explicit Anthropic endpoint/provider context before assigning identity. + detection_priority=0, ) CHATGPT_MODELS_SHAPE = ProviderCatalogShape( shape_id="chatgpt-subscription.codex-models.v1", @@ -376,6 +398,15 @@ HUGGINGFACE_MODEL_SHAPE = ProviderCatalogShape( item_types=(("pipeline_tag", (str,)),), detection_priority=0, ) +HUGGINGFACE_MODELS_LIST_SHAPE = ProviderCatalogShape( + shape_id="huggingface.hub.model-info-list.v1", + provider_id="huggingface", + envelope=ENVELOPE_BARE_LIST, + identity_paths=("modelId", "id"), + required_item_paths=("pipeline_tag",), + item_types=(("pipeline_tag", (str,)),), + detection_priority=0, +) COHERE_MODELS_SHAPE = ProviderCatalogShape( shape_id="cohere.models.rich.v1", provider_id="cohere", @@ -432,7 +463,7 @@ PROVIDER_SCHEMAS = { "google", aliases=("gemini", "google_ai_studio"), hosts=("generativelanguage.googleapis.com",), - shapes=(GOOGLE_MODELS_SHAPE,), + shapes=(GOOGLE_MODELS_SHAPE, GOOGLE_MODEL_SHAPE), ), "anthropic": _provider( "anthropic", @@ -452,7 +483,7 @@ PROVIDER_SCHEMAS = { "llamacpp": _provider( "llamacpp", aliases=("llama.cpp", "llama_cpp", "llama_server"), - shapes=(LLAMACPP_PROPS_SHAPE,), + shapes=(LLAMACPP_PROPS_SHAPE, LLAMACPP_MODELS_SHAPE), ), "mistral": _provider( "mistral", @@ -480,7 +511,7 @@ PROVIDER_SCHEMAS = { "huggingface", aliases=("hf", "hugging_face"), hosts=("huggingface.co",), - shapes=(HUGGINGFACE_MODEL_SHAPE,), + shapes=(HUGGINGFACE_MODEL_SHAPE, HUGGINGFACE_MODELS_LIST_SHAPE), ), "cohere": _provider( "cohere", diff --git a/tests/test_provider_capability_schemas.py b/tests/test_provider_capability_schemas.py index 030c0bc4a..f83c104cd 100644 --- a/tests/test_provider_capability_schemas.py +++ b/tests/test_provider_capability_schemas.py @@ -241,6 +241,7 @@ def test_native_catalog_shapes_resolve_with_required_provider_context(): ) explicit_context_providers = { + "anthropic", "chatgpt_subscription", "cohere", "huggingface", @@ -608,6 +609,139 @@ def test_structured_identity_values_are_not_stringified_into_fallback_records(): assert records_from_payload(payload, vendor="future-provider") == () +def test_native_readers_skip_structured_identity_candidates(): + google_record = records_from_payload( + { + "models": [ + { + "baseModelId": {"nested": "bad"}, + "name": "models/good-google-id", + "supportedGenerationMethods": ["embedContent"], + } + ] + } + )[0] + huggingface_record = records_from_payload( + { + "modelId": {"nested": "bad"}, + "id": "org/good-hf-id", + "pipeline_tag": "text-to-image", + }, + vendor="huggingface", + )[0] + llamacpp_record = records_from_payload( + { + "model_alias": {"nested": "bad"}, + "model_path": "/models/good-llama.gguf", + "default_generation_settings": {}, + "chat_template_caps": {"supports_vision": True}, + }, + vendor="llamacpp", + )[0] + sglang_record = records_from_payload( + { + "served_model_name": {"nested": "bad"}, + "model_path": "/models/good-sglang", + "is_generation": True, + "has_image_understanding": True, + }, + vendor="sglang", + )[0] + + assert google_record.model_id == "good-google-id" + assert huggingface_record.model_id == "org/good-hf-id" + assert llamacpp_record.model_id == "good-llama.gguf" + assert sglang_record.model_id == "good-sglang" + assert chatgpt_subscription.record_from_model({"slug": {"nested": "bad"}}) is None + assert cohere.record_from_model( + {"name": {"nested": "bad"}, "endpoints": ["chat"]} + ) is None + + +def test_native_singleton_and_bare_list_shapes_reach_their_readers(): + google_records = records_from_payload( + { + "name": "models/gemini-embed", + "supportedGenerationMethods": ["embedContent"], + }, + vendor="google", + ) + huggingface_records = records_from_payload( + [{"modelId": "org/model", "pipeline_tag": "text-generation"}], + vendor="huggingface", + ) + llamacpp_records = records_from_payload( + { + "models": [ + { + "id": "served-model", + "capabilities": ["chat", "tools"], + } + ] + }, + vendor="llamacpp", + ) + + assert google_records[0].model_id == "gemini-embed" + assert google_records[0].capability.family == mc.FAMILY_EMBEDDING + assert google_records[0].catalog_shape_id == ( + "google.generative-language.model.v1beta" + ) + assert huggingface_records[0].model_id == "org/model" + assert huggingface_records[0].capability.family == mc.FAMILY_CHAT + assert huggingface_records[0].catalog_shape_id == ( + "huggingface.hub.model-info-list.v1" + ) + assert llamacpp_records[0].model_id == "served-model" + assert llamacpp_records[0].capability.family == mc.FAMILY_CHAT + assert llamacpp_records[0].capability.capabilities == (mc.CAP_TOOL_CALL,) + assert llamacpp_records[0].catalog_shape_id == "llamacpp.models.native.v1" + + +def test_huggingface_openai_serving_envelope_stays_identity_only(): + payload = { + "data": [ + { + "id": "served-model", + "pipeline_tag": "text-to-image", + } + ] + } + + direct = huggingface.records_from_payload(payload) + wrapped = records_from_payload(payload, vendor="huggingface") + + assert direct == () + assert wrapped[0].model_id == "served-model" + assert wrapped[0].capability.family == mc.FAMILY_UNKNOWN + assert wrapped[0].capability.capabilities == () + assert wrapped[0].fallback is True + + +def test_generic_model_resource_fields_do_not_infer_anthropic_identity(): + payload = { + "data": [ + { + "id": "foreign-model", + "type": "model", + "display_name": "Foreign Model", + "created_at": "2026-01-01T00:00:00Z", + "capabilities": {"tools": True}, + } + ] + } + + resolution = pcs.resolve_provider(payload) + records = records_from_payload(payload) + + assert resolution.provider_id == pcs.PROVIDER_UNKNOWN + assert resolution.shape_id == "fallback.models.data.v1" + assert resolution.fallback is True + assert records[0].vendor == pcs.PROVIDER_UNKNOWN + assert records[0].fallback is True + assert records[0].capability.capabilities == () + + def test_mistral_reader_maps_per_model_capabilities_without_provider_inheritance(): records = mistral.records_from_payload( { @@ -686,6 +820,60 @@ def test_copilot_reader_ignores_unverified_support_aliases(): assert record.capability.capabilities == () +def test_copilot_catalog_uses_picker_selection_with_no_picker_fallback(): + def payload(*picker_values): + return { + "data": [ + { + "id": f"model-{index}", + "model_picker_enabled": picker_enabled, + "capabilities": {"supports": {}}, + } + for index, picker_enabled in enumerate(picker_values) + ] + } + + selected = records_from_payload(payload(False, True, False), vendor="copilot") + fallback = records_from_payload(payload(False, False), vendor="copilot") + + assert [record.model_id for record in selected] == ["model-1"] + assert [record.model_id for record in fallback] == ["model-0", "model-1"] + + +def test_chatgpt_catalog_applies_visibility_priority_and_slug_deduplication(): + records = records_from_payload( + { + "models": [ + {"slug": "hidden", "visibility": "hidden", "priority": 0}, + {"slug": "later", "visibility": "list", "priority": 20}, + { + "slug": "duplicate", + "visibility": "list", + "priority": 30, + "title": "lower-precedence", + }, + {"slug": "first", "visibility": "list", "priority": 1}, + { + "slug": "duplicate", + "visibility": "list", + "priority": 5, + "title": "selected", + }, + {"slug": "unranked", "visibility": "list", "priority": float("inf")}, + ] + }, + vendor="chatgpt_subscription", + ) + + assert [record.model_id for record in records] == [ + "first", + "duplicate", + "later", + "unranked", + ] + assert records[1].display_name == "selected" + + def test_sglang_model_info_maps_native_generation_flags_only(): generation = sglang.records_from_payload( { @@ -746,7 +934,7 @@ def test_sglang_openai_catalog_preserves_only_valid_native_context_limit(): ] } )[0] - for value in (0, True, float("inf")) + for value in (0, True, 1.5, float("inf")) ] assert valid.vendor == "sglang"