unitysvc-core 0.2.37__tar.gz → 0.3.0__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 (54) hide show
  1. {unitysvc_core-0.2.37/src/unitysvc_core.egg-info → unitysvc_core-0.3.0}/PKG-INFO +59 -26
  2. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/README.md +58 -25
  3. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/pyproject.toml +1 -1
  4. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/grants.py +129 -5
  5. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/__init__.py +29 -0
  6. unitysvc_core-0.3.0/src/unitysvc_core/models/channel.py +302 -0
  7. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/listing_data.py +0 -11
  8. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/listing_v1.py +0 -1
  9. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/offering_v1.py +6 -23
  10. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/validators.py +100 -56
  11. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/parameter_usage.py +21 -46
  12. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/schema/listing_v1.json +0 -28
  13. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/schema/offering_v1.json +2479 -240
  14. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/validator.py +232 -174
  15. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0/src/unitysvc_core.egg-info}/PKG-INFO +59 -26
  16. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core.egg-info/SOURCES.txt +3 -0
  17. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_canonical_interfaces.py +31 -2
  18. unitysvc_core-0.3.0/tests/test_channel.py +138 -0
  19. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_grants.py +154 -0
  20. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_parameter_usage.py +51 -53
  21. unitysvc_core-0.3.0/tests/test_validate_service.py +115 -0
  22. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_validator.py +83 -179
  23. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/LICENSE +0 -0
  24. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/setup.cfg +0 -0
  25. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/__init__.py +0 -0
  26. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/base.py +0 -0
  27. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/documents.py +0 -0
  28. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/offering_data.py +0 -0
  29. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/pricing.py +0 -0
  30. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/promotion_data.py +0 -0
  31. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/provider_data.py +0 -0
  32. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/provider_v1.py +0 -0
  33. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/service.py +0 -0
  34. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/models/service_group_data.py +0 -0
  35. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/py.typed +0 -0
  36. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/schema/base.json +0 -0
  37. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/schema/promotion_v1.json +0 -0
  38. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/schema/provider_v1.json +0 -0
  39. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/schema/service_group_v1.json +0 -0
  40. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core/utils.py +0 -0
  41. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core.egg-info/dependency_links.txt +0 -0
  42. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core.egg-info/requires.txt +0 -0
  43. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/src/unitysvc_core.egg-info/top_level.txt +0 -0
  44. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_access_interface_routing.py +0 -0
  45. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_effective_price.py +0 -0
  46. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_hidden_path_skip.py +0 -0
  47. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_listing_audience.py +0 -0
  48. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_offering_data.py +0 -0
  49. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_pricing.py +0 -0
  50. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_provider_account_rate_limits.py +0 -0
  51. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_service_group.py +0 -0
  52. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_service_status.py +0 -0
  53. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_utils.py +0 -0
  54. {unitysvc_core-0.2.37 → unitysvc_core-0.3.0}/tests/test_validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unitysvc-core
3
- Version: 0.2.37
3
+ Version: 0.3.0
4
4
  Summary: Shared data models and validation helpers for UnitySVC packages
5
5
  Author-email: Bo Peng <bo.peng@unitysvc.com>
6
6
  Maintainer-email: Bo Peng <bo.peng@unitysvc.com>
@@ -57,6 +57,8 @@ src/unitysvc_core/
57
57
  │ ├── base.py # All enums (status, type, currency, rate-limit, etc.)
58
58
  │ ├── pricing.py # PriceStr, UsageData, all *PriceData classes,
59
59
  │ │ # Pricing union, validate_pricing, cost calculation
60
+ │ ├── channel.py # UpstreamAccessConfig, ChannelConfig, per-protocol
61
+ │ │ # servers, ChannelParams, RETIRED_CHANNEL_KEYS
60
62
  │ ├── documents.py # DocumentData
61
63
  │ ├── service.py # RateLimit, ServiceConstraints, AccessInterfaceData,
62
64
  │ │ # UpstreamAccessConfigData
@@ -135,24 +137,26 @@ MIT
135
137
 
136
138
  ### Required enrollment parameter usage
137
139
 
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.
140
+ Each channel's required parameters must be read by that channel or by document
141
+ content (a `.j2` document's template source, which the seller validator reads
142
+ from disk as the SDK uploads it); another channel's use does not count. Metadata
143
+ such as `display_name` or `description` is never rendered with `params`, and a
144
+ user access interface never reads them. File validation checks a listing against
145
+ its paired offering when present; backend ingest calls the same
146
+ `validate_required_parameter_usage` helper with the complete listing and
147
+ offering. Schema declarations alone are not consumers. Jinja attribute access,
148
+ string-key lookups, and `params.get("name")` are inspected; dynamic access and
149
+ whole-map iteration conservatively consume all parameters. Deprecated content can
150
+ still be retired.
152
151
 
