unitysvc-core 0.2.29__tar.gz → 0.2.31__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {unitysvc_core-0.2.29/src/unitysvc_core.egg-info → unitysvc_core-0.2.31}/PKG-INFO +1 -1
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/pyproject.toml +1 -1
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/__init__.py +4 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/pricing.py +19 -7
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/validators.py +72 -1
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/validator.py +50 -1
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31/src/unitysvc_core.egg-info}/PKG-INFO +1 -1
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_pricing.py +50 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_validator.py +68 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_validators.py +52 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/LICENSE +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/README.md +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/setup.cfg +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/__init__.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/base.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/documents.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/listing_data.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/listing_v1.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/offering_data.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/offering_v1.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/promotion_data.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/provider_data.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/provider_v1.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/service.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/service_group_data.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/py.typed +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/schema/base.json +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/schema/listing_v1.json +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/schema/offering_v1.json +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/schema/promotion_v1.json +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/schema/provider_v1.json +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/schema/service_group_v1.json +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/utils.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core.egg-info/SOURCES.txt +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core.egg-info/requires.txt +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core.egg-info/top_level.txt +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_access_interface_routing.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_effective_price.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_hidden_path_skip.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_offering_data.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_provider_account_rate_limits.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_service_group.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_service_status.py +0 -0
- {unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/tests/test_utils.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "unitysvc-core"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.31"
|
|
8
8
|
description = "Shared data models and validation helpers for UnitySVC packages"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [{ name = "Bo Peng", email = "bo.peng@unitysvc.com" }]
|
|
@@ -83,12 +83,14 @@ from .service_group_data import (
|
|
|
83
83
|
validate_service_group,
|
|
84
84
|
)
|
|
85
85
|
from .validators import (
|
|
86
|
+
PLATFORM_SERVICES_DIRNAME,
|
|
86
87
|
SUPPORTED_SERVICE_OPTIONS,
|
|
87
88
|
suggest_valid_name,
|
|
88
89
|
validate_listing_gateway_base_urls,
|
|
89
90
|
validate_listing_s3_base_urls,
|
|
90
91
|
validate_listing_smtp_base_urls,
|
|
91
92
|
validate_name,
|
|
93
|
+
validate_platform_listing_base_urls,
|
|
92
94
|
validate_s3_gateway_alias,
|
|
93
95
|
validate_service_identifier,
|
|
94
96
|
validate_service_options,
|
|
@@ -177,7 +179,9 @@ __all__ = [
|
|
|
177
179
|
"validate_service_identifier",
|
|
178
180
|
"validate_service_options",
|
|
179
181
|
"validate_s3_gateway_alias",
|
|
182
|
+
"PLATFORM_SERVICES_DIRNAME",
|
|
180
183
|
"validate_listing_gateway_base_urls",
|
|
184
|
+
"validate_platform_listing_base_urls",
|
|
181
185
|
"validate_listing_s3_base_urls",
|
|
182
186
|
"validate_listing_smtp_base_urls",
|
|
183
187
|
"suggest_valid_name",
|
|
@@ -12,13 +12,14 @@ Contains:
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import ast
|
|
15
|
+
import functools
|
|
15
16
|
import operator
|
|
16
17
|
import re
|
|
17
18
|
from collections.abc import Callable, Iterable
|
|
18
19
|
from decimal import Decimal, InvalidOperation
|
|
19
20
|
from typing import Annotated, Any, Literal
|
|
20
21
|
|
|
21
|
-
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
22
|
+
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator
|
|
22
23
|
from pydantic.functional_validators import BeforeValidator
|
|
23
24
|
|
|
24
25
|
|
|
@@ -1876,6 +1877,22 @@ Pricing = Annotated[
|
|
|
1876
1877
|
]
|
|
1877
1878
|
|
|
1878
1879
|
|
|
1880
|
+
@functools.cache
|
|
1881
|
+
def _pricing_adapter() -> TypeAdapter[Pricing]:
|
|
1882
|
+
"""The ``Pricing`` union's validator, built once and reused.
|
|
1883
|
+
|
|
1884
|
+
Building a ``TypeAdapter`` compiles the validation schema for every member
|
|
1885
|
+
of the union, which costs ~0.4-0.5 ms; validating with it afterwards costs
|
|
1886
|
+
microseconds. ``validate_pricing`` used to build one per call, and
|
|
1887
|
+
composite prices (``multiply``, ``add``, ...) validate their nested parts
|
|
1888
|
+
through it too, so pricing one usage event built it several times: ~2 ms
|
|
1889
|
+
of CPU per event in the usage consumer (unitysvc/unitysvc#2591). Built
|
|
1890
|
+
lazily rather than at import so ``Pricing``'s forward references are
|
|
1891
|
+
resolved first. ``TypeAdapter`` is safe to share across threads.
|
|
1892
|
+
"""
|
|
1893
|
+
return TypeAdapter(Pricing)
|
|
1894
|
+
|
|
1895
|
+
|
|
1879
1896
|
def validate_pricing(
|
|
1880
1897
|
data: dict[str, Any],
|
|
1881
1898
|
) -> (
|
|
@@ -1915,9 +1932,4 @@ def validate_pricing(
|
|
|
1915
1932
|
>>> validated = validate_pricing(data)
|
|
1916
1933
|
>>> print(validated.input) # "0.5"
|
|
1917
1934
|
"""
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
adapter: TypeAdapter[TokenPriceData | TimePriceData | ImagePriceData | StepPriceData | RevenueSharePriceData] = (
|
|
1921
|
-
TypeAdapter(Pricing)
|
|
1922
|
-
)
|
|
1923
|
-
return adapter.validate_python(data)
|
|
1935
|
+
return _pricing_adapter().validate_python(data)
|
|
@@ -558,9 +558,16 @@ _API_GATEWAY_PREFIX = "${API_GATEWAY_BASE_URL}"
|
|
|
558
558
|
_GATEWAY_SEGMENT_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]*$")
|
|
559
559
|
|
|
560
560
|
|
|
561
|
-
def validate_listing_gateway_base_urls(
|
|
561
|
+
def validate_listing_gateway_base_urls(
|
|
562
|
+
user_access_interfaces: dict[str, Any] | None,
|
|
563
|
+
*,
|
|
564
|
+
platform_service: bool = False,
|
|
565
|
+
) -> list[str]:
|
|
562
566
|
"""Validate ``${API_GATEWAY_BASE_URL}/...`` base_urls.
|
|
563
567
|
|
|
568
|
+
A **platform service**'s listing (``platform_service=True``) follows its own
|
|
569
|
+
rule instead: see :func:`validate_platform_listing_base_urls`.
|
|
570
|
+
|
|
564
571
|
The gateway base_url is intentionally left almost unconstrained: a provider
|
|
565
572
|
may route many services through a single base_url and differentiate them by
|
|
566
573
|
other parts of the request (e.g. ``routing_key`` / ``model``), so we cannot
|
|
@@ -582,6 +589,8 @@ def validate_listing_gateway_base_urls(user_access_interfaces: dict[str, Any] |
|
|
|
582
589
|
|
|
583
590
|
Returns a list of error messages (empty if all valid).
|
|
584
591
|
"""
|
|
592
|
+
if platform_service:
|
|
593
|
+
return validate_platform_listing_base_urls(user_access_interfaces)
|
|
585
594
|
if not user_access_interfaces or not isinstance(user_access_interfaces, dict):
|
|
586
595
|
return []
|
|
587
596
|
|
|
@@ -633,6 +642,68 @@ def validate_listing_gateway_base_urls(user_access_interfaces: dict[str, Any] |
|
|
|
633
642
|
return errors
|
|
634
643
|
|
|
635
644
|
|
|
645
|
+
# A platform service is published from ``platform-services/<provider>/<name>/``
|
|
646
|
+
# (unitysvc#2569); its listing is the only one allowed a ``/p`` address.
|
|
647
|
+
PLATFORM_SERVICES_DIRNAME = "platform-services"
|
|
648
|
+
|
|
649
|
+
# Same grammar the platform enforces at publish time.
|
|
650
|
+
_PLATFORM_BASE_URL_RE = re.compile(r"^\$\{API_GATEWAY_BASE_URL\}/p/(?P<route>[a-z0-9][a-z0-9_-]*)$")
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def validate_platform_listing_base_urls(user_access_interfaces: dict[str, Any] | None) -> list[str]:
|
|
654
|
+
"""Validate a platform service listing's ``/p`` address.
|
|
655
|
+
|
|
656
|
+
A platform service is called at ``${API_GATEWAY_BASE_URL}/p/<route>``, which
|
|
657
|
+
ordinary listings may not use (single-letter prefixes are reserved). Its
|
|
658
|
+
listing must declare that address and nothing else:
|
|
659
|
+
|
|
660
|
+
- at least one interface;
|
|
661
|
+
- every ``base_url`` exactly ``${API_GATEWAY_BASE_URL}/p/<route>`` — lowercase
|
|
662
|
+
letters, digits, ``-`` and ``_``, starting with a letter or digit, no
|
|
663
|
+
further path;
|
|
664
|
+
- one ``<route>`` shared by all interfaces;
|
|
665
|
+
- the primary interface's ``routing_key``, if set, an object.
|
|
666
|
+
|
|
667
|
+
Mirrors the platform's publish-time check, so authors see these errors
|
|
668
|
+
before upload. Returns a list of error messages (empty if valid).
|
|
669
|
+
"""
|
|
670
|
+
if (
|
|
671
|
+
not isinstance(user_access_interfaces, dict)
|
|
672
|
+
or not user_access_interfaces
|
|
673
|
+
or not all(isinstance(v, dict) for v in user_access_interfaces.values())
|
|
674
|
+
):
|
|
675
|
+
return [
|
|
676
|
+
"user_access_interfaces: a platform service must declare its "
|
|
677
|
+
"'${API_GATEWAY_BASE_URL}/p/<route>' address (an object of interface objects keyed by name)"
|
|
678
|
+
]
|
|
679
|
+
|
|
680
|
+
errors: list[str] = []
|
|
681
|
+
routes: set[str] = set()
|
|
682
|
+
for iface_name, iface in user_access_interfaces.items():
|
|
683
|
+
base_url = iface.get("base_url")
|
|
684
|
+
match = _PLATFORM_BASE_URL_RE.match(base_url) if isinstance(base_url, str) else None
|
|
685
|
+
if match is None:
|
|
686
|
+
errors.append(
|
|
687
|
+
f"user_access_interfaces.{iface_name}.base_url: a platform service's base_url must be "
|
|
688
|
+
f"exactly '${{API_GATEWAY_BASE_URL}}/p/<route>', got {base_url!r}"
|
|
689
|
+
)
|
|
690
|
+
else:
|
|
691
|
+
routes.add(match.group("route"))
|
|
692
|
+
if len(routes) > 1:
|
|
693
|
+
errors.append(
|
|
694
|
+
f"user_access_interfaces: all interfaces must share one /p route, got {', '.join(sorted(routes))}"
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
primary = sorted(
|
|
698
|
+
user_access_interfaces.items(),
|
|
699
|
+
key=lambda item: (not bool(item[1].get("is_primary")), int(item[1].get("sort_order") or 0), item[0]),
|
|
700
|
+
)[0]
|
|
701
|
+
routing_key = primary[1].get("routing_key")
|
|
702
|
+
if routing_key is not None and not isinstance(routing_key, dict):
|
|
703
|
+
errors.append(f"user_access_interfaces.{primary[0]}.routing_key: must be an object")
|
|
704
|
+
return errors
|
|
705
|
+
|
|
706
|
+
|
|
636
707
|
def _earliest_dynamic_marker(s: str) -> int | None:
|
|
637
708
|
"""Return the index of the earliest dynamic-substitution marker in ``s``.
|
|
638
709
|
|
|
@@ -12,6 +12,12 @@ from jsonschema.validators import Draft7Validator
|
|
|
12
12
|
|
|
13
13
|
from .utils import SCHEMA_TO_STEM, is_hidden_path, load_data_file, schema_for_path
|
|
14
14
|
|
|
15
|
+
# ``details.model``: a provider-neutral model name (``glm-5.2``, not
|
|
16
|
+
# ``zai-org/GLM-5.2`` or Fireworks' ``glm-5p2``). Lowercase, no ``org/``
|
|
17
|
+
# prefix. The backend re-checks the same pattern before it shows a name on a
|
|
18
|
+
# platform tier (``app/crud/platform_served.py``).
|
|
19
|
+
CANONICAL_MODEL_RE = re.compile(r"^[a-z0-9][a-z0-9._:-]*$")
|
|
20
|
+
|
|
15
21
|
# svcpass api_key dispositions (#1198, #1786). On an upstream_access_config
|
|
16
22
|
# interface these literal values are not credentials: "" / "__strip__" strip
|
|
17
23
|
# the svcpass-bearing header, "__forward__" forwards svcpass verbatim, and
|
|
@@ -665,6 +671,37 @@ class DataValidator:
|
|
|
665
671
|
|
|
666
672
|
return errors
|
|
667
673
|
|
|
674
|
+
def validate_offering_model_name(self, data: dict[str, Any], schema_name: str) -> list[str]:
|
|
675
|
+
"""Validate the optional ``details.model`` of an offering.
|
|
676
|
+
|
|
677
|
+
``details.model`` names the model an offering serves in a
|
|
678
|
+
provider-neutral way, independent of the provider's own spelling
|
|
679
|
+
in ``details.upstream_model``. A platform tier lists its members'
|
|
680
|
+
names as the models it is currently served by (unitysvc#2567).
|
|
681
|
+
|
|
682
|
+
Only the shape is checked (lowercase, no ``org/`` prefix); there
|
|
683
|
+
is no registry of allowed names. Absent is fine.
|
|
684
|
+
|
|
685
|
+
Args:
|
|
686
|
+
data: The offering data being validated.
|
|
687
|
+
schema_name: The schema name (only ``offering_v1`` is checked).
|
|
688
|
+
|
|
689
|
+
Returns:
|
|
690
|
+
List of validation error messages.
|
|
691
|
+
"""
|
|
692
|
+
if schema_name != "offering_v1":
|
|
693
|
+
return []
|
|
694
|
+
details = data.get("details")
|
|
695
|
+
if not isinstance(details, dict) or "model" not in details:
|
|
696
|
+
return []
|
|
697
|
+
value = details["model"]
|
|
698
|
+
if isinstance(value, str) and CANONICAL_MODEL_RE.match(value):
|
|
699
|
+
return []
|
|
700
|
+
return [
|
|
701
|
+
f"details.model must be a provider-neutral model name: lowercase letters, "
|
|
702
|
+
f"digits and '.', '_', ':', '-', with no 'org/' prefix (e.g. 'glm-5.2'), got {value!r}."
|
|
703
|
+
]
|
|
704
|
+
|
|
668
705
|
def validate_connectivity_test_exists(self, data: dict[str, Any], schema_name: str) -> list[str]:
|
|
669
706
|
"""Validate that at least one connectivity_test document exists.
|
|
670
707
|
|
|
@@ -921,6 +958,9 @@ class DataValidator:
|
|
|
921
958
|
llm_metadata_errors = self.validate_llm_offering_metadata(data, schema_name)
|
|
922
959
|
errors.extend(llm_metadata_errors)
|
|
923
960
|
|
|
961
|
+
# Validate the optional provider-neutral details.model (offering_v1 only)
|
|
962
|
+
errors.extend(self.validate_offering_model_name(data, schema_name))
|
|
963
|
+
|
|
924
964
|
# Validate MCP offerings declare an mcp upstream channel (offering_v1
|
|
925
965
|
# only); the customer-facing gateway interface is checked below with
|
|
926
966
|
# the other listing_v1 interface rules.
|
|
@@ -940,6 +980,7 @@ class DataValidator:
|
|
|
940
980
|
# Validate S3, SMTP, MCP, and API-gateway interfaces (listing_v1 only)
|
|
941
981
|
if schema_name == "listing_v1":
|
|
942
982
|
from .models.validators import (
|
|
983
|
+
PLATFORM_SERVICES_DIRNAME,
|
|
943
984
|
validate_access_interface_names,
|
|
944
985
|
validate_listing_gateway_base_urls,
|
|
945
986
|
validate_listing_jinja_var_references,
|
|
@@ -950,7 +991,15 @@ class DataValidator:
|
|
|
950
991
|
|
|
951
992
|
uai = data.get("user_access_interfaces")
|
|
952
993
|
errors.extend(validate_access_interface_names(uai))
|
|
953
|
-
|
|
994
|
+
# A listing under platform-services/ is a platform service: its
|
|
995
|
+
# address is /p, which every other listing is refused.
|
|
996
|
+
try:
|
|
997
|
+
parts = file_path.resolve().relative_to(self.data_dir.resolve()).parts
|
|
998
|
+
except ValueError:
|
|
999
|
+
parts = file_path.parts
|
|
1000
|
+
errors.extend(
|
|
1001
|
+
validate_listing_gateway_base_urls(uai, platform_service=PLATFORM_SERVICES_DIRNAME in parts[:-1])
|
|
1002
|
+
)
|
|
954
1003
|
errors.extend(validate_listing_s3_base_urls(uai))
|
|
955
1004
|
errors.extend(validate_listing_smtp_base_urls(uai))
|
|
956
1005
|
errors.extend(validate_listing_mcp_base_urls(uai))
|
|
@@ -2156,3 +2156,53 @@ class TestChannelPriceData:
|
|
|
2156
2156
|
assert pricing.calculate_cost(usage, request_count=10, channel="managed") == Decimal("5")
|
|
2157
2157
|
assert pricing.calculate_cost(usage, request_count=10_000, channel="managed") == Decimal("50")
|
|
2158
2158
|
assert pricing.calculate_cost(usage, request_count=10_000, channel="byok") == Decimal("0")
|
|
2159
|
+
|
|
2160
|
+
|
|
2161
|
+
class TestValidatePricingAdapterIsBuiltOnce:
|
|
2162
|
+
"""``validate_pricing`` reuses one ``TypeAdapter`` (unitysvc/unitysvc#2591).
|
|
2163
|
+
|
|
2164
|
+
Building the adapter compiles the whole ``Pricing`` union's schema
|
|
2165
|
+
(~0.4-0.5 ms); the usage consumer validates several prices per event, so
|
|
2166
|
+
rebuilding it per call made billing CPU-bound.
|
|
2167
|
+
"""
|
|
2168
|
+
|
|
2169
|
+
def test_repeated_calls_build_the_adapter_once(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
2170
|
+
import unitysvc_core.models.pricing as pricing
|
|
2171
|
+
|
|
2172
|
+
built: list[object] = []
|
|
2173
|
+
real = pricing.TypeAdapter
|
|
2174
|
+
|
|
2175
|
+
def counting(*args: object, **kwargs: object) -> object:
|
|
2176
|
+
built.append(args)
|
|
2177
|
+
return real(*args, **kwargs)
|
|
2178
|
+
|
|
2179
|
+
pricing._pricing_adapter.cache_clear()
|
|
2180
|
+
monkeypatch.setattr(pricing, "TypeAdapter", counting)
|
|
2181
|
+
try:
|
|
2182
|
+
token = {"type": "one_million_tokens", "input": "0.5", "output": "1.5"}
|
|
2183
|
+
composite = {
|
|
2184
|
+
"type": "multiply",
|
|
2185
|
+
"factor": "1.05",
|
|
2186
|
+
"base": {"type": "one_million_tokens", "input": "0.5", "output": "1.5"},
|
|
2187
|
+
}
|
|
2188
|
+
for _ in range(50):
|
|
2189
|
+
assert validate_pricing(token).input == "0.5"
|
|
2190
|
+
validate_pricing(composite)
|
|
2191
|
+
finally:
|
|
2192
|
+
pricing._pricing_adapter.cache_clear()
|
|
2193
|
+
assert len(built) == 1
|
|
2194
|
+
|
|
2195
|
+
def test_results_match_a_fresh_adapter(self) -> None:
|
|
2196
|
+
from pydantic import TypeAdapter
|
|
2197
|
+
|
|
2198
|
+
from unitysvc_core.models.pricing import Pricing
|
|
2199
|
+
|
|
2200
|
+
for data in (
|
|
2201
|
+
{"type": "one_million_tokens", "input": "0.5", "output": "1.5"},
|
|
2202
|
+
{"type": "multiply", "factor": "2", "base": {"type": "constant", "price": "1"}},
|
|
2203
|
+
):
|
|
2204
|
+
assert validate_pricing(data) == TypeAdapter(Pricing).validate_python(data)
|
|
2205
|
+
|
|
2206
|
+
def test_invalid_data_still_raises(self) -> None:
|
|
2207
|
+
with pytest.raises(ValueError):
|
|
2208
|
+
validate_pricing({"type": "no_such_price_type"})
|
|
@@ -1164,6 +1164,35 @@ class TestAccessInterfaceNameValidation:
|
|
|
1164
1164
|
assert "'ok_two'" not in joined
|
|
1165
1165
|
|
|
1166
1166
|
|
|
1167
|
+
class TestOfferingModelNameValidation:
|
|
1168
|
+
"""Tests for ``validate_offering_model_name``: ``details.model`` is an
|
|
1169
|
+
optional, provider-neutral model name, checked for shape only."""
|
|
1170
|
+
|
|
1171
|
+
@pytest.mark.parametrize("name", ["glm-5.2", "kimi-k2.6", "deepseek-v4-pro", "qwen3:8b", "gpt_oss_120b"])
|
|
1172
|
+
def test_canonical_names_pass(self, schema_dir, example_data_dir, name):
|
|
1173
|
+
validator = DataValidator(example_data_dir, schema_dir)
|
|
1174
|
+
data = {"service_type": "llm", "details": {"model": name}}
|
|
1175
|
+
assert validator.validate_offering_model_name(data, "offering_v1") == []
|
|
1176
|
+
|
|
1177
|
+
def test_absent_passes(self, schema_dir, example_data_dir):
|
|
1178
|
+
validator = DataValidator(example_data_dir, schema_dir)
|
|
1179
|
+
assert validator.validate_offering_model_name({"details": {}}, "offering_v1") == []
|
|
1180
|
+
assert validator.validate_offering_model_name({}, "offering_v1") == []
|
|
1181
|
+
|
|
1182
|
+
@pytest.mark.parametrize("name", ["zai-org/glm-5.2", "GLM-5.2", "", "-glm", "glm 5.2", None, 5])
|
|
1183
|
+
def test_provider_spellings_rejected(self, schema_dir, example_data_dir, name):
|
|
1184
|
+
validator = DataValidator(example_data_dir, schema_dir)
|
|
1185
|
+
data = {"service_type": "llm", "details": {"model": name}}
|
|
1186
|
+
errors = validator.validate_offering_model_name(data, "offering_v1")
|
|
1187
|
+
assert len(errors) == 1
|
|
1188
|
+
assert "details.model" in errors[0]
|
|
1189
|
+
|
|
1190
|
+
def test_other_schemas_ignored(self, schema_dir, example_data_dir):
|
|
1191
|
+
validator = DataValidator(example_data_dir, schema_dir)
|
|
1192
|
+
data = {"details": {"model": "Not/Canonical"}}
|
|
1193
|
+
assert validator.validate_offering_model_name(data, "listing_v1") == []
|
|
1194
|
+
|
|
1195
|
+
|
|
1167
1196
|
class TestLLMOfferingMetadataValidation:
|
|
1168
1197
|
"""Tests for ``validate_llm_offering_metadata``.
|
|
1169
1198
|
|
|
@@ -1637,3 +1666,42 @@ class TestSvcpassDisposition:
|
|
|
1637
1666
|
data = {"service_options": {"ops_testing_parameters": {"api_key": "__sigv4__"}}}
|
|
1638
1667
|
errors = validator.validate_api_key_secrets(data)
|
|
1639
1668
|
assert errors and "secrets reference format" in errors[0]
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
class TestPlatformServiceListingFile:
|
|
1672
|
+
"""``DataValidator`` applies the ``/p`` rule to listings under
|
|
1673
|
+
``platform-services/`` and the reserved-prefix rule everywhere else (#2569)."""
|
|
1674
|
+
|
|
1675
|
+
@staticmethod
|
|
1676
|
+
def _write(path: Path, base_url: str) -> Path:
|
|
1677
|
+
import json
|
|
1678
|
+
|
|
1679
|
+
path.parent.mkdir(parents=True)
|
|
1680
|
+
path.write_text(
|
|
1681
|
+
json.dumps(
|
|
1682
|
+
{
|
|
1683
|
+
"name": "labs/demo",
|
|
1684
|
+
"user_access_interfaces": {"default": {"access_method": "http", "base_url": base_url}},
|
|
1685
|
+
}
|
|
1686
|
+
)
|
|
1687
|
+
)
|
|
1688
|
+
return path
|
|
1689
|
+
|
|
1690
|
+
@staticmethod
|
|
1691
|
+
def _gateway_errors(validator: DataValidator, path: Path) -> list[str]:
|
|
1692
|
+
_, errors = validator.validate_data_file(path, schema_name="listing_v1", check_name_consistency=False)
|
|
1693
|
+
return [e for e in errors if "base_url" in e]
|
|
1694
|
+
|
|
1695
|
+
def test_platform_service_listing_may_use_p(self, schema_dir, tmp_path):
|
|
1696
|
+
path = self._write(tmp_path / "platform-services/labs/demo/listing.json", "${API_GATEWAY_BASE_URL}/p/llm")
|
|
1697
|
+
assert self._gateway_errors(DataValidator(tmp_path, schema_dir), path) == []
|
|
1698
|
+
|
|
1699
|
+
def test_platform_service_listing_must_use_p(self, schema_dir, tmp_path):
|
|
1700
|
+
path = self._write(tmp_path / "platform-services/labs/demo/listing.json", "${API_GATEWAY_BASE_URL}/llm")
|
|
1701
|
+
errors = self._gateway_errors(DataValidator(tmp_path, schema_dir), path)
|
|
1702
|
+
assert len(errors) == 1 and "/p/<route>" in errors[0]
|
|
1703
|
+
|
|
1704
|
+
def test_ordinary_listing_may_not_use_p(self, schema_dir, tmp_path):
|
|
1705
|
+
path = self._write(tmp_path / "specs/demo/listing.json", "${API_GATEWAY_BASE_URL}/p/llm")
|
|
1706
|
+
errors = self._gateway_errors(DataValidator(tmp_path, schema_dir), path)
|
|
1707
|
+
assert len(errors) == 1 and "reserved single-letter prefix" in errors[0]
|
|
@@ -13,6 +13,7 @@ from unitysvc_core.models.validators import (
|
|
|
13
13
|
validate_listing_mcp_base_urls,
|
|
14
14
|
validate_mcp_namespace,
|
|
15
15
|
validate_mcp_offering,
|
|
16
|
+
validate_platform_listing_base_urls,
|
|
16
17
|
validate_service_identifier,
|
|
17
18
|
)
|
|
18
19
|
|
|
@@ -623,3 +624,54 @@ class TestValidateMcpOffering:
|
|
|
623
624
|
offering = self._offering()
|
|
624
625
|
offering["user_access_interfaces"] = _mcp_uai()
|
|
625
626
|
assert validate_mcp_offering(offering) == []
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class TestPlatformListingBaseUrls:
|
|
630
|
+
"""A platform service's listing declares exactly its ``/p`` address (#2569)."""
|
|
631
|
+
|
|
632
|
+
@staticmethod
|
|
633
|
+
def _p(*base_urls: str, routing_key=None) -> dict:
|
|
634
|
+
uai = {f"i{n}": {"access_method": "http", "base_url": url} for n, url in enumerate(base_urls)}
|
|
635
|
+
if routing_key is not None:
|
|
636
|
+
uai["i0"]["routing_key"] = routing_key
|
|
637
|
+
return uai
|
|
638
|
+
|
|
639
|
+
def test_accepts_one_route(self) -> None:
|
|
640
|
+
uai = self._p("${API_GATEWAY_BASE_URL}/p/llm", "${API_GATEWAY_BASE_URL}/p/llm", routing_key={"model": "x"})
|
|
641
|
+
assert validate_platform_listing_base_urls(uai) == []
|
|
642
|
+
assert validate_listing_gateway_base_urls(uai, platform_service=True) == []
|
|
643
|
+
|
|
644
|
+
def test_ordinary_listing_still_refuses_p(self) -> None:
|
|
645
|
+
errors = validate_listing_gateway_base_urls(self._p("${API_GATEWAY_BASE_URL}/p/llm"))
|
|
646
|
+
assert any("reserved single-letter prefix" in e for e in errors)
|
|
647
|
+
|
|
648
|
+
@pytest.mark.parametrize(
|
|
649
|
+
"base_url",
|
|
650
|
+
[
|
|
651
|
+
"${API_GATEWAY_BASE_URL}/llm",
|
|
652
|
+
"${API_GATEWAY_BASE_URL}/p/llm/v1",
|
|
653
|
+
"${API_GATEWAY_BASE_URL}/p/LLM",
|
|
654
|
+
"${API_GATEWAY_BASE_URL}/p/{{ service_name }}",
|
|
655
|
+
"${API_GATEWAY_BASE_URL}/p/",
|
|
656
|
+
"https://example.com/p/llm",
|
|
657
|
+
],
|
|
658
|
+
)
|
|
659
|
+
def test_rejects_anything_but_exactly_p_route(self, base_url: str) -> None:
|
|
660
|
+
errors = validate_platform_listing_base_urls(self._p(base_url))
|
|
661
|
+
assert len(errors) == 1
|
|
662
|
+
assert "user_access_interfaces.i0.base_url" in errors[0]
|
|
663
|
+
|
|
664
|
+
def test_rejects_mixed_routes(self) -> None:
|
|
665
|
+
errors = validate_platform_listing_base_urls(
|
|
666
|
+
self._p("${API_GATEWAY_BASE_URL}/p/llm", "${API_GATEWAY_BASE_URL}/p/embed")
|
|
667
|
+
)
|
|
668
|
+
assert errors == ["user_access_interfaces: all interfaces must share one /p route, got embed, llm"]
|
|
669
|
+
|
|
670
|
+
def test_rejects_non_object_routing_key(self) -> None:
|
|
671
|
+
errors = validate_platform_listing_base_urls(self._p("${API_GATEWAY_BASE_URL}/p/llm", routing_key="x"))
|
|
672
|
+
assert errors == ["user_access_interfaces.i0.routing_key: must be an object"]
|
|
673
|
+
|
|
674
|
+
@pytest.mark.parametrize("value", [None, {}, "not a dict", {"a": "not an object"}])
|
|
675
|
+
def test_requires_the_address(self, value) -> None:
|
|
676
|
+
errors = validate_platform_listing_base_urls(value)
|
|
677
|
+
assert len(errors) == 1 and "/p/<route>" in errors[0]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/models/service_group_data.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core/schema/service_group_v1.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{unitysvc_core-0.2.29 → unitysvc_core-0.2.31}/src/unitysvc_core.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|