offering-protocol 0.1.0__tar.gz → 0.1.1__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 (81) hide show
  1. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/PKG-INFO +5 -1
  2. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/README.md +4 -0
  3. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/pyproject.toml +1 -1
  4. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/agent/client.py +47 -8
  5. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/agent/details.py +1 -1
  6. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/__init__.py +2 -0
  7. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/models.py +1 -1
  8. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/validation.py +365 -1
  9. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/directory/client.py +38 -3
  10. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/.gitignore +0 -0
  11. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/LICENSE +0 -0
  12. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/__init__.py +0 -0
  13. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/agent/__init__.py +0 -0
  14. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/agent/agent.py +0 -0
  15. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/agent/cache.py +0 -0
  16. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/agent/capabilities.py +0 -0
  17. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/agent/schema.py +0 -0
  18. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/references.py +0 -0
  19. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/action-relation.schema.json +0 -0
  20. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/action-request.schema.json +0 -0
  21. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/action.schema.json +0 -0
  22. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/attribute-schema-reference.schema.json +0 -0
  23. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/authentication-requirement.schema.json +0 -0
  24. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/capability-identifier.schema.json +0 -0
  25. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/capability-link.schema.json +0 -0
  26. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/collection-search-request.schema.json +0 -0
  27. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/collection.schema.json +0 -0
  28. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/detail-fields.schema.json +0 -0
  29. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/enrollment-protocol.schema.json +0 -0
  30. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/filter-capability-source.schema.json +0 -0
  31. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/filter-definition-page.schema.json +0 -0
  32. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/filter-definition.schema.json +0 -0
  33. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/filter-expression.schema.json +0 -0
  34. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/filter-operator.schema.json +0 -0
  35. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/filter-type.schema.json +0 -0
  36. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/filter-unit.schema.json +0 -0
  37. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/http-action-target.schema.json +0 -0
  38. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/invalid-parameter.schema.json +0 -0
  39. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/local-resource-identifier-list.schema.json +0 -0
  40. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/local-resource-identifier.schema.json +0 -0
  41. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/mcp-endpoint.schema.json +0 -0
  42. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/offering-search-request.schema.json +0 -0
  43. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/offering-search-response.schema.json +0 -0
  44. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/offering.schema.json +0 -0
  45. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/openapi-action-target.schema.json +0 -0
  46. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/operation-descriptor.schema.json +0 -0
  47. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/page-envelope.schema.json +0 -0
  48. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/page-limit.schema.json +0 -0
  49. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/payment-option.schema.json +0 -0
  50. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/payment-protocol.schema.json +0 -0
  51. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/price-preview.schema.json +0 -0
  52. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/problem-code.schema.json +0 -0
  53. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/problem-details.schema.json +0 -0
  54. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/protocol-version.schema.json +0 -0
  55. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/refinement-bucket.schema.json +0 -0
  56. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/refinement-group.schema.json +0 -0
  57. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/representation.schema.json +0 -0
  58. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/resource-identity.schema.json +0 -0
  59. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/resource-image.schema.json +0 -0
  60. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/resource-reference.schema.json +0 -0
  61. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/schema-reference.schema.json +0 -0
  62. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/search-capabilities.schema.json +0 -0
  63. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/service-branding-image.schema.json +0 -0
  64. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/service-branding.schema.json +0 -0
  65. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/service-document.schema.json +0 -0
  66. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/service-openapi.schema.json +0 -0
  67. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/service-origin.schema.json +0 -0
  68. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/service-protocols.schema.json +0 -0
  69. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/sort-capability-source.schema.json +0 -0
  70. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/sort-definition-page.schema.json +0 -0
  71. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/sort-definition.schema.json +0 -0
  72. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/sort-key.schema.json +0 -0
  73. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/top-level-document.schema.json +0 -0
  74. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/core/schemas/trust-protocol.schema.json +0 -0
  75. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/directory/__init__.py +0 -0
  76. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/directory/models.py +0 -0
  77. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/directory/transport.py +0 -0
  78. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/py.typed +0 -0
  79. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/service/__init__.py +0 -0
  80. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/service/service.py +0 -0
  81. {offering_protocol-0.1.0 → offering_protocol-0.1.1}/src/offering_protocol/service/static_catalog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: offering-protocol
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: Official Python SDK for the Offering Discovery Protocol
5
5
  Project-URL: Documentation, https://www.offeringprotocol.org/