153
152
  ### Canonical user access interfaces
154
153
 
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).
154
+ A user access interface is the service's canonical address, the same for every caller. `validate_listing_interfaces_are_canonical` renders `{{ service_name }}` (the one value the platform fills in when it stores an interface) and then requires:
155
+
156
+ - a `base_url` that is one of the four gateways, `${API_GATEWAY_BASE_URL}`, `${S3_GATEWAY_BASE_URL}`, `${SMTP_GATEWAY_BASE_URL}` or `${MCP_GATEWAY_BASE_URL}`, optionally followed by `/` and a path (`/a/<alias>` included); never an external URL, another variable or a secret reference;
157
+ - no template left over. `{{ routing_vars.X }}` would stay literal in the stored interface and never route, and a per-enrollment value (`enrollment.*`, `params.*`) is never canonical: an enrollment is reached through `/e/<CODE>` on the API gateway, which a listing never declares.
158
+
159
+ The one exception is `{{ enrollment.code }}` as the whole SMTP `routing_key.username` or the whole S3 bucket segment, a reminder of the per-enrollment address on protocols without a path for it. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
156
160
 
157
161
  ### Listing audience
158
162
 
@@ -193,7 +197,7 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
193
197
  - **Without `permissions`** (most services, including one that declares only `options`), the service exports one permission, `invoke`, on every route (`"* /*"`); `service:<name>` and the `service:*` wildcard grant it.
194
198
  - **With `permissions`**, the service exports exactly those, and `invoke` only if it declares it too. A data service (`me`, `log`, `needs`) declares `read` / `write` and no `invoke`: it holds the customer's own data, so only a grant that names its permissions reaches it, never `invoke` or a wildcard.
195
199
  - `permissions`: a name (lowercase letters, digits and `_`) → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
196
- - `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, 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 a grant can write every value in query-string syntax: 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 policy does not set the option.
200
+ - `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, like a channel's `params.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 a grant can write every value in query-string syntax: 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 policy does not set the option.
197
201
 
198
202
  **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:
199
203
 
@@ -204,10 +208,11 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
204
208
  "limits": {"count_per_day": 1000}}]
205
209
  ```
206
210
 
207
- - `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
211
+ - `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
208
212
  - `options` is written in query-string syntax (`parse_options`; a notation, not how options are delivered): 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.
209
213
  - **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.
210
214
  - `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.
215
+ - **A longer-lived leased key** is asked for with one `apikey` entry: `{"object": "apikey", "limits": {"api_key_lifespan": 14400}, "justification": "Batch runs take up to four hours"}`. It names the key itself, so it takes only `limits` (exactly `api_key_lifespan`, whole seconds from 1 to 86400) and a `justification`, needs no catalog lookup, and grants nothing. Without it, a key the seller leases for the enrollment lives `DEFAULT_API_KEY_LIFESPAN` (3600 s). `api_key_lifespan` is valid only on this entry.
211
216
 
212
217
  **3. How options reach the service.** Not through the upstream URL: the gateway never changes a target's `base_url` or query to carry policy. How an option is applied is up to the service's implementation:
213
218
 
