diff --git a/src/model_capability_readers/base.py b/src/model_capability_readers/base.py index 813f79754..f184ab413 100644 --- a/src/model_capability_readers/base.py +++ b/src/model_capability_readers/base.py @@ -171,7 +171,10 @@ 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: - value = compact_str(raw.get(key)) + raw_value = raw.get(key) + if not isinstance(raw_value, str): + continue + value = raw_value.strip() if value: return value.removeprefix("models/") return "" diff --git a/src/model_capability_readers/copilot.py b/src/model_capability_readers/copilot.py index ffc0c670e..642cc87ed 100644 --- a/src/model_capability_readers/copilot.py +++ b/src/model_capability_readers/copilot.py @@ -24,10 +24,7 @@ vendor = VENDOR_COPILOT _SUPPORT_CAPABILITIES = { "tool_calls": mc.CAP_TOOL_CALL, - "tools": mc.CAP_TOOL_CALL, "vision": mc.CAP_VISION, - "reasoning": mc.CAP_REASONING, - "structured_outputs": mc.CAP_STRUCTURED_OUTPUT, } diff --git a/src/provider_capability_schemas.py b/src/provider_capability_schemas.py index 1ef701fdd..8250ea4f6 100644 --- a/src/provider_capability_schemas.py +++ b/src/provider_capability_schemas.py @@ -86,8 +86,8 @@ class ProviderCatalogShape: def item_matches(self, item: Mapping[str, Any]) -> bool: if self.identity_paths and not any( (value := _path_value(item, path)) is not _MISSING - and value is not None - and value != "" + and isinstance(value, str) + and bool(value.strip()) for path in self.identity_paths ): return False @@ -120,7 +120,11 @@ class ProviderCatalogShape: if self.envelope == ENVELOPE_BARE_LIST: return [item] if self.envelope == ENVELOPE_SINGLE: - return item + return { + key: value + for key, value in item.items() + if key not in {ENVELOPE_DATA, ENVELOPE_MODELS} + } if isinstance(payload, Mapping): narrowed = { key: value @@ -231,7 +235,10 @@ OLLAMA_TAGS_SHAPE = ProviderCatalogShape( envelope=ENVELOPE_MODELS, identity_paths=("model", "name"), required_item_any_paths=("digest", "details.family", "details.families"), - detection_priority=90, + # `name` plus a digest/details field is not globally provider-specific. + # Configured provider context remains authoritative for local Ollama + # inventories; payload-only detection would create false provider identity. + detection_priority=0, ) OLLAMA_SHOW_SHAPE = ProviderCatalogShape( shape_id="ollama.show.v1", @@ -241,7 +248,11 @@ OLLAMA_SHOW_SHAPE = ProviderCatalogShape( required_item_paths=("capabilities",), required_item_any_paths=("model_info", "details", "template", "parameters"), item_types=(("capabilities", (list, tuple)),), - detection_priority=100, + # `/api/show` capability and parameter fields are not sufficiently unique + # to identify an otherwise unknown provider. Local/default ports are also + # deliberately non-authoritative, so require configured provider context + # before interpreting this singleton response as Ollama-native metadata. + detection_priority=0, ) LMSTUDIO_MODELS_V1_SHAPE = ProviderCatalogShape( shape_id="lmstudio.models.native.v1", @@ -610,7 +621,10 @@ def native_shape_for_payload( return None priority = max(shape.detection_priority for shape in matches) best = [shape for shape in matches if shape.detection_priority == priority] - return sorted(best, key=lambda shape: shape.shape_id)[0] + # Registry declaration order expresses preference between revisions of the + # same provider shape (for example LM Studio v1 before v0). Alphabetical + # shape ids invert that version preference for otherwise equal evidence. + return best[0] def catalog_shape_for_id(shape_id: Any) -> ProviderCatalogShape | None: diff --git a/tests/test_provider_capability_schemas.py b/tests/test_provider_capability_schemas.py index 39663795b..030c0bc4a 100644 --- a/tests/test_provider_capability_schemas.py +++ b/tests/test_provider_capability_schemas.py @@ -246,10 +246,13 @@ def test_native_catalog_shapes_resolve_with_required_provider_context(): "huggingface", "lmstudio", "mistral", + "ollama", } for payload, expected_provider, expected_shape in cases: explicit_provider = ( - expected_provider if expected_provider in explicit_context_providers else None + expected_provider + if expected_provider in explicit_context_providers + else None ) resolution = pcs.resolve_provider(payload, provider=explicit_provider) assert resolution.provider_id == expected_provider @@ -262,6 +265,59 @@ def test_native_catalog_shapes_resolve_with_required_provider_context(): assert resolution.fallback is False +def test_generic_ollama_like_fields_require_provider_context(): + show_payload = { + "name": "foreign-model", + "capabilities": ["vision"], + "parameters": {}, + } + tags_payload = {"models": [{"name": "foreign-model", "digest": None}]} + + inferred = pcs.resolve_provider(show_payload) + contextual = pcs.resolve_provider(show_payload, provider="ollama") + + assert inferred.provider_id == pcs.PROVIDER_UNKNOWN + assert inferred.shape_id == "" + assert inferred.fallback is False + assert records_from_payload(show_payload) == () + assert contextual.provider_id == "ollama" + assert contextual.shape_id == "ollama.show.v1" + assert contextual.fallback is False + contextual_record = records_from_payload(show_payload, vendor="ollama")[0] + assert contextual_record.capability.capabilities == (mc.CAP_VISION,) + + inferred_tags = pcs.resolve_provider(tags_payload) + contextual_tags = pcs.resolve_provider(tags_payload, provider="ollama") + assert inferred_tags.provider_id == pcs.PROVIDER_UNKNOWN + assert inferred_tags.shape_id == "fallback.models.envelope.v1" + assert inferred_tags.fallback is True + assert contextual_tags.provider_id == "ollama" + assert contextual_tags.shape_id == "ollama.tags.v1" + assert contextual_tags.fallback is False + + +def test_singleton_native_reader_ignores_competing_list_envelopes(): + record = records_from_payload( + { + "model": "show-model", + "capabilities": ["completion", "vision"], + "model_info": {"family.context_length": 4096}, + "models": [ + { + "name": "shadow-model", + "digest": "abc", + "details": {"family": "shadow"}, + } + ], + }, + vendor="ollama", + )[0] + + assert record.model_id == "show-model" + assert record.catalog_shape_id == "ollama.show.v1" + assert record.capability.capabilities == (mc.CAP_VISION,) + + def test_ambiguous_common_fields_do_not_infer_provider_from_payload_alone(): cases = ( ({"data": [{"id": "generic", "architecture": {}}]}, "openrouter"), @@ -436,6 +492,32 @@ def test_selected_native_envelope_ignores_an_unrelated_alternate_envelope(): assert record.fallback is False +def test_same_provider_shape_tie_prefers_declared_modern_envelope(): + record = records_from_payload( + { + "data": [ + { + "id": "legacy-v0-card", + "type": "vlm", + "arch": "legacy", + } + ], + "models": [ + { + "key": "modern-v1-card", + "type": "llm", + "capabilities": {"vision": True}, + } + ], + }, + vendor="lmstudio", + )[0] + + assert record.model_id == "modern-v1-card" + assert record.catalog_shape_id == "lmstudio.models.native.v1" + assert record.capability.capabilities == (mc.CAP_VISION,) + + def test_selected_fallback_envelope_is_not_shadowed_by_empty_data(): record = records_from_payload( { @@ -517,6 +599,15 @@ def test_fallback_reader_fails_soft_for_null_and_malformed_envelopes(): assert generic_openai.records_from_payload(payload) == () +def test_structured_identity_values_are_not_stringified_into_fallback_records(): + for key in ("id", "name", "model", "key", "slug"): + payload = [{key: {"nested": "model"}}] + + assert pcs.resolve_provider(payload).shape_id == "" + assert generic_openai.records_from_payload(payload) == () + assert records_from_payload(payload, vendor="future-provider") == () + + def test_mistral_reader_maps_per_model_capabilities_without_provider_inheritance(): records = mistral.records_from_payload( { @@ -571,6 +662,30 @@ def test_copilot_reader_uses_picker_and_nested_supports_shape(): assert dict(record.capability.limits) == {"input_tokens": 64000, "output_tokens": 8192} +def test_copilot_reader_ignores_unverified_support_aliases(): + record = records_from_payload( + { + "data": [ + { + "id": "future-supports-model", + "model_picker_enabled": True, + "capabilities": { + "supports": { + "tools": True, + "reasoning": True, + "structured_outputs": True, + } + }, + } + ] + }, + vendor="copilot", + )[0] + + assert record.capability.family == mc.FAMILY_CHAT + assert record.capability.capabilities == () + + def test_sglang_model_info_maps_native_generation_flags_only(): generation = sglang.records_from_payload( {