6
6
  Project-URL: Issues, https://github.com/offering-protocol/odp-python/issues
@@ -281,6 +281,10 @@ its Actions can advertise enrollment, payment, and trust protocols, but ODP does
281
281
  credentials, invoke Actions, submit payments, or implement trust protocols. Applications compose
282
282
  the appropriate protocol clients around an Action resolved through ODP.
283
283
 
284
+ `parse_service_document` is the strict current-version Service parser. Agent inspection and
285
+ Directory results filter unrecognized enrollment, payment, and trust descriptors while retaining
286
+ strict validation for recognized descriptors.
287
+
284
288
  ## Errors and validation
285
289
 
286
290
  Each role exposes typed errors:
@@ -252,6 +252,10 @@ its Actions can advertise enrollment, payment, and trust protocols, but ODP does
252
252
  credentials, invoke Actions, submit payments, or implement trust protocols. Applications compose
253
253
  the appropriate protocol clients around an Action resolved through ODP.
254
254
 
255
+ `parse_service_document` is the strict current-version Service parser. Agent inspection and
256
+ Directory results filter unrecognized enrollment, payment, and trust descriptors while retaining
257
+ strict validation for recognized descriptors.
258
+
255
259
  ## Errors and validation
256
260
 
257
261
  Each role exposes typed errors:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "offering-protocol"
7
- version = "0.1.0"
7
+ version = "0.1.1"
8
8
  description = "Official Python SDK for the Offering Discovery Protocol"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -21,18 +21,30 @@ from offering_protocol.core import (
21
21
  OfferingSearchRequest,
22
22
  Operation,
23
23
  Page,
24
+ ProblemDetails,
24
25
  Representation,
25
26
  ServiceDocument,
26
27
  build_operation_url,
27
28
  derive_service_origin,
28
- parse_collection,
29
- parse_collection_page,
30
- parse_offering,
31
- parse_offering_page,
32
- parse_problem_response,
33
- parse_service_document,
29
+ parse_agent_service_document,
34
30
  resolve_continuation,
35
31
  )
32
+ from offering_protocol.core import (
33
+ parse_collection as parse_collection_strict,
34
+ )
35
+ from offering_protocol.core import (
36
+ parse_collection_page as parse_collection_page_strict,
37
+ )
38
+ from offering_protocol.core import (
39
+ parse_offering as parse_offering_strict,
40
+ )
41
+ from offering_protocol.core import (
42
+ parse_offering_page as parse_offering_page_strict,
43
+ )
44
+ from offering_protocol.core import (
45
+ parse_problem_response as parse_problem_response_strict,
46
+ )
47
+ from offering_protocol.core.validation import _normalize_agent_response
36
48
  from offering_protocol.directory.transport import (
37
49
  HttpRequest,
38
50
  HttpResponse,
@@ -136,10 +148,10 @@ class ServiceClient:
136
148
  b"",
137
149
  _MAXIMUM_DOCUMENT_BYTES,
138
150
  self._cache_fallbacks.service_document,
139
- parse_service_document,
151
+ parse_agent_service_document,
140
152
  )