@@ -224,9 +229,40 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
224
229
  - `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.
225
230
  - Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
226
231
 
232
+ **6. From approvals to a policy (`build_policy`).** When the customer enrolls, `build_policy(requests, approvals, resolve)` turns their approvals of the channel's requests into the policy the enrollment keeps and every key leased for it copies (unitysvc/unitysvc#2766):
233
+
234
+ ```json
235
+ [{"object": "service:log", "permissions": ["invoke"], "options": "log_level=error", "limits": {"count_per_day": 200}},
236
+ {"object": "apikey", "limits": {"api_key_lifespan": 7200}}]
237
+ ```
238
+
239
+ - Each approval names a requested object, at most once, with some of the permissions requested. Every `required` request must be approved; optional ones may be left out.
240
+ - Options and limits are the customer's. They are checked against the target's `exports` (with `resolve`) and `validate_limits`, not against the request, whose values only pre-fill the form.
241
+ - The `apikey` lifespan may be shortened, never lengthened beyond the request.
242
+ - The result drops `required` / `justification` and empty `options` / `limits`. `api_key_lifespan(policy)` reads the lifespan back (the default when there is none).
243
+
244
+ ### Upstream access channels
245
+
246
+ `upstream_access_config` maps a channel name to a channel (`UpstreamAccessConfig`, `ChannelConfig`; unitysvc/unitysvc#2730). A channel describes one way the platform reaches the upstream, as a whole, and its `servers` hold the connection, typed by the channel's protocol:
247
+
248
+ | Channel key | What it is |
249
+ | --- | --- |
250
+ | `access_method` | `http` (default), `smtp`, `s3` or `mcp`; selects the server type |
251
+ | `type` | normally derived by the platform; declare `external` (or `enrollable`) to state it |
252
+ | `description`, `is_active`, `sort_order` | display text; inactive channels are skipped; rank, lowest first |
253
+ | `request_formats`, `applicable_interfaces` | the formats the channel serves; the interfaces that reach it (display only) |
254
+ | `rate_limit_refs` | the provider rate limits it consumes |
255
+ | `customer_access_requests` | what the enrolling customer is asked to approve (see above) |
256
+ | `params` | the enrollment parameters (below) |
257
+ | `servers` | the connection: `HttpServer`, `SmtpServer`, `S3Server` or `McpServer` |
258
+
259
+ The common one-server case writes the connection fields directly on the channel; they are read as `servers[0]`. When a channel has both, each server inherits the direct fields it does not set. Any string may be a template (`{{ params.X }}`, `${ secrets.X }`, `${ customer_secrets.X ?? default }`), so numeric and boolean connection fields also accept strings.
260
+
261
+ Unknown keys are refused. Retired keys are refused with what to do instead (`RETIRED_CHANNEL_KEYS`): `raw`, `is_primary`, the listing-style parameter trio, `grants`, `rate_limits` (nothing reads it; use `rate_limit_refs`), `storage_type` (use `access_method: s3`), `response_rules`, `timeout_ms`, `transport`, and the platform-derived `customer_secrets_required` / `customer_secrets_optional`.
262
+
227
263
  ### Enrollment parameters on channels
228
264
 
229
- A channel may declare its own enrollment parameters, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726):
265
+ A channel declares the enrollment parameters it reads, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726). A listing declares none: `user_parameters_schema`, `user_parameters_ui_schema` and `service_options.ops_testing_parameters` are refused.
230
266
 
231
267
  ```json
232
268
  "upstream_access_config": {
@@ -241,11 +277,8 @@ A channel may declare its own enrollment parameters, so each plan of a service a
241
277
  }
