unitysvc-core 0.2.34__tar.gz → 0.2.36__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.
Files changed (55) hide show
  1. unitysvc_core-0.2.36/PKG-INFO +224 -0
  2. unitysvc_core-0.2.36/README.md +189 -0
  3. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/pyproject.toml +2 -2
  4. unitysvc_core-0.2.36/src/unitysvc_core/grants.py +375 -0
  5. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/__init__.py +2 -0
  6. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/listing_data.py +40 -1
  7. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/offering_data.py +13 -0
  8. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/validators.py +72 -1
  9. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/parameter_usage.py +34 -3
  10. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/listing_v1.json +16 -0
  11. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/offering_v1.json +14 -0
  12. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/validator.py +43 -2
  13. unitysvc_core-0.2.36/src/unitysvc_core.egg-info/PKG-INFO +224 -0
  14. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core.egg-info/SOURCES.txt +4 -0
  15. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core.egg-info/requires.txt +1 -1
  16. unitysvc_core-0.2.36/tests/test_canonical_interfaces.py +69 -0
  17. unitysvc_core-0.2.36/tests/test_grants.py +313 -0
  18. unitysvc_core-0.2.36/tests/test_listing_audience.py +52 -0
  19. unitysvc_core-0.2.36/tests/test_parameter_usage.py +185 -0
  20. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_validator.py +78 -0
  21. unitysvc_core-0.2.34/PKG-INFO +0 -147
  22. unitysvc_core-0.2.34/README.md +0 -112
  23. unitysvc_core-0.2.34/src/unitysvc_core.egg-info/PKG-INFO +0 -147
  24. unitysvc_core-0.2.34/tests/test_parameter_usage.py +0 -121
  25. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/LICENSE +0 -0
  26. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/setup.cfg +0 -0
  27. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/__init__.py +0 -0
  28. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/base.py +0 -0
  29. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/documents.py +0 -0
  30. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/listing_v1.py +0 -0
  31. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/offering_v1.py +0 -0
  32. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/pricing.py +0 -0
  33. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/promotion_data.py +0 -0
  34. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/provider_data.py +0 -0
  35. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/provider_v1.py +0 -0
  36. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/service.py +0 -0
  37. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/models/service_group_data.py +0 -0
  38. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/py.typed +0 -0
  39. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/base.json +0 -0
  40. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  41. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/provider_v1.json +0 -0
  42. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  43. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core/utils.py +0 -0
  44. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  45. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  46. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_access_interface_routing.py +0 -0
  47. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_effective_price.py +0 -0
  48. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_hidden_path_skip.py +0 -0
  49. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_offering_data.py +0 -0
  50. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_pricing.py +0 -0
  51. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_provider_account_rate_limits.py +0 -0
  52. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_service_group.py +0 -0
  53. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_service_status.py +0 -0
  54. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_utils.py +0 -0
  55. {unitysvc_core-0.2.34 → unitysvc_core-0.2.36}/tests/test_validators.py +0 -0