141
153
  return Inspection(
142
- document=parse_service_document(response.body),
154
+ document=parse_agent_service_document(response.body),
143
155
  final_url=response.final_url,
144
156
  freshness=response.freshness,
145
157
  requested_url=requested_url,
@@ -571,6 +583,33 @@ def _operation_parser(operation: Operation) -> object:
571
583
  return parse_offering_page
572
584
 
573
585
 
586
+ def parse_collection(data: bytes | str) -> Collection:
587
+ return parse_collection_strict(_normalize_body(data, "collection"))
588
+
589
+
590
+ def parse_offering(data: bytes | str) -> Offering:
591
+ return parse_offering_strict(_normalize_body(data, "offering"))
592
+
593
+
594
+ def parse_collection_page(data: bytes | str) -> Page[Collection]:
595
+ return parse_collection_page_strict(_normalize_body(data, "collection-page"))
596
+
597
+
598
+ def parse_offering_page(data: bytes | str) -> OfferingPage[Offering]:
599
+ return parse_offering_page_strict(_normalize_body(data, "offering-page"))
600
+
601
+
602
+ def parse_problem_response(data: bytes | str, status: int) -> ProblemDetails:
603
+ return parse_problem_response_strict(_normalize_body(data, "problem"), status)
604
+
605
+
606
+ def _normalize_body(data: bytes | str, kind: str) -> str:
607
+ raw = json.loads(data)
608
+ if not isinstance(raw, dict):
609
+ return data.decode() if isinstance(data, bytes) else data
610
+ return json.dumps(_normalize_agent_response(raw, kind), separators=(",", ":"))
611
+
612
+
574
613
  def _encode(value: object) -> bytes:
575
614
  if not hasattr(value, "model_dump_json"):
576
615
  raise TypeError("ODP model is not serializable")
@@ -53,7 +53,7 @@ class DiscoveredAction:
53
53
  http: DiscoveredHttpAction | None
54
54
  id: str
55
55
  openapi: DiscoveredOpenApiAction | None
56
- rel: ActionRelation
56
+ rel: ActionRelation | str
57
57
 
58
58
 
59
59
  @dataclass(frozen=True, slots=True)
@@ -16,6 +16,7 @@ from offering_protocol.core.references import (
16
16
  from offering_protocol.core.validation import (
17
17
  OdpValidationError,
18
18
  ValidationIssue,
19
+ parse_agent_service_document,
19
20
  parse_collection,
20
21
  parse_collection_page,
21
22
  parse_collection_search_request,
@@ -44,6 +45,7 @@ __all__ = [
44
45
  "is_local_resource_identifier",
45
46
  "operation_method",
46
47
  "operation_path",
48
+ "parse_agent_service_document",
47
49
  "parse_collection",
48
50
  "parse_collection_page",
49
51
  "parse_collection_search_request",
@@ -379,7 +379,7 @@ class Action(OdpModel):
379
379
  http: HttpActionTarget | None = None
380
380
  id: str
381
381
  openapi: OpenApiActionTarget | None = None
382
- rel: ActionRelation
382
+ rel: ActionRelation | str
383
383
 
384
384
 
385
385
  class Offering(OdpModel):
@@ -82,6 +82,363 @@ def parse_service_document(data: bytes | str) -> ServiceDocument:
82
82
  return value
83
83
 
84
84
 
85
+ def parse_agent_service_document(data: bytes | str) -> ServiceDocument:
86
+ try:
87
+ raw = json.loads(data)
88
+ except (UnicodeDecodeError, json.JSONDecodeError):
89
+ return parse_service_document(data)
90
+ if isinstance(raw, dict):
91
+ raw = _normalize_agent_response(raw, "service-document")
92
+ return parse_service_document(json.dumps(raw, separators=(",", ":")))
93
+
94
+
95
+ def _normalize_agent_response(value: dict[str, Any], kind: str) -> dict[str, Any]:
96
+ normalized = dict(value)
97
+ if kind == "service-document":
98
+ _filter_agent_protocols(normalized)
99
+ protocols = normalized.get("protocols")
100
+ if isinstance(protocols, dict):
101
+ _filter_unknown_authentication(protocols, "payments")
102
+ _filter_named_list(
103
+ normalized,
104
+ "operations",
105
+ {
106
+ "get-collection",
107
+ "get-offering",
108
+ "list-collection-offerings",
109
+ "list-collections",
110
+ "list-offerings",
111
+ "search-collections",
112
+ "search-offerings",
113
+ },
114
+ )
115
+ _filter_unknown_authentication(normalized, "operations")
116
+ _filter_typed_list(normalized, "mcp", {"streamable-http"})
117
+ _filter_closed_object_list(normalized, "operations", {"authentication", "name"})
118
+ _filter_closed_object_list(normalized, "mcp", {"description", "name", "type", "url"})
119
+ _filter_payment_options(normalized)
120
+ _normalize_branding(normalized)
121
+ _normalize_search_capabilities(normalized)
122
+ elif kind in {"collection", "offering"}:
123
+ _filter_typed_list(
124
+ normalized,
125
+ "images",
126
+ {"image/avif", "image/jpeg", "image/png", "image/svg+xml", "image/webp"},
127
+ )
128
+ _strip_object_list(normalized, "images", {"alt", "height", "src", "type", "width"})
129
+ _normalize_search_capabilities(normalized)
130
+ if kind == "offering":
131
+ _normalize_offering(normalized)
132
+ elif kind in {"collection-page", "offering-page"} and isinstance(normalized.get("items"), list):
133
+ item_kind = "offering" if kind == "offering-page" else "collection"
134
+ normalized["items"] = [
135
+ _normalize_agent_response(item, item_kind) if isinstance(item, dict) else item
136
+ for item in normalized["items"]
137
+ ]
138
+ elif kind in {"filter-page", "sort-page"} and isinstance(normalized.get("items"), list):
139
+ predicate = _known_filter if kind == "filter-page" else _known_sort
140
+ normalized["items"] = [item for item in normalized["items"] if predicate(item)]
141
+ elif kind == "problem" and isinstance(normalized.get("invalid_params"), list):
142
+ normalized["invalid_params"] = [
143
+ item
144
+ for item in normalized["invalid_params"]
145
+ if not (
146
+ isinstance(item, dict)
147
+ and isinstance(item.get("in"), str)
148
+ and item["in"] not in {"body", "header", "path", "query"}
149
+ )
150
+ ]
151
+ return normalized
152
+
153
+
154
+ def _filter_named_list(value: dict[str, Any], member: str, recognized: set[str]) -> None:
155
+ items = value.get(member)
156
+ if not isinstance(items, list):
157
+ return
158
+ filtered = [
159
+ item
160
+ for item in items
161
+ if not (
162
+ isinstance(item, dict)
163
+ and isinstance(item.get("name"), str)
164
+ and item["name"] not in recognized
165
+ )
166
+ ]
167
+ if filtered:
168
+ value[member] = filtered
169
+ else:
170
+ value.pop(member, None)
171
+
172
+
173
+ def _filter_typed_list(value: dict[str, Any], member: str, recognized: set[str]) -> None:
174
+ items = value.get(member)
175
+ if not isinstance(items, list):
176
+ return
177
+ filtered = [
178
+ item
179
+ for item in items
180
+ if not (
181
+ isinstance(item, dict)
182
+ and isinstance(item.get("type"), str)
183
+ and item["type"] not in recognized
184
+ )
185
+ ]
186
+ if filtered:
187
+ value[member] = filtered
188
+ else:
189
+ value.pop(member, None)
190
+
191
+
192
+ def _filter_closed_object_list(value: dict[str, Any], member: str, allowed: set[str]) -> None:
193
+ items = value.get(member)
194
+ if not isinstance(items, list):
195
+ return
196
+ filtered = [
197
+ item for item in items if not isinstance(item, dict) or not set(item).difference(allowed)
198
+ ]
199
+ if filtered:
200
+ value[member] = filtered
201
+ else:
202
+ value.pop(member, None)
203
+
204
+
205
+ def _filter_unknown_authentication(value: dict[str, Any], member: str) -> None:
206
+ items = value.get(member)
207
+ if not isinstance(items, list):
208
+ return
209
+ filtered = [
210
+ item
211
+ for item in items
212
+ if not isinstance(item, dict) or not _has_unknown_authentication(item)
213
+ ]
214
+ if filtered:
215
+ value[member] = filtered
216
+ else:
217
+ value.pop(member, None)
218
+
219
+
220
+ def _has_unknown_authentication(value: dict[str, Any]) -> bool:
221
+ authentication = value.get("authentication")
222
+ return isinstance(authentication, str) and authentication not in {
223
+ "not-required",
224
+ "optional",
225
+ "required",
226
+ }
227
+
228
+
229
+ def _strip_object_list(value: dict[str, Any], member: str, allowed: set[str]) -> None:
230
+ items = value.get(member)
231
+ if not isinstance(items, list):
232
+ return
233
+ value[member] = [
234
+ {key: entry for key, entry in item.items() if key in allowed}
235
+ if isinstance(item, dict)
236
+ else item
237
+ for item in items
238
+ ]
239
+
240
+
241
+ def _filter_payment_options(value: dict[str, Any]) -> None:
242
+ protocols = value.get("protocols")
243
+ if not isinstance(protocols, dict) or not isinstance(protocols.get("payments"), list):
244
+ return
245
+ recognized = {
246
+ "algorand",
247
+ "aptos",
248
+ "arbitrum",
249
+ "avalanche",
250
+ "base",
251
+ "card",
252
+ "ethereum",
253
+ "hedera",
254
+ "inflow",
255
+ "lightning",
256
+ "polygon",
257
+ "solana",
258
+ "stellar",
259
+ "stripe",
260
+ "tempo",
261
+ "ton",
262
+ }
263
+ for payment in protocols["payments"]:
264
+ if not isinstance(payment, dict) or not isinstance(payment.get("options"), list):
265
+ continue
266
+ options = [
267
+ option
268
+ for option in payment["options"]
269
+ if not isinstance(option, str) or option in recognized
270
+ ]
271
+ if options:
272
+ payment["options"] = options
273
+ else:
274
+ payment.pop("options", None)
275
+
276
+
277
+ def _normalize_branding(value: dict[str, Any]) -> None:
278
+ branding = value.get("branding")
279
+ if not isinstance(branding, dict):
280
+ return
281
+ normalized = {key: entry for key, entry in branding.items() if key in {"icon", "logo"}}
282
+ for member in ("icon", "logo"):
283
+ image = normalized.get(member)
284
+ if (
285
+ isinstance(image, dict)
286
+ and isinstance(image.get("type"), str)
287
+ and image["type"] not in {"image/png", "image/svg+xml", "image/webp"}
288
+ ):
289
+ normalized.pop(member, None)
290
+ elif isinstance(image, dict):
291
+ normalized[member] = {
292
+ key: entry for key, entry in image.items() if key in {"src", "type"}
293
+ }
294
+ if normalized:
295
+ value["branding"] = normalized
296
+ else:
297
+ value.pop("branding", None)
298
+
299
+
300
+ def _normalize_search_capabilities(value: dict[str, Any]) -> None:
301
+ capabilities = value.get("search_capabilities")
302
+ if not isinstance(capabilities, dict):
303
+ return
304
+ normalized = dict(capabilities)
305
+ for member, predicate in (("filters", _known_filter), ("sorts", _known_sort)):
306
+ source = normalized.get(member)
307
+ if not isinstance(source, dict) or not isinstance(source.get("inline"), list):
308
+ continue
309
+ items = [item for item in source["inline"] if predicate(item)]
310
+ if items:
311
+ normalized[member] = {**source, "inline": items}
312
+ else:
313
+ normalized.pop(member, None)
314
+ if normalized:
315
+ value["search_capabilities"] = normalized
316
+ else:
317
+ value.pop("search_capabilities", None)
318
+
319
+
320
+ def _normalize_offering(value: dict[str, Any]) -> None:
321
+ schema = value.get("schema")
322
+ if isinstance(schema, dict) and set(schema).difference({"url"}):
323
+ value.pop("schema", None)
324
+ price = value.get("price")
325
+ if (
326
+ isinstance(price, dict)
327
+ and isinstance(price.get("type"), str)
328
+ and price["type"] not in {"fixed", "free", "metered", "quote", "range", "starting_at"}
329
+ ):
330
+ value.pop("price", None)
331
+ actions = value.get("actions")
332
+ if not isinstance(actions, list):
333
+ return
334
+ filtered = []
335
+ for action in actions:
336
+ if isinstance(action, dict) and _has_unknown_authentication(action):
337
+ continue
338
+ if isinstance(action, dict) and set(action).difference(
339
+ {"authentication", "description", "http", "id", "openapi", "rel"}
340
+ ):
341
+ continue
342
+ http = action.get("http") if isinstance(action, dict) else None
343
+ if isinstance(http, dict) and set(http).difference(
344
+ {"href", "method", "request", "response_content_types"}
345
+ ):
346
+ continue
347
+ request = http.get("request") if isinstance(http, dict) else None
348
+ if isinstance(request, dict) and set(request).difference({"content_type", "schema"}):
349
+ continue
350
+ schema = request.get("schema") if isinstance(request, dict) else None
351
+ if isinstance(schema, dict) and set(schema).difference({"url"}):
352
+ continue
353
+ openapi = action.get("openapi") if isinstance(action, dict) else None
354
+ if isinstance(openapi, dict) and set(openapi).difference({"operation_id", "url"}):
355
+ continue
356
+ method = http.get("method") if isinstance(http, dict) else None
357
+ if isinstance(method, str) and method not in {"GET", "POST"}:
358
+ continue
359
+ filtered.append(action)
360
+ if filtered:
361
+ value["actions"] = filtered
362
+ else:
363
+ value.pop("actions", None)
364
+
365
+
366
+ def _known_filter(value: Any) -> bool:
367
+ if not isinstance(value, dict):
368
+ return True
369
+ if isinstance(value.get("type"), str) and value["type"] not in {
370
+ "boolean",
371
+ "date",
372
+ "date-time",
373
+ "decimal",
374
+ "integer",
375
+ "number",
376
+ "string",
377
+ }:
378
+ return False
379
+ operators = value.get("operators")
380
+ if isinstance(operators, list) and any(
381
+ isinstance(operator, str)
382
+ and operator not in {"eq", "exists", "gt", "gte", "in", "lt", "lte"}
383
+ for operator in operators
384
+ ):
385
+ return False
386
+ unit = value.get("unit")
387
+ return not (
388
+ isinstance(unit, dict)
389
+ and isinstance(unit.get("system"), str)
390
+ and unit["system"] not in {"service", "ucum"}
391
+ )
392
+
393
+
394
+ def _known_sort(value: Any) -> bool:
395
+ if not isinstance(value, dict) or not isinstance(value.get("keys"), list):
396
+ return True
397
+ return not any(
398
+ isinstance(key, dict)
399
+ and (
400
+ (
401
+ isinstance(key.get("direction"), str)
402
+ and key["direction"] not in {"ascending", "descending"}
403
+ )
404
+ or (isinstance(key.get("missing"), str) and key["missing"] not in {"first", "last"})
405
+ )
406
+ for key in value["keys"]
407
+ )
408
+
409
+
410
+ def _filter_agent_protocols(document: dict[str, Any]) -> None:
411
+ protocols = document.get("protocols")
412
+ if not isinstance(protocols, dict):
413
+ return
414
+ _filter_agent_protocol_category(protocols, "enrollment", {"aep"})
415
+ _filter_agent_protocol_category(protocols, "payments", {"mpp", "x402"})
416
+ _filter_agent_protocol_category(protocols, "trust", {"tap"})
417
+ if not protocols:
418
+ document.pop("protocols")
419
+
420
+
421
+ def _filter_agent_protocol_category(
422
+ protocols: dict[str, Any], category: str, recognized: set[str]
423
+ ) -> None:
424
+ descriptors = protocols.get(category)
425
+ if not isinstance(descriptors, list):
426
+ return
427
+ filtered = [
428
+ descriptor
429
+ for descriptor in descriptors
430
+ if not (
431
+ isinstance(descriptor, dict)
432
+ and isinstance(descriptor.get("name"), str)
433
+ and descriptor["name"] not in recognized
434
+ )
435
+ ]
436
+ if descriptors and not filtered:
437
+ protocols.pop(category)
438
+ else:
439
+ protocols[category] = filtered
440
+
441
+
85
442
  def parse_collection(data: bytes | str) -> Collection:
86
443
  value = _parse(data, "collection.schema.json", "Collection", Collection)
87
444
  _validate_representation(
@@ -99,7 +456,14 @@ def parse_offering(data: bytes | str) -> Offering:
99
456
 
100
457
 
101
458
  def parse_problem_details(data: bytes | str) -> ProblemDetails:
102
- return _parse(data, "problem-details.schema.json", "Problem Details", ProblemDetails)
459
+ value = _parse(data, "problem-details.schema.json", "Problem Details", ProblemDetails)
460
+ expected_type = "https://offeringprotocol.org/problems/" + value.code.lower().replace("_", "-")
461
+ if value.problem_type != expected_type:
462
+ raise OdpValidationError(
463
+ "Problem Details",
464
+ [_issue("/type", "problem-type", "must correspond to the problem code")],
465
+ )
466
+ return value
103
467
 
104
468
 
105
469
  def parse_problem_response(data: bytes | str, http_status: int) -> ProblemDetails:
@@ -7,7 +7,11 @@ from urllib.parse import urlencode, urljoin
7
7
 
8
8
  from pydantic import ValidationError as ModelValidationError
9
9
 
10
- from offering_protocol.core import derive_service_origin
10
+ from offering_protocol.core import (
11
+ OdpValidationError,
12
+ derive_service_origin,
13
+ parse_agent_service_document,
14
+ )
11
15
  from offering_protocol.directory.models import (
12
16
  DirectoryService,
13
17
  Environment,
@@ -159,8 +163,18 @@ class DirectoryClient:
159
163
 
160
164
  def _parse_search_page(body: bytes) -> SearchPage:
161
165
  try:
162
- page = SearchPage.model_validate_json(body)
163
- except ModelValidationError as error:
166
+ raw = json.loads(body)
167
+ if isinstance(raw, dict) and isinstance(raw.get("items"), list):
168
+ for item in raw["items"]:
169
+ if isinstance(item, dict) and "protocols" in item:
170
+ _normalize_service_protocols(item)
171
+ page = SearchPage.model_validate(raw)
172
+ except (
173
+ UnicodeDecodeError,
174
+ json.JSONDecodeError,
175
+ ModelValidationError,
176
+ OdpValidationError,
177
+ ) as error:
164
178
  raise DirectoryError(f"invalid Directory response: {error}") from error
165
179
  if len(page.items) > 100:
166
180
  raise DirectoryError("Directory search page exceeds 100 Services")
@@ -170,6 +184,27 @@ def _parse_search_page(body: bytes) -> SearchPage:
170
184
  return page
171
185
 
172
186
 
187
+ def _normalize_service_protocols(item: dict[str, object]) -> None:
188
+ candidate = {
189
+ "description": "Directory protocol validation",
190
+ "http": {"endpoint_base": "/"},
191
+ "language": "en",
192
+ "localizations": ["en"],
193
+ "name": "Directory Service",
194
+ "odp_version": "1.0",
195
+ "operations": [
196
+ {"authentication": "not-required", "name": "get-offering"},
197
+ {"authentication": "not-required", "name": "list-offerings"},
198
+ ],
199
+ "protocols": item["protocols"],
200
+ }
201
+ document = parse_agent_service_document(json.dumps(candidate, separators=(",", ":")))
202
+ if document.protocols is None:
203
+ item.pop("protocols")
204
+ else:
205
+ item["protocols"] = document.protocols.model_dump(mode="json", exclude_defaults=True)
206
+
207
+
173
208
  def _validate_search_request(request: SearchRequest) -> None:
174
209
  if request.limit > 100:
175
210
  raise DirectoryError("limit must be from 1 through 100")