242
278
  ```
243
279
 
244
- `params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Once any channel declares `params`:
245
-
246
- - a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare its own `params` (`validate_channel_parameters`, on every `offering_v1`);
247
- - each required parameter has a value in that channel's `params.testing_defaults`;
248
- - each required parameter is read by its own channel or by document content; another channel's use does not count (`validate_required_parameter_usage`, which backend ingest also calls);
249
- - the listing must not also declare `user_parameters_schema`.
280
+ `params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Rules:
250
281
 
251
- When no channel declares `params`, the listing-level `user_parameters_schema` applies to the whole service as before.
282
+ - a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare `params` (`validate_channel_parameters`, on every `offering_v1`);
283
+ - each required parameter has a value in that channel's `params.testing_defaults`, which is also what `${ customer_secrets.{{ params.X }} }` renders with when secret references are checked;
284
+ - each required parameter is read by its own channel or by document content (`validate_required_parameter_usage`, which backend ingest also calls).
@@ -22,6 +22,8 @@ src/unitysvc_core/
22
22
  │ ├── base.py # All enums (status, type, currency, rate-limit, etc.)
23
23
  │ ├── pricing.py # PriceStr, UsageData, all *PriceData classes,
24
24
  │ │ # Pricing union, validate_pricing, cost calculation
25
+ │ ├── channel.py # UpstreamAccessConfig, ChannelConfig, per-protocol
26
+ │ │ # servers, ChannelParams, RETIRED_CHANNEL_KEYS
25
27
  │ ├── documents.py # DocumentData
26
28
  │ ├── service.py # RateLimit, ServiceConstraints, AccessInterfaceData,
27
29
  │ │ # UpstreamAccessConfigData
@@ -100,24 +102,26 @@ MIT
100
102
 
101
103
  ### Required enrollment parameter usage
102
104
 
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.
105
+ Each channel's required parameters must be read by that channel or by document
106
+ content (a `.j2` document's template source, which the seller validator reads
107
+ from disk as the SDK uploads it); another channel's use does not count. Metadata
108
+ such as `display_name` or `description` is never rendered with `params`, and a
109
+ user access interface never reads them. File validation checks a listing against
110
+ its paired offering when present; backend ingest calls the same
111
+ `validate_required_parameter_usage` helper with the complete listing and
112
+ offering. Schema declarations alone are not consumers. Jinja attribute access,
113
+ string-key lookups, and `params.get("name")` are inspected; dynamic access and
114
+ whole-map iteration conservatively consume all parameters. Deprecated content can
115
+ still be retired.
117
116
 
118
117
  ### Canonical user access interfaces
119
118
 
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).
119
+ A user access interface is the service's canonical address, the same for every caller. `validate_listing_interfaces_are_canonical` renders `{{ service_name }}` (the one value the platform fills in when it stores an interface) and then requires:
120
+
121
+ - a `base_url` that is one of the four gateways, `${API_GATEWAY_BASE_URL}`, `${S3_GATEWAY_BASE_URL}`, `${SMTP_GATEWAY_BASE_URL}` or `${MCP_GATEWAY_BASE_URL}`, optionally followed by `/` and a path (`/a/<alias>` included); never an external URL, another variable or a secret reference;
122
+ - no template left over. `{{ routing_vars.X }}` would stay literal in the stored interface and never route, and a per-enrollment value (`enrollment.*`, `params.*`) is never canonical: an enrollment is reached through `/e/<CODE>` on the API gateway, which a listing never declares.
123
+
124
+ The one exception is `{{ enrollment.code }}` as the whole SMTP `routing_key.username` or the whole S3 bucket segment, a reminder of the per-enrollment address on protocols without a path for it. The shared validator runs it on every `listing_v1`; backend ingest calls the same function (unitysvc/unitysvc#2726).
121
125
 
122
126
  ### Listing audience
123
127
 
@@ -158,7 +162,7 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
158
162
  - **Without `permissions`** (most services, including one that declares only `options`), the service exports one permission, `invoke`, on every route (`"* /*"`); `service:<name>` and the `service:*` wildcard grant it.
159
163
  - **With `permissions`**, the service exports exactly those, and `invoke` only if it declares it too. A data service (`me`, `log`, `needs`) declares `read` / `write` and no `invoke`: it holds the customer's own data, so only a grant that names its permissions reaches it, never `invoke` or a wildcard.
160
164
  - `permissions`: a name (lowercase letters, digits and `_`) → the routes it covers, each `"<METHOD> /<path>"` (`*` for any method, `*` globs in the path). Permissions are never inferred from HTTP methods.
161
- - `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, 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 a grant can write every value in query-string syntax: 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 policy does not set the option.
165
+ - `options`: a JSON Schema (draft 7) object of the customizations a grant may set on the service, like a channel's `params.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 a grant can write every value in query-string syntax: 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 policy does not set the option.
162
166
 
163
167
  **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:
164
168
 
@@ -169,10 +173,11 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
169
173
  "limits": {"count_per_day": 1000}}]
170
174
  ```
171
175
 
172
- - `object` names one service (`service:<name>`, no wildcards). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
176
+ - `object` names one service (`service:<name>`, no wildcards), or `apikey` for the leased key's lifespan (below). `enrollment:<CODE>` (one enrollment of an enrollable service) may be allowed later.
173
177
  - `options` is written in query-string syntax (`parse_options`; a notation, not how options are delivered): 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.
174
178
  - **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.
175
179
  - `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.
180
+ - **A longer-lived leased key** is asked for with one `apikey` entry: `{"object": "apikey", "limits": {"api_key_lifespan": 14400}, "justification": "Batch runs take up to four hours"}`. It names the key itself, so it takes only `limits` (exactly `api_key_lifespan`, whole seconds from 1 to 86400) and a `justification`, needs no catalog lookup, and grants nothing. Without it, a key the seller leases for the enrollment lives `DEFAULT_API_KEY_LIFESPAN` (3600 s). `api_key_lifespan` is valid only on this entry.
176
181
 
177
182
  **3. How options reach the service.** Not through the upstream URL: the gateway never changes a target's `base_url` or query to carry policy. How an option is applied is up to the service's implementation:
178
183
 