@@ -0,0 +1,224 @@
1
+ Metadata-Version: 2.4
2
+ Name: unitysvc-core
3
+ Version: 0.2.36
4
+ Summary: Shared data models and validation helpers for UnitySVC packages
5
+ Author-email: Bo Peng <bo.peng@unitysvc.com>
6
+ Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
7
+ License-Expression: MIT
8
+ Project-URL: bugs, https://github.com/unitysvc/unitysvc-core/issues
9
+ Project-URL: homepage, https://github.com/unitysvc/unitysvc-core
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3 :: Only
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.11
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pydantic>=2
18
+ Requires-Dist: email-validator
19
+ Requires-Dist: jsonschema>=4.18
20
+ Requires-Dist: jinja2
21
+ Requires-Dist: tomli-w
22
+ Requires-Dist: unitysvc-data>=0.1.9
23
+ Provides-Extra: test
24
+ Requires-Dist: coverage; extra == "test"
25
+ Requires-Dist: pytest; extra == "test"
26
+ Requires-Dist: ruff; extra == "test"
27
+ Requires-Dist: mypy; extra == "test"
28
+ Provides-Extra: dev
29
+ Requires-Dist: coverage; extra == "dev"
30
+ Requires-Dist: pytest; extra == "dev"
31
+ Requires-Dist: ruff; extra == "dev"
32
+ Requires-Dist: ty; extra == "dev"
33
+ Requires-Dist: ipdb; extra == "dev"
34
+ Dynamic: license-file
35
+
36
+ # unitysvc-core
37
+
38
+ Shared data models, JSON schemas, and validation helpers for the UnitySVC
39
+ ecosystem. This package is **audience-neutral**: it contains only the
40
+ foundational types and helpers that are consumed by the UnitySVC backend,
41
+ the customer SDK, the admin CLI, and the seller SDK.
42
+
43
+ It intentionally does **not** include any CLI, HTTP client, or
44
+ audience-specific helpers. Those live in the corresponding packages:
45
+
46
+ - `unitysvc-sellers` — seller SDK + `usvc_seller` CLI, catalog builders
47
+ (`populate_from_iterator`, `render_template_file`,
48
+ `convert_convenience_fields_to_documents`, etc.)
49
+ - `unitysvc` — customer SDK + `usvc` CLI
50
+ - `unitysvc-admin` — admin CLI
51
+
52
+ ## What's inside
53
+
54
+ ```
55
+ src/unitysvc_core/
56
+ ├── models/
57
+ │ ├── base.py # All enums (status, type, currency, rate-limit, etc.)
58
+ │ ├── pricing.py # PriceStr, UsageData, all *PriceData classes,
59
+ │ │ # Pricing union, validate_pricing, cost calculation
60
+ │ ├── documents.py # DocumentData
61
+ │ ├── service.py # RateLimit, ServiceConstraints, AccessInterfaceData,
62
+ │ │ # UpstreamAccessConfigData
63
+ │ ├── validators.py # validate_name, validate_service_options,
64
+ │ │ # suggest_valid_name, SUPPORTED_SERVICE_OPTIONS
65
+ │ ├── provider_v1.py # Provider file schema + ProviderData
66
+ │ ├── offering_v1.py # Offering file schema + ServiceOfferingData
67
+ │ ├── listing_v1.py # Listing file schema + ServiceListingData
68
+ │ ├── promotion_v1.py # Promotion file schema + PromotionData
69
+ │ └── service_group_v1.py
70
+ ├── schema/ # Generated JSON schemas (run scripts/generate_schemas.py; CI enforces sync)
71
+ ├── validator.py # DataValidator — per-file schema validation,
72
+ │ # union-field reference checks, API-key secret
73
+ │ # scanning, Jinja2 syntax validation, etc.
74
+ └── utils.py # Content hashing, mime/extension helpers,
75
+ # data file loaders (JSON/TOML + overrides),
76
+ # schema-based file discovery
77
+ ```
78
+
79
+ ### What's deliberately NOT here
80
+
81
+ - **CLI code** — `typer`, `rich`, no entry points
82
+ - **HTTP clients** — no `httpx`, no API wrappers
83
+ - **Seller catalog builders** — `populate_from_iterator`,
84
+ `render_template_file`, `convert_convenience_fields_to_documents`,
85
+ `execute_script_content` live in `unitysvc-sellers`
86
+ - **Seller-catalog-layout validation** — `validate_provider_status`,
87
+ `validate_all_service_directories`, `resolve_provider_name`,
88
+ `resolve_service_name_for_listing` live in `unitysvc-sellers`
89
+
90
+ ## Installation
91
+
92
+ ```bash
93
+ pip install unitysvc-core
94
+ ```
95
+
96
+ Dependencies: `pydantic`, `email-validator`, `jsonschema`, `jinja2`,
97
+ `tomli-w`. No `typer`, no `rich`, no `httpx`.
98
+
99
+ ## Usage
100
+
101
+ ```python
102
+ from pathlib import Path
103
+
104
+ import unitysvc_core
105
+ from unitysvc_core.models import ProviderV1, OfferingV1, ListingV1
106
+ from unitysvc_core.models.pricing import UsageData, validate_pricing
107
+ from unitysvc_core.validator import DataValidator
108
+
109
+ # Validate a single catalog directory
110
+ validator = DataValidator(
111
+ data_dir=Path("./catalog"),
112
+ schema_dir=Path(unitysvc_core.__file__).parent / "schema",
113
+ )
114
+ results = validator.validate_all()
115
+
116
+ # Typed pricing + cost calculation
117
+ pricing = validate_pricing({
118
+ "type": "one_million_tokens",
119
+ "input": "0.50",
120
+ "output": "1.50",
121
+ })
122
+ cost = pricing.calculate_cost(UsageData(input_tokens=1_000, output_tokens=500))
123
+ ```
124
+
125
+ ## History
126
+
127
+ This package was split out of `unitysvc-services` (see
128
+ [unitysvc/unitysvc-services#99](https://github.com/unitysvc/unitysvc-services/issues/99)).
129
+ The seller CLI, catalog builders, and seller-catalog-layout validators
130
+ moved to `unitysvc-sellers`.
131
+
132
+ ## License
133
+
134
+ MIT
135
+
136
+ ### Required enrollment parameter usage
137
+
138
+ The shared validator rejects required user parameters that no channel, interface,
139
+ or document consumes. Only those consumer fields count — the offering's
140
+ `upstream_access_config`, the listing's `user_access_interfaces`, and document
141
+ content (a `.j2` document's template source, which the seller validator reads from
142
+ disk as the SDK uploads it); metadata such as `display_name` or `description` is
143
+ never rendered with `params`. File validation checks a listing against its paired offering
144
+ when present; backend ingest calls the same `validate_required_parameter_usage`
145
+ helper with the complete listing and offering. Schema declarations alone are not
146
+ consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
147
+ are inspected; dynamic access and whole-map iteration conservatively consume all
148
+ parameters. Deprecated content can still be retired.
149
+
150
+ This validates usage, not channel attribution: a document reference counts as a
151
+ consumer without proving that the intended paid channel takes enrollment.
152
+
153
+ ### Canonical user access interfaces
154
+
155
+ A user access interface is the service's canonical address, the same for every caller, so `validate_listing_interfaces_are_canonical` refuses one that reads a per-enrollment value (`enrollment.*`, `params.*`). An enrollment is reached through the address the platform gives it: `/e/<CODE>` on the API gateway, which a listing never declares. The only per-enrollment form allowed is where the protocol has no such path and the code itself is the address: an SMTP `routing_key.username` or an S3 bucket segment that is exactly `{{ enrollment.code }}`. Values the same for every caller (`service_name`, `routing_vars`) stay allowed. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
156
+
157
+ ### Listing audience
158
+
159
+ A listing may say who the service is for and what a buyer must know before enrolling, as `audience`: a short list of plain-text phrases (unitysvc/unitysvc#2717):
160
+
161
+ ```json
162
+ "audience": [
163
+ "Rice University students (enrollment at Rice required)",
164
+ "US customers only / export restrictions may apply"
165
+ ]
166
+ ```
167
+
168
+ At most 8 phrases, each 1-120 characters after trimming, no URLs or markup (details belong in the description or documents), and no phrase twice. The phrases are shown before enrollment and read by assistants matching services to customers; the platform never enforces them. `capabilities` says what a service does; `audience` says who it is for.
169
+
170
+ ### Exports and customer access requests
171
+
172
+ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it exposes), a seller's **`customer_access_requests`** (what the seller asks the enrolling customer to approve: using other services through that customer's account and authority), and the issued **key's policy** (what the customer actually approved). There is no central list of services. `unitysvc_core.grants` holds the formats and the checks; it does no I/O.
173
+
174
+ **Invariant.** Calls made under an approved request run under the enrolling customer's delegated identity, within the approved permissions, options and limits. Looking up a target's `exports` validates a request's *capabilities*; customer consent and key issuance establish its *authority*. A request grants nothing by itself.
175
+
176
+ **1. The `exports` format (the offering, top level).** How other services may use this one:
177
+
178
+ ```json
179
+ "exports": {
180
+ "permissions": {"read": ["GET /records/*"], "write": ["POST /records", "PUT /records/*"]},
181
+ "options": {
182
+ "type": "object",
183
+ "properties": {
184
+ "log_level": {"type": "string", "enum": ["debug", "info", "warn", "error"], "default": "info",
185
+ "title": "Lowest level recorded"},
186
+ "max_size": {"type": "integer", "minimum": 1, "maximum": 10000}
187
+ }
188
+ },
189
+ "options_ui_schema": {"log_level": {"ui:widget": "radio"}}
190
+ }
191
+ ```
192
+
193
+ - `invoke` is always exported and covers every route; it may not be redeclared. A service that declares nothing (most of them) exports `invoke` only, with no options, which `service:<name>` or `service:*` covers.
194
+ - `permissions`: a name → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
195
+ - `options`: a JSON Schema (draft 7) object of the query arguments the service accepts as customizations, like an enrollment's `user_parameters_schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so every value is one query argument: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the option is not sent.
196
+
197
+ **2. A customer access request (`upstream_access_config.<channel>.customer_access_requests`), and the policy entry it becomes.** Any channel may declare its own list; each approved request becomes an entry (a grant) in the issued key's policy, in the same shape:
198
+
199
+ ```json
200
+ "customer_access_requests": [{"object": "service:log", "permissions": ["invoke"], "required": true,
201
+ "justification": "Records each check you run",
202
+ "options": "log_level=warn",
203
+ "limits": {"count_per_day": 1000}}]
204
+ ```
205
+
206
+ - `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
207
+ - `options` is a query string (`parse_options`): each name once, each value non-empty; with a catalog, each value is converted to its declared type and validated against the target's `options` schema.
208
+ - **Enrollment is where options and limits are set.** In a request, `options` and `limits` only pre-fill the enrollment form for convenience. When enrolling, the customer confirms and sets the options (a form rendered from the target's `options` schema) and the limits, and adds their own limits; the result is the issued key's policy.
209
+ - `limits` (`validate_limits`): `count_per_hour` / `count_per_day` / `count_per_month` / `count_total` (positive integer) and the same for `amount_` (finite positive decimal, as a number or a string). Limits are universal: the platform meters every call, so any service can be limited without exporting anything. The seller's limits are a pre-fill; the customer sets them at enrollment, and an enrollment's own limits (unitysvc/unitysvc#2687) use the same shape. They are counted per policy entry (the enrollment's calls to that service) from the usage stream (counters keyed by scope, metric and window bucket), and the gateway refuses calls once one is exceeded.
210
+
211
+ **3. How options reach the service.** No new channel: the upstream URL. When the gateway constructs the target's upstream URL for a call made under a policy entry, it appends that entry's `options` as query arguments, `?log_level=warn&max_size=2000`, the way query arguments already reach every upstream, `log://` included. Rules:
212
+
213
+ - The gateway first strips any query argument the client sent under one of the target's exported option names, then appends the policy's values. Today the client's arguments win on a name collision, so without this a caller could override its own policy.
214
+ - An option the policy does not set is not sent; the service applies its own default. A plain `service:*` key sends none.
215
+ - Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config, including a templated `base_url` query.
216
+ - The platform's own services are terminated in gateway stages before the upstream URI is built, so each stage reads its options from the request's arguments (`log_dispatch` drops entries below `log_level`).
217
+ - SMTP has no query string; options for pure-SMTP delivery are out of scope.
218
+
219
+ **4. Where exports are published.** In the public catalog bundle: every service row carries its `exports` exactly as declared (`null` when none). `GET /v1/catalog/bundle` returns the bundle's URL; a `service:<name>` is the row whose `name` is `<name>`.
220
+
221
+ **5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits and a default claim (`invoke`, no options) need no lookup. For any other claim, `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
222
+
223
+ - `DataValidator(data_dir, schema_dir, exports_resolver=...)` runs both checks on every `offering_v1` (`exports` and each channel's `customer_access_requests`). The seller SDK passes a resolver that downloads the catalog bundle once and reads the target row's `exports`; offline it passes none and gets the shape checks.
224
+ - Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
@@ -0,0 +1,189 @@
1
+ # unitysvc-core
2
+
3
+ Shared data models, JSON schemas, and validation helpers for the UnitySVC
4
+ ecosystem. This package is **audience-neutral**: it contains only the
5
+ foundational types and helpers that are consumed by the UnitySVC backend,
6
+ the customer SDK, the admin CLI, and the seller SDK.
7
+
8
+ It intentionally does **not** include any CLI, HTTP client, or
9
+ audience-specific helpers. Those live in the corresponding packages:
10
+
11
+ - `unitysvc-sellers` — seller SDK + `usvc_seller` CLI, catalog builders
12
+ (`populate_from_iterator`, `render_template_file`,
13
+ `convert_convenience_fields_to_documents`, etc.)
14
+ - `unitysvc` — customer SDK + `usvc` CLI
15
+ - `unitysvc-admin` — admin CLI
16
+
17
+ ## What's inside
18
+
19
+ ```
20
+ src/unitysvc_core/
21
+ ├── models/
22
+ │ ├── base.py # All enums (status, type, currency, rate-limit, etc.)
23
+ │ ├── pricing.py # PriceStr, UsageData, all *PriceData classes,
24
+ │ │ # Pricing union, validate_pricing, cost calculation
25
+ │ ├── documents.py # DocumentData
26
+ │ ├── service.py # RateLimit, ServiceConstraints, AccessInterfaceData,
27
+ │ │ # UpstreamAccessConfigData
28
+ │ ├── validators.py # validate_name, validate_service_options,
29
+ │ │ # suggest_valid_name, SUPPORTED_SERVICE_OPTIONS
30
+ │ ├── provider_v1.py # Provider file schema + ProviderData
31
+ │ ├── offering_v1.py # Offering file schema + ServiceOfferingData
32
+ │ ├── listing_v1.py # Listing file schema + ServiceListingData
33
+ │ ├── promotion_v1.py # Promotion file schema + PromotionData
34
+ │ └── service_group_v1.py
35
+ ├── schema/ # Generated JSON schemas (run scripts/generate_schemas.py; CI enforces sync)
36
+ ├── validator.py # DataValidator — per-file schema validation,
37
+ │ # union-field reference checks, API-key secret
38
+ │ # scanning, Jinja2 syntax validation, etc.
39
+ └── utils.py # Content hashing, mime/extension helpers,
40
+ # data file loaders (JSON/TOML + overrides),
41
+ # schema-based file discovery
42
+ ```
43
+
44
+ ### What's deliberately NOT here
45
+
46
+ - **CLI code** — `typer`, `rich`, no entry points
47
+ - **HTTP clients** — no `httpx`, no API wrappers
48
+ - **Seller catalog builders** — `populate_from_iterator`,
49
+ `render_template_file`, `convert_convenience_fields_to_documents`,
50
+ `execute_script_content` live in `unitysvc-sellers`
51
+ - **Seller-catalog-layout validation** — `validate_provider_status`,
52
+ `validate_all_service_directories`, `resolve_provider_name`,
53
+ `resolve_service_name_for_listing` live in `unitysvc-sellers`
54
+
55
+ ## Installation
56
+
57
+ ```bash
58
+ pip install unitysvc-core
59
+ ```
60
+
61
+ Dependencies: `pydantic`, `email-validator`, `jsonschema`, `jinja2`,
62
+ `tomli-w`. No `typer`, no `rich`, no `httpx`.
63
+
64
+ ## Usage
65
+
66
+ ```python
67
+ from pathlib import Path
68
+
69
+ import unitysvc_core
70
+ from unitysvc_core.models import ProviderV1, OfferingV1, ListingV1
71
+ from unitysvc_core.models.pricing import UsageData, validate_pricing
72
+ from unitysvc_core.validator import DataValidator
73
+
74
+ # Validate a single catalog directory
75
+ validator = DataValidator(
76
+ data_dir=Path("./catalog"),
77
+ schema_dir=Path(unitysvc_core.__file__).parent / "schema",
78
+ )
79
+ results = validator.validate_all()
80
+
81
+ # Typed pricing + cost calculation
82
+ pricing = validate_pricing({
83
+ "type": "one_million_tokens",
84
+ "input": "0.50",
85
+ "output": "1.50",
86
+ })
87
+ cost = pricing.calculate_cost(UsageData(input_tokens=1_000, output_tokens=500))
88
+ ```
89
+
90
+ ## History
91
+
92
+ This package was split out of `unitysvc-services` (see
93
+ [unitysvc/unitysvc-services#99](https://github.com/unitysvc/unitysvc-services/issues/99)).
94
+ The seller CLI, catalog builders, and seller-catalog-layout validators
95
+ moved to `unitysvc-sellers`.
96
+
97
+ ## License
98
+
99
+ MIT
100
+
101
+ ### Required enrollment parameter usage
102
+
103
+ The shared validator rejects required user parameters that no channel, interface,
104
+ or document consumes. Only those consumer fields count — the offering's
105
+ `upstream_access_config`, the listing's `user_access_interfaces`, and document
106
+ content (a `.j2` document's template source, which the seller validator reads from
107
+ disk as the SDK uploads it); metadata such as `display_name` or `description` is
108
+ never rendered with `params`. File validation checks a listing against its paired offering
109
+ when present; backend ingest calls the same `validate_required_parameter_usage`
110
+ helper with the complete listing and offering. Schema declarations alone are not
111
+ consumers. Jinja attribute access, string-key lookups, and `params.get("name")`
112
+ are inspected; dynamic access and whole-map iteration conservatively consume all
113
+ parameters. Deprecated content can still be retired.
114
+
115
+ This validates usage, not channel attribution: a document reference counts as a
116
+ consumer without proving that the intended paid channel takes enrollment.
117
+
118
+ ### Canonical user access interfaces
119
+
120
+ A user access interface is the service's canonical address, the same for every caller, so `validate_listing_interfaces_are_canonical` refuses one that reads a per-enrollment value (`enrollment.*`, `params.*`). An enrollment is reached through the address the platform gives it: `/e/<CODE>` on the API gateway, which a listing never declares. The only per-enrollment form allowed is where the protocol has no such path and the code itself is the address: an SMTP `routing_key.username` or an S3 bucket segment that is exactly `{{ enrollment.code }}`. Values the same for every caller (`service_name`, `routing_vars`) stay allowed. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
121
+
122
+ ### Listing audience
123
+
124
+ A listing may say who the service is for and what a buyer must know before enrolling, as `audience`: a short list of plain-text phrases (unitysvc/unitysvc#2717):
125
+
126
+ ```json
127
+ "audience": [
128
+ "Rice University students (enrollment at Rice required)",
129
+ "US customers only / export restrictions may apply"
130
+ ]
131
+ ```
132
+
133
+ At most 8 phrases, each 1-120 characters after trimming, no URLs or markup (details belong in the description or documents), and no phrase twice. The phrases are shown before enrollment and read by assistants matching services to customers; the platform never enforces them. `capabilities` says what a service does; `audience` says who it is for.
134
+
135
+ ### Exports and customer access requests
136
+
137
+ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it exposes), a seller's **`customer_access_requests`** (what the seller asks the enrolling customer to approve: using other services through that customer's account and authority), and the issued **key's policy** (what the customer actually approved). There is no central list of services. `unitysvc_core.grants` holds the formats and the checks; it does no I/O.
138
+
139
+ **Invariant.** Calls made under an approved request run under the enrolling customer's delegated identity, within the approved permissions, options and limits. Looking up a target's `exports` validates a request's *capabilities*; customer consent and key issuance establish its *authority*. A request grants nothing by itself.
140
+
141
+ **1. The `exports` format (the offering, top level).** How other services may use this one:
142
+
143
+ ```json
144
+ "exports": {
145
+ "permissions": {"read": ["GET /records/*"], "write": ["POST /records", "PUT /records/*"]},
146
+ "options": {
147
+ "type": "object",
148
+ "properties": {
149
+ "log_level": {"type": "string", "enum": ["debug", "info", "warn", "error"], "default": "info",
150
+ "title": "Lowest level recorded"},
151
+ "max_size": {"type": "integer", "minimum": 1, "maximum": 10000}
152
+ }
153
+ },
154
+ "options_ui_schema": {"log_level": {"ui:widget": "radio"}}
155
+ }
156
+ ```
157
+
158
+ - `invoke` is always exported and covers every route; it may not be redeclared. A service that declares nothing (most of them) exports `invoke` only, with no options, which `service:<name>` or `service:*` covers.
159
+ - `permissions`: a name → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
160
+ - `options`: a JSON Schema (draft 7) object of the query arguments the service accepts as customizations, like an enrollment's `user_parameters_schema`, with an optional react-jsonschema-form `options_ui_schema`; the frontend renders it with the same form component. It covers limits only the service can apply (a message size). Restrictions so every value is one query argument: property names are lowercase letters, digits and `_`; each property is a `string`, `integer`, `number`, `boolean` or an `array` of strings, serialized as an OpenAPI form-style query parameter with arrays comma-joined (`style: form, explode: false`); options are optional (no `required`) and only declared properties are accepted; the schema is self-contained (no `$ref`), so validating against it never fetches anything. A property's `default` is what the service does when the option is not sent.
161
+
162
+ **2. A customer access request (`upstream_access_config.<channel>.customer_access_requests`), and the policy entry it becomes.** Any channel may declare its own list; each approved request becomes an entry (a grant) in the issued key's policy, in the same shape:
163
+
164
+ ```json
165
+ "customer_access_requests": [{"object": "service:log", "permissions": ["invoke"], "required": true,
166
+ "justification": "Records each check you run",
167
+ "options": "log_level=warn",
168
+ "limits": {"count_per_day": 1000}}]
169
+ ```
170
+
171
+ - `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
172
+ - `options` is a query string (`parse_options`): each name once, each value non-empty; with a catalog, each value is converted to its declared type and validated against the target's `options` schema.
173
+ - **Enrollment is where options and limits are set.** In a request, `options` and `limits` only pre-fill the enrollment form for convenience. When enrolling, the customer confirms and sets the options (a form rendered from the target's `options` schema) and the limits, and adds their own limits; the result is the issued key's policy.
174
+ - `limits` (`validate_limits`): `count_per_hour` / `count_per_day` / `count_per_month` / `count_total` (positive integer) and the same for `amount_` (finite positive decimal, as a number or a string). Limits are universal: the platform meters every call, so any service can be limited without exporting anything. The seller's limits are a pre-fill; the customer sets them at enrollment, and an enrollment's own limits (unitysvc/unitysvc#2687) use the same shape. They are counted per policy entry (the enrollment's calls to that service) from the usage stream (counters keyed by scope, metric and window bucket), and the gateway refuses calls once one is exceeded.
175
+
176
+ **3. How options reach the service.** No new channel: the upstream URL. When the gateway constructs the target's upstream URL for a call made under a policy entry, it appends that entry's `options` as query arguments, `?log_level=warn&max_size=2000`, the way query arguments already reach every upstream, `log://` included. Rules:
177
+
178
+ - The gateway first strips any query argument the client sent under one of the target's exported option names, then appends the policy's values. Today the client's arguments win on a name collision, so without this a caller could override its own policy.
179
+ - An option the policy does not set is not sent; the service applies its own default. A plain `service:*` key sends none.
180
+ - Enrollment parameters are unchanged and keep working alongside: `{{ params.X }}` still renders into the channel config, including a templated `base_url` query.
181
+ - The platform's own services are terminated in gateway stages before the upstream URI is built, so each stage reads its options from the request's arguments (`log_dispatch` drops entries below `log_level`).
182
+ - SMTP has no query string; options for pure-SMTP delivery are out of scope.
183
+
184
+ **4. Where exports are published.** In the public catalog bundle: every service row carries its `exports` exactly as declared (`null` when none). `GET /v1/catalog/bundle` returns the bundle's URL; a `service:<name>` is the row whose `name` is `<name>`.
185
+
186
+ **5. Validating `customer_access_requests`.** `validate_channel_access_requests(upstream_access_config, resolve)` checks every channel's requests. Limits and a default claim (`invoke`, no options) need no lookup. For any other claim, `resolve(name)` returns the target's `exports` from the catalog (`{}` when it declares none) or `None` when no such service is published; core parses it with `parse_exports` and checks the claimed permissions and the options (names, then values against its `options` schema) against it, refusing an unpublished target. Without a resolver only the shape is checked.
187
+
188
+ - `DataValidator(data_dir, schema_dir, exports_resolver=...)` runs both checks on every `offering_v1` (`exports` and each channel's `customer_access_requests`). The seller SDK passes a resolver that downloads the catalog bundle once and reads the target row's `exports`; offline it passes none and gets the shape checks.
189
+ - Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unitysvc-core"
7
- version = "0.2.34"
7
+ version = "0.2.36"
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" }]
@@ -19,7 +19,7 @@ classifiers = [
19
19
  dependencies = [
20
20
  "pydantic>=2",
21
21
  "email-validator",
22
- "jsonschema",
22
+ "jsonschema>=4.18",
23
23
  "jinja2",
24
24
  "tomli-w",
25
25
  # Presets exposed under $<fn> sentinels that load_data_file expands