@@ -189,9 +194,40 @@ Three stages (unitysvc/unitysvc#2720): a target service's **`exports`** (what it
189
194
  - `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.
190
195
  - Backend ingest calls the same function with a resolver over the published services, so an unknown target, permission or option fails at upload.
191
196
 
197
+ **6. From approvals to a policy (`build_policy`).** When the customer enrolls, `build_policy(requests, approvals, resolve)` turns their approvals of the channel's requests into the policy the enrollment keeps and every key leased for it copies (unitysvc/unitysvc#2766):
198
+
199
+ ```json
200
+ [{"object": "service:log", "permissions": ["invoke"], "options": "log_level=error", "limits": {"count_per_day": 200}},
201
+ {"object": "apikey", "limits": {"api_key_lifespan": 7200}}]
202
+ ```
203
+
204
+ - Each approval names a requested object, at most once, with some of the permissions requested. Every `required` request must be approved; optional ones may be left out.
205
+ - Options and limits are the customer's. They are checked against the target's `exports` (with `resolve`) and `validate_limits`, not against the request, whose values only pre-fill the form.
206
+ - The `apikey` lifespan may be shortened, never lengthened beyond the request.
207
+ - The result drops `required` / `justification` and empty `options` / `limits`. `api_key_lifespan(policy)` reads the lifespan back (the default when there is none).
208
+
209
+ ### Upstream access channels
210
+
211
+ `upstream_access_config` maps a channel name to a channel (`UpstreamAccessConfig`, `ChannelConfig`; unitysvc/unitysvc#2730). A channel describes one way the platform reaches the upstream, as a whole, and its `servers` hold the connection, typed by the channel's protocol:
212
+
213
+ | Channel key | What it is |
214
+ | --- | --- |
215
+ | `access_method` | `http` (default), `smtp`, `s3` or `mcp`; selects the server type |
216
+ | `type` | normally derived by the platform; declare `external` (or `enrollable`) to state it |
217
+ | `description`, `is_active`, `sort_order` | display text; inactive channels are skipped; rank, lowest first |
218
+ | `request_formats`, `applicable_interfaces` | the formats the channel serves; the interfaces that reach it (display only) |
219
+ | `rate_limit_refs` | the provider rate limits it consumes |
220
+ | `customer_access_requests` | what the enrolling customer is asked to approve (see above) |
221
+ | `params` | the enrollment parameters (below) |
222
+ | `servers` | the connection: `HttpServer`, `SmtpServer`, `S3Server` or `McpServer` |
223
+
224
+ The common one-server case writes the connection fields directly on the channel; they are read as `servers[0]`. When a channel has both, each server inherits the direct fields it does not set. Any string may be a template (`{{ params.X }}`, `${ secrets.X }`, `${ customer_secrets.X ?? default }`), so numeric and boolean connection fields also accept strings.
225
+
226
+ Unknown keys are refused. Retired keys are refused with what to do instead (`RETIRED_CHANNEL_KEYS`): `raw`, `is_primary`, the listing-style parameter trio, `grants`, `rate_limits` (nothing reads it; use `rate_limit_refs`), `storage_type` (use `access_method: s3`), `response_rules`, `timeout_ms`, `transport`, and the platform-derived `customer_secrets_required` / `customer_secrets_optional`.
227
+
192
228
  ### Enrollment parameters on channels
193
229
 
194
- A channel may declare its own enrollment parameters, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726):
230
+ A channel declares the enrollment parameters it reads, so each plan of a service asks for what it needs (unitysvc/unitysvc#2726). A listing declares none: `user_parameters_schema`, `user_parameters_ui_schema` and `service_options.ops_testing_parameters` are refused.
195
231
 
196
232
  ```json
197
233
  "upstream_access_config": {
@@ -206,11 +242,8 @@ A channel may declare its own enrollment parameters, so each plan of a service a
206
242
  }
207
243
  ```
208
244
 
209
- `params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Once any channel declares `params`:
210
-
211
- - a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare its own `params` (`validate_channel_parameters`, on every `offering_v1`);
212
- - each required parameter has a value in that channel's `params.testing_defaults`;
213
- - each required parameter is read by its own channel or by document content; another channel's use does not count (`validate_required_parameter_usage`, which backend ingest also calls);
214
- - the listing must not also declare `user_parameters_schema`.
245
+ `params.schema` is a JSON Schema of the parameters (a property's own `default` pre-fills the customer's form), `params.ui_schema` holds form hints, and `params.testing_defaults` holds the values the platform tests the channel with. Rules:
215
246
 
216
- When no channel declares `params`, the listing-level `user_parameters_schema` applies to the whole service as before.
247
+ - a channel reads only the parameters its `params.schema` declares, and a channel that reads `{{ params.* }}` must declare `params` (`validate_channel_parameters`, on every `offering_v1`);
248
+ - each required parameter has a value in that channel's `params.testing_defaults`, which is also what `${ customer_secrets.{{ params.X }} }` renders with when secret references are checked;
249
+ - each required parameter is read by its own channel or by document content (`validate_required_parameter_usage`, which backend ingest also calls).
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unitysvc-core"
7
- version = "0.2.37"
7
+ version = "0.3.0"
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" }]
@@ -22,7 +22,7 @@ Three stages (unitysvc/unitysvc#2720):
22
22
  that names its permissions, never by ``invoke`` or a wildcard. Permissions
23
23
  name the routes they cover; they are never inferred from HTTP methods
24
24
  (``POST /chat/completions`` modifies nothing). ``options`` is a JSON Schema,
25
- like an enrollment's ``user_parameters_schema`` (with an optional
25
+ like a channel's ``params.schema`` (with an optional
26
26
  ``options_ui_schema``), of the customizations a grant may set on the
27
27
  service, including limits only it can apply (a message size); a property's
28
28
  ``default`` is what the service does when the policy does not set the
@@ -72,8 +72,11 @@ customer consent and key issuance establish its *authority*.
72
72
  be limited by ``count`` or ``amount`` per hour, day or month, or in total,
73
73
  without exporting anything (``{"count_per_day": 2, "amount_per_month": "2.00"}``).
74
74
 
75
- Today a request's ``object`` is ``service:<name>``; ``enrollment:<CODE>`` (one
76
- enrollment of an enrollable service) may follow.
75
+ Today a request's ``object`` is ``service:<name>``, or ``apikey``: how long a
76
+ key leased for the enrollment lives (``limits.api_key_lifespan``, up to a day,
77
+ with a justification), which grants nothing. ``enrollment:<CODE>`` (one
78
+ enrollment of an enrollable service) may follow. :func:`build_policy` turns a
79
+ customer's approvals into the policy an enrollment keeps.
77
80
 
78
81
  There is no central list of services. Core does no I/O: every request's
79
82
  permissions and options are checked against its target's ``exports`` when the
@@ -101,6 +104,13 @@ from .models.validators import validate_service_identifier
101
104
 
102
105
  SERVICE_PREFIX = "service:"
103
106
  INVOKE = "invoke"
107
+ # The key itself: a request for a longer-lived leased key, and the approved
108
+ # lifespan in a policy. It grants nothing.
109
+ APIKEY_OBJECT = "apikey"
110
+ API_KEY_LIFESPAN = "api_key_lifespan"
111
+ # Seconds a leased key lives unless the policy says otherwise, and at most.
112
+ DEFAULT_API_KEY_LIFESPAN = 3600
113
+ MAX_API_KEY_LIFESPAN = 86400
104
114
  # The channel key a seller's requests are declared under.
105
115
  REQUESTS_KEY = "customer_access_requests"
106
116
 
@@ -144,6 +154,9 @@ DEFAULT_PERMISSIONS: dict[str, tuple[str, ...]] = {INVOKE: (ALL_ROUTES,)}
144
154
  DEFAULT_EXPORTS = Exports(DEFAULT_PERMISSIONS, {})
145
155
 
146
156
  _REQUEST_KEYS = frozenset({"object", "permissions", "required", "justification", "options", "limits"})
157
+ _APIKEY_REQUEST_KEYS = frozenset({"object", "justification", "limits"})
158
+ # What a policy entry (an approved request) holds.
159
+ _POLICY_KEYS = frozenset({"object", "permissions", "options", "limits"})
147
160
 
148
161
 
149
162
  def _strings(value: Any) -> bool:
@@ -310,15 +323,29 @@ def parse_options(where: str, options: Any) -> dict[str, str]:
310
323
  return parsed
311
324
 
312
325
 
313
- def validate_limits(where: str, limits: Any) -> None:
326
+ def validate_limits(where: str, limits: Any, *, apikey: bool = False) -> None:
314
327
  """Refuse ``limits`` unless it maps ``<metric>_per_<window>`` or
315
328
  ``<metric>_total`` to a maximum: ``count`` a positive integer, ``amount`` a
316
329
  finite positive decimal, as a number (``2.5``) or a string (``"2.50"``).
317
330
  Windows: hour, day, month. The same shape serves a request's suggested
318
331
  limits, the limits a customer sets in a key's policy, and an enrollment's own
319
- limits."""
332
+ limits.
333
+
334
+ With ``apikey`` (the ``apikey`` entry), the only limit is
335
+ ``api_key_lifespan``: how many seconds a leased key lives, an integer from 1
336
+ to :data:`MAX_API_KEY_LIFESPAN`.
337
+ """
320
338
  if not isinstance(limits, dict):
321
339
  raise ValueError(f'{where} must be an object like {{"count_per_day": 2}}')
340
+ if apikey:
341
+ if set(limits) != {API_KEY_LIFESPAN}:
342
+ raise ValueError(f'{where} must be {{"{API_KEY_LIFESPAN}": <seconds>}}')
343
+ value = limits[API_KEY_LIFESPAN]
344
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 < value <= MAX_API_KEY_LIFESPAN:
345
+ raise ValueError(
346
+ f"{where}.{API_KEY_LIFESPAN} must be a whole number of seconds from 1 to {MAX_API_KEY_LIFESPAN}"
347
+ )
348
+ return
322
349
  for key, value in limits.items():
323
350
  at = f"{where}.{key}"
324
351
  match = _LIMIT.match(key) if isinstance(key, str) else None
@@ -351,6 +378,12 @@ def validate_access_requests(where: str, requests: Any, resolve: ExportsResolver
351
378
  if not isinstance(entry, dict) or not isinstance(entry.get("object"), str):
352
379
  raise ValueError(f'{at} must be {{"object": "service:<name>", ...}}')
353
380
  obj = entry["object"]
381
+ if obj == APIKEY_OBJECT:
382
+ if obj in seen:
383
+ raise ValueError(f"{at}: {obj} is listed twice")
384
+ seen.add(obj)
385
+ _check_apikey_request(at, entry)
386
+ continue
354
387
  if not obj.startswith(SERVICE_PREFIX):
355
388
  raise ValueError(f"{at}: {obj!r} cannot be requested; name one service as 'service:<name>'")
356
389
  name = obj.removeprefix(SERVICE_PREFIX)
@@ -383,6 +416,16 @@ def validate_access_requests(where: str, requests: Any, resolve: ExportsResolver
383
416
  check_claim(at, obj, permissions, options, parse_exports(f"{obj} exports", declared))
384
417
 
385
418
 
419
+ def _check_apikey_request(at: str, entry: dict[str, Any]) -> None:
420
+ """The ``apikey`` request: how long a leased key should live, with why."""
421
+ if extra := set(entry) - _APIKEY_REQUEST_KEYS:
422
+ raise ValueError(f"{at}: unknown keys {sorted(extra)} (the {APIKEY_OBJECT} request takes only limits)")
423
+ justification = entry.get("justification")
424
+ if not isinstance(justification, str) or not justification.strip():
425
+ raise ValueError(f"{at}.justification must say why a leased key needs to live this long; the customer reads it")
426
+ validate_limits(f"{at}.limits", entry.get("limits"), apikey=True)
427
+
428
+
386
429
  def check_claim(where: str, obj: str, permissions: list[str], options: dict[str, str], exports: Exports) -> None:
387
430
  """Refuse a claim on ``obj`` (``service:<name>``) that its ``exports`` don't cover.
388
431
 
@@ -410,3 +453,84 @@ def validate_channel_access_requests(config: Any, resolve: ExportsResolver | Non
410
453
  for name, channel in config.items():
411
454
  if isinstance(channel, dict) and REQUESTS_KEY in channel:
412
455
  validate_access_requests(f"upstream_access_config.{name}.{REQUESTS_KEY}", channel[REQUESTS_KEY], resolve)
456
+
457
+
458
+ def build_policy(requests: Any, approvals: Any, resolve: ExportsResolver | None = None) -> list[dict[str, Any]]:
459
+ """The key policy a customer's ``approvals`` of a channel's ``requests``
460
+ make (unitysvc/unitysvc#2766).
461
+
462
+ ``requests`` is the channel's ``customer_access_requests``; ``approvals``
463
+ lists the requests the customer approves, each ``{"object", "permissions",
464
+ "options", "limits"}`` (the ``apikey`` entry only ``{"object", "limits"}``).
465
+ Each approval names a requested object, at most once; its permissions are
466
+ some of those requested; every ``required`` request is approved. The
467
+ customer sets options and limits: a request's own only pre-fill them, so
468
+ they are checked against the target's ``exports`` (with ``resolve``) and
469
+ :func:`validate_limits`, not against the request. A key lifespan may only
470
+ be shortened. Returns the approvals as policy entries, without empty
471
+ ``options`` / ``limits``. Raises ``ValueError``.
472
+ """
473
+ validate_access_requests("requests", requests)
474
+ requested = {entry["object"]: entry for entry in requests}
475
+ if not isinstance(approvals, list):
476
+ raise ValueError("approvals must be a list of approved requests")
477
+ policy: list[dict[str, Any]] = []
478
+ for index, approval in enumerate(approvals):
479
+ at = f"approvals[{index}]"
480
+ if not isinstance(approval, dict) or not isinstance(approval.get("object"), str):
481
+ raise ValueError(f'{at} must be {{"object": ..., "permissions": [...]}}')
482
+ obj = approval["object"]
483
+ request = requested.get(obj)
484
+ if request is None:
485
+ raise ValueError(f"{at}: {obj} is not requested")
486
+ if any(entry["object"] == obj for entry in policy):
487
+ raise ValueError(f"{at}: {obj} is approved twice")
488
+ if obj == APIKEY_OBJECT:
489
+ if extra := set(approval) - {"object", "limits"}:
490
+ raise ValueError(f"{at}: unknown keys {sorted(extra)} (the {APIKEY_OBJECT} entry takes only limits)")
491
+ validate_limits(f"{at}.limits", approval.get("limits"), apikey=True)
492
+ asked = request["limits"][API_KEY_LIFESPAN]
493
+ if approval["limits"][API_KEY_LIFESPAN] > asked:
494
+ raise ValueError(f"{at}.limits.{API_KEY_LIFESPAN}: the service asks for at most {asked} seconds")
495
+ policy.append({"object": obj, "limits": dict(approval["limits"])})
496
+ continue
497
+ if extra := set(approval) - _POLICY_KEYS:
498
+ raise ValueError(f"{at}: unknown keys {sorted(extra)}")
499
+ permissions: list[str] = approval.get("permissions") or []
500
+ if not _strings(permissions):
501
+ raise ValueError(f"{at}.permissions must be a non-empty list of distinct names")
502
+ if not set(permissions) <= set(request["permissions"]):
503
+ raise ValueError(f"{at}.permissions: the service requests only {request['permissions']}")
504
+ options_text = approval.get("options", "")
505
+ options = parse_options(f"{at}.options", options_text)
506
+ limits = approval.get("limits", {})
507
+ validate_limits(f"{at}.limits", limits)
508
+ if resolve is not None:
509
+ declared = resolve(obj.removeprefix(SERVICE_PREFIX))
510
+ if declared is None:
511
+ raise ValueError(f"{at}: {obj} is not a published service")
512
+ check_claim(at, obj, permissions, options, parse_exports(f"{obj} exports", declared))
513
+ entry: dict[str, Any] = {"object": obj, "permissions": list(permissions)}
514
+ if options:
515
+ entry["options"] = options_text
516
+ if limits:
517
+ entry["limits"] = dict(limits)
518
+ policy.append(entry)
519
+ approved = {entry["object"] for entry in policy}
520
+ if missing := sorted(obj for obj, request in requested.items() if request.get("required") and obj not in approved):
521
+ raise ValueError(f"approvals: the service requires {', '.join(missing)}")
522
+ return policy
523
+
524
+
525
+ def api_key_lifespan(policy: Any) -> int:
526
+ """Seconds a key leased with ``policy`` lives: its ``apikey`` entry's
527
+ ``api_key_lifespan``, else :data:`DEFAULT_API_KEY_LIFESPAN`. A malformed
528
+ entry gets the default, never more."""
529
+ for entry in policy if isinstance(policy, list) else []:
530
+ if isinstance(entry, dict) and entry.get("object") == APIKEY_OBJECT:
531
+ try:
532
+ validate_limits("apikey.limits", entry.get("limits"), apikey=True)
533
+ except ValueError:
534
+ return DEFAULT_API_KEY_LIFESPAN
535
+ return int(entry["limits"][API_KEY_LIFESPAN])
536
+ return DEFAULT_API_KEY_LIFESPAN
@@ -26,6 +26,21 @@ from .base import (
26
26
  TimeWindow,
27
27
  UpstreamStatusEnum, # Backwards compatibility alias for OfferingStatusEnum
28
28
  )
29
+ from .channel import (
30
+ RETIRED_CHANNEL_KEYS,
31
+ ChannelConfig,
32
+ ChannelParams,
33
+ HttpChannel,
34
+ HttpServer,
35
+ McpChannel,
36
+ McpServer,
37
+ RequestTransformer,
38
+ S3Channel,
39
+ S3Server,
40
+ SmtpChannel,
41
+ SmtpServer,
42
+ UpstreamAccessConfig,
43
+ )
29
44
  from .documents import DocumentData
30
45
  from .listing_data import ServiceListingData
31
46
  from .listing_v1 import ListingV1
@@ -104,6 +119,20 @@ __all__ = [
104
119
  "ListingV1",
105
120
  "PromotionV1",
106
121
  "ServiceGroupV1",
122
+ # Upstream access channels
123
+ "UpstreamAccessConfig",
124
+ "ChannelConfig",
125
+ "ChannelParams",
126
+ "HttpChannel",
127
+ "SmtpChannel",
128
+ "S3Channel",
129
+ "McpChannel",
130
+ "HttpServer",
131
+ "SmtpServer",
132
+ "S3Server",
133
+ "McpServer",
134
+ "RequestTransformer",
135
+ "RETIRED_CHANNEL_KEYS",
107
136
  # Data models (for API/backend use)
108
137
  "ProviderAccountRateLimit",
109
138
  "ProviderData",