keywright-server 0.0.1__py3-none-any.whl

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 (127) hide show
  1. keywright_server/__init__.py +3 -0
  2. keywright_server/__main__.py +71 -0
  3. keywright_server/access.py +131 -0
  4. keywright_server/access_view.py +144 -0
  5. keywright_server/actions.py +30 -0
  6. keywright_server/approvals.py +433 -0
  7. keywright_server/audit.py +58 -0
  8. keywright_server/bundles.py +87 -0
  9. keywright_server/config.py +102 -0
  10. keywright_server/db.py +119 -0
  11. keywright_server/discovery.py +277 -0
  12. keywright_server/events.py +78 -0
  13. keywright_server/friction.py +242 -0
  14. keywright_server/integrations.py +64 -0
  15. keywright_server/inventory.py +415 -0
  16. keywright_server/inventory_mcp.py +288 -0
  17. keywright_server/jev.py +80 -0
  18. keywright_server/knowledge.py +403 -0
  19. keywright_server/knowledge_chunking.py +118 -0
  20. keywright_server/knowledge_index.py +233 -0
  21. keywright_server/knowledge_mcp.py +82 -0
  22. keywright_server/llm.py +338 -0
  23. keywright_server/llm_proxy.py +271 -0
  24. keywright_server/llm_usage.py +99 -0
  25. keywright_server/mail.py +312 -0
  26. keywright_server/mcp_oauth.py +422 -0
  27. keywright_server/mcp_proxy.py +128 -0
  28. keywright_server/mcp_server.py +1025 -0
  29. keywright_server/models.py +442 -0
  30. keywright_server/network_map.py +128 -0
  31. keywright_server/notify/__init__.py +8 -0
  32. keywright_server/notify/base.py +156 -0
  33. keywright_server/notify/homeassistant.py +221 -0
  34. keywright_server/notify/log.py +30 -0
  35. keywright_server/notify/manager.py +131 -0
  36. keywright_server/notify/webhook.py +79 -0
  37. keywright_server/nudges.py +121 -0
  38. keywright_server/omada.py +179 -0
  39. keywright_server/policy.py +265 -0
  40. keywright_server/policy_preview.py +151 -0
  41. keywright_server/reconciliation.py +188 -0
  42. keywright_server/result_inspection.py +293 -0
  43. keywright_server/semantic.py +451 -0
  44. keywright_server/semantic_questions.json +120 -0
  45. keywright_server/settings_store.py +44 -0
  46. keywright_server/ssh.py +290 -0
  47. keywright_server/tool_access.py +260 -0
  48. keywright_server/usage.py +121 -0
  49. keywright_server/web/__init__.py +0 -0
  50. keywright_server/web/admin.py +1126 -0
  51. keywright_server/web/app.py +154 -0
  52. keywright_server/web/auth.py +144 -0
  53. keywright_server/web/discovery_ui.py +112 -0
  54. keywright_server/web/friction_ui.py +92 -0
  55. keywright_server/web/inventory_ui.py +192 -0
  56. keywright_server/web/knowledge_ui.py +177 -0
  57. keywright_server/web/mail_ui.py +190 -0
  58. keywright_server/web/mcp_oauth_ui.py +87 -0
  59. keywright_server/web/network_map_ui.py +37 -0
  60. keywright_server/web/oidc.py +231 -0
  61. keywright_server/web/security.py +77 -0
  62. keywright_server/web/templates/_activity_row.html +25 -0
  63. keywright_server/web/templates/_agent_setup.html +50 -0
  64. keywright_server/web/templates/_collections_style.html +88 -0
  65. keywright_server/web/templates/_constraints.html +6 -0
  66. keywright_server/web/templates/_decide.html +39 -0
  67. keywright_server/web/templates/_device_kind_picker.html +66 -0
  68. keywright_server/web/templates/_hazards.html +6 -0
  69. keywright_server/web/templates/_icons.html +19 -0
  70. keywright_server/web/templates/_infrastructure_nav.html +8 -0
  71. keywright_server/web/templates/_integration_style.html +30 -0
  72. keywright_server/web/templates/_inventory_forms.html +36 -0
  73. keywright_server/web/templates/_inventory_style.html +36 -0
  74. keywright_server/web/templates/_live.html +10 -0
  75. keywright_server/web/templates/_mail_nav.html +5 -0
  76. keywright_server/web/templates/_mail_style.html +10 -0
  77. keywright_server/web/templates/_network_map_script.html +5 -0
  78. keywright_server/web/templates/_nudge_card.html +12 -0
  79. keywright_server/web/templates/_patterns.html +6 -0
  80. keywright_server/web/templates/_policy_preview_results.html +13 -0
  81. keywright_server/web/templates/_request_card.html +46 -0
  82. keywright_server/web/templates/_semantic.html +10 -0
  83. keywright_server/web/templates/_topology_controller.js +217 -0
  84. keywright_server/web/templates/_topology_layout.js +174 -0
  85. keywright_server/web/templates/_topology_render.js +117 -0
  86. keywright_server/web/templates/access.html +119 -0
  87. keywright_server/web/templates/agents.html +49 -0
  88. keywright_server/web/templates/audit.html +6 -0
  89. keywright_server/web/templates/base.html +198 -0
  90. keywright_server/web/templates/dashboard.html +226 -0
  91. keywright_server/web/templates/decision.html +26 -0
  92. keywright_server/web/templates/friction.html +36 -0
  93. keywright_server/web/templates/friction_review.html +21 -0
  94. keywright_server/web/templates/grants.html +61 -0
  95. keywright_server/web/templates/integration_picker.html +47 -0
  96. keywright_server/web/templates/inventory.html +21 -0
  97. keywright_server/web/templates/inventory_asset.html +42 -0
  98. keywright_server/web/templates/inventory_discovery.html +32 -0
  99. keywright_server/web/templates/inventory_discovery_review.html +34 -0
  100. keywright_server/web/templates/inventory_map.html +45 -0
  101. keywright_server/web/templates/inventory_networks.html +12 -0
  102. keywright_server/web/templates/inventory_subnet.html +41 -0
  103. keywright_server/web/templates/inventory_topology.html +22 -0
  104. keywright_server/web/templates/knowledge.html +25 -0
  105. keywright_server/web/templates/knowledge_document.html +7 -0
  106. keywright_server/web/templates/knowledge_proposal.html +7 -0
  107. keywright_server/web/templates/login.html +9 -0
  108. keywright_server/web/templates/mail.html +45 -0
  109. keywright_server/web/templates/mail_rules.html +24 -0
  110. keywright_server/web/templates/mail_settings.html +19 -0
  111. keywright_server/web/templates/mcp_oauth_continue.html +14 -0
  112. keywright_server/web/templates/policies.html +30 -0
  113. keywright_server/web/templates/request_detail.html +27 -0
  114. keywright_server/web/templates/requests.html +9 -0
  115. keywright_server/web/templates/resource_form.html +198 -0
  116. keywright_server/web/templates/resources.html +76 -0
  117. keywright_server/web/templates/settings.html +53 -0
  118. keywright_server/web/templates/setup.html +8 -0
  119. keywright_server/web/templates/usage.html +84 -0
  120. keywright_server/web/usage_ui.py +41 -0
  121. keywright_server-0.0.1.dist-info/METADATA +152 -0
  122. keywright_server-0.0.1.dist-info/RECORD +127 -0
  123. keywright_server-0.0.1.dist-info/WHEEL +4 -0
  124. keywright_server-0.0.1.dist-info/entry_points.txt +2 -0
  125. keywright_server-0.0.1.dist-info/licenses/LICENSE +202 -0
  126. keywright_server-0.0.1.dist-info/licenses/NOTICE +5 -0
  127. keywright_server-0.0.1.dist-info/licenses/THIRD_PARTY_NOTICES +55 -0
@@ -0,0 +1,288 @@
1
+ """Local inventory tools. The MCP broker gates calls before entering this module.
2
+
3
+ Writes use the caller's database transaction so the inventory and its audit event commit
4
+ atomically. Resource credentials and access settings are never serialized or changed here.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import json
10
+ from datetime import datetime
11
+ from typing import Annotated, Literal
12
+
13
+ import mcp_types as types
14
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
15
+ from sqlalchemy import select
16
+
17
+ from . import inventory as inv
18
+ from .models import DiscoveryLink, DiscoveryObservation
19
+
20
+ Text = Annotated[str, Field(max_length=160)]
21
+ Identifier = Annotated[str, Field(max_length=160)]
22
+ Kind = Literal[*inv.KINDS]
23
+ Medium = Literal['unknown', 'wired', 'wifi', 'virtual']
24
+ State = Literal['assigned', 'reserved', 'planned', 'excluded']
25
+
26
+
27
+ class Model(BaseModel):
28
+ model_config = ConfigDict(extra='forbid', strict=True)
29
+
30
+
31
+ class Page(Model):
32
+ offset: int = Field(default=0, ge=0)
33
+ limit: int = Field(default=100, ge=1, le=500)
34
+
35
+
36
+ class InventoryQuery(Page):
37
+ query: Text = ''
38
+ asset_id: Identifier = ''
39
+
40
+
41
+ class IPQuery(Page):
42
+ subnet_id: Identifier = ''
43
+ asset_id: Identifier = ''
44
+ address: Text = ''
45
+
46
+
47
+ class DiscoveryQuery(Page):
48
+ resource_id: Identifier = ''
49
+ query: Text = ''
50
+
51
+
52
+ class Asset(Model):
53
+ id: Identifier = ''
54
+ name: Text = ''
55
+ kind: Kind = 'physical'
56
+ parent_id: Identifier = ''
57
+ site: Text = ''
58
+ tags: Annotated[str, Field(max_length=500)] = ''
59
+ notes: Annotated[str, Field(max_length=4000)] = ''
60
+
61
+
62
+ class Allocation(Model):
63
+ id: Identifier = ''
64
+ subnet_id: Identifier
65
+ address: Text
66
+ state: State = 'reserved'
67
+ interface_id: Identifier = ''
68
+ label: Text = ''
69
+ notes: Annotated[str, Field(max_length=4000)] = ''
70
+
71
+
72
+ class Interface(Model):
73
+ id: Identifier = ''
74
+ name: Text = ''
75
+ medium: Medium = 'unknown'
76
+ mac: Text = ''
77
+ attached_to_id: Identifier = ''
78
+ port: Text = ''
79
+ allocations: list[Allocation] = Field(default_factory=list, max_length=20)
80
+
81
+
82
+ class Write(Model):
83
+ expected_revision: str = Field(pattern=r'^[a-f0-9]{64}$', description=
84
+ 'Inventory revision from a read or successful write. A concurrent change rejects this call; read again.')
85
+
86
+
87
+ class HostSetup(Write):
88
+ asset: Asset
89
+ interfaces: list[Interface] = Field(default_factory=list, max_length=20)
90
+ resource_ids: list[Identifier] = Field(default_factory=list, max_length=20)
91
+
92
+
93
+ class Subnet(Model):
94
+ id: Identifier = ''
95
+ name: Text = ''
96
+ cidr: Text = ''
97
+ scope: Text = 'default'
98
+ site: Text = ''
99
+ vlan: Text = ''
100
+ gateway: Text = ''
101
+ dhcp_start: Text = ''
102
+ dhcp_end: Text = ''
103
+ notes: Annotated[str, Field(max_length=4000)] = ''
104
+
105
+
106
+ class SubnetSave(Write):
107
+ subnet: Subnet
108
+
109
+
110
+ class IPSave(Write):
111
+ allocation: Allocation
112
+
113
+
114
+ OPERATIONS = {
115
+ 'list_inventory': (InventoryQuery, True, ('Find inventory hosts, VMs, containers and services, their interfaces, '
116
+ 'IP allocations and linked resource IDs. Includes resource names/types, never connection credentials. '
117
+ 'Use query for name, IP or resource name, or asset_id for one record. Returns an inventory revision.')),
118
+ 'get_topology': (InventoryQuery, True, ('Read hosting and network attachment edges for inventory records. '
119
+ 'Paginated by asset; edges can reference assets on other pages. Returns an inventory revision.')),
120
+ 'list_subnets': (Page, True, ('List recorded subnets, gateways, DHCP pools, allocations and unrecorded ranges. '
121
+ 'Unrecorded ranges are candidates, not proof that an IP is unused on the network.')),
122
+ 'list_ips': (IPQuery, True, ('List recorded IP allocations and their interface/host ownership, optionally '
123
+ 'filtered by subnet_id, asset_id or exact address. These are inventory records, not a live IP scan.')),
124
+ 'list_discovery': (DiscoveryQuery, True, ('Read cached discovery observations with source, last-seen time and '
125
+ 'existing inventory links. Observations are evidence, not confirmed IP reservations. Does not refresh sources.')),
126
+ 'setup_host': (HostSetup, False, ('Create or update an inventory host, VM, container or service, its parent, '
127
+ 'interfaces, IP allocations and links to existing resources in one atomic operation. Use IDs to update; '
128
+ 'omitted fields on existing records are preserved. An interface allocation is tied to that interface. '
129
+ 'Use state=assigned for a confirmed assigned IP, reserved for a reservation. Resource links do not grant '
130
+ 'access. This records topology; use the brokered SSH tools to configure machines or deploy software. '
131
+ 'Read inventory first and supply expected_revision. Errors make no inventory changes.')),
132
+ 'save_subnet': (SubnetSave, False, ('Create or update a subnet and its gateway, DHCP pool and address space. '
133
+ 'Use subnet.id to update; omitted fields are preserved. Overlapping subnets in one scope are rejected. '
134
+ 'Read inventory first and supply expected_revision. Records inventory; does not configure a router.')),
135
+ 'save_ip': (IPSave, False, ('Reserve, plan or assign an explicit IP in a recorded subnet, or update an '
136
+ 'allocation by ID. Assigned IPs require interface_id; otherwise provide an ownership label. '
137
+ 'Duplicate allocations and gateway conflicts are rejected. Supply expected_revision from a read. '
138
+ 'This records inventory; it does not configure DHCP or the host.')),
139
+ }
140
+ READ_TOOLS = [name for name, (_, read, _) in OPERATIONS.items() if read]
141
+ WRITE_TOOLS = [name for name, (_, read, _) in OPERATIONS.items() if not read]
142
+ DEFAULT_BUNDLES = {'read-only': ['tools/' + n for n in READ_TOOLS],
143
+ 'inventory-write': ['tools/' + n for n in WRITE_TOOLS]}
144
+
145
+
146
+ def list_tools():
147
+ return [types.Tool(name=name, description=description, input_schema=model.model_json_schema(),
148
+ annotations=types.ToolAnnotations(read_only_hint=read, destructive_hint=False,
149
+ idempotent_hint=True))
150
+ for name, (model, read, description) in OPERATIONS.items()]
151
+
152
+
153
+ def validate(tool, arguments):
154
+ if tool not in OPERATIONS:
155
+ raise inv.InventoryError('Unknown Keywright inventory tool.')
156
+ try:
157
+ return OPERATIONS[tool][0].model_validate(arguments)
158
+ except ValidationError as exc:
159
+ fields = ', '.join('.'.join(map(str, e['loc'])) for e in exc.errors(include_input=False))
160
+ raise inv.InventoryError(f'Invalid inventory arguments: {fields}. Use the tool schema.') from None
161
+
162
+
163
+ def row_values(row):
164
+ return {c.name: v.isoformat() if isinstance(v, datetime) else v
165
+ for c in row.__table__.columns for v in [getattr(row, c.name)]}
166
+
167
+
168
+ def public_resource(row):
169
+ return {'id': row.id, 'name': row.name, 'kind': row.kind}
170
+
171
+
172
+ def revision(data):
173
+ records = {k: sorted((row_values(r) for r in data[k]), key=lambda r: json.dumps(r, sort_keys=True))
174
+ for k in ('assets', 'interfaces', 'subnets', 'allocations', 'links')}
175
+ records['resources'] = sorted((public_resource(r) for r in data['resources']), key=lambda r: r['id'])
176
+ return hashlib.sha256(json.dumps(records, sort_keys=True).encode()).hexdigest()
177
+
178
+
179
+ def page(rows, args):
180
+ end = args.offset + args.limit
181
+ return {'rows': rows[args.offset:end], 'total': len(rows), 'offset': args.offset,
182
+ 'next_offset': end if end < len(rows) else None}
183
+
184
+
185
+ def allocation_values(row, data):
186
+ iface = data['interface_map'].get(row.interface_id)
187
+ asset = data['asset_map'].get(iface.asset_id) if iface else None
188
+ subnet = data['subnet_map'][row.subnet_id]
189
+ return {**row_values(row), 'asset_id': asset.id if asset else None, 'asset_name': asset.name if asset else None,
190
+ 'interface_name': iface.name if iface else None, 'subnet_name': subnet.name, 'scope': subnet.scope}
191
+
192
+
193
+ async def read(db, tool, args, data):
194
+ if tool in ('list_inventory', 'get_topology'):
195
+ rows = [r for r in inv.asset_rows(data, args.query) if not args.asset_id or r['asset'].id == args.asset_id]
196
+ result = page(rows, args)
197
+ if tool == 'list_inventory':
198
+ result['rows'] = [{**row_values(r['asset']), 'interfaces': [row_values(i) for i in r['interfaces']],
199
+ 'allocations': [allocation_values(a, data) for a in r['allocations']],
200
+ 'resources': [public_resource(res) for res in r['resources']]} for r in result['rows']]
201
+ result['resources'] = [public_resource(r) for r in data['resources'][:500]]
202
+ result['resources_total'] = len(data['resources'])
203
+ else:
204
+ edges = []
205
+ for r in result['rows']:
206
+ a = r['asset']
207
+ if a.parent_id:
208
+ edges.append({'type': 'hosted_on', 'from_asset_id': a.id, 'to_asset_id': a.parent_id})
209
+ edges += [{'type': 'connected_to', 'from_asset_id': a.id, 'to_asset_id': i.attached_to_id,
210
+ 'interface_id': i.id, 'medium': i.medium, 'port': i.port}
211
+ for i in r['interfaces'] if i.attached_to_id]
212
+ result['rows'] = [row_values(r['asset']) for r in result['rows']]
213
+ result['edges'] = edges
214
+ return result
215
+ if tool == 'list_subnets':
216
+ result = page(data['subnets'], args)
217
+ result['rows'] = [{**row_values(s), **{k: v for k, v in inv.subnet_summary(s, data['allocations']).items()
218
+ if k != 'rows'}} for s in result['rows']]
219
+ result['note'] = 'Unrecorded ranges exclude the gateway, DHCP pool and recorded allocations; verify before use.'
220
+ return result
221
+ if tool == 'list_ips':
222
+ address = str(inv.address(args.address)) if args.address else ''
223
+ rows = [allocation_values(a, data) for a in sorted(data['allocations'], key=lambda a: (a.subnet_id, inv.address_key(a.address)))]
224
+ return page([r for r in rows if (not args.subnet_id or r['subnet_id'] == args.subnet_id)
225
+ and (not args.asset_id or r['asset_id'] == args.asset_id)
226
+ and (not address or r['address'] == address)], args)
227
+ links = {r.observation_id: r for r in (await db.scalars(select(DiscoveryLink))).all()}
228
+ observations = list((await db.scalars(select(DiscoveryObservation).order_by(DiscoveryObservation.id))).all())
229
+ rows = [{**row_values(o), 'asset_id': links[o.id].asset_id if o.id in links else None,
230
+ 'source_name': data['resource_map'][o.resource_id].name}
231
+ for o in observations if (not args.resource_id or o.resource_id == args.resource_id)
232
+ and args.query.casefold() in json.dumps(o.data).casefold()]
233
+ return page(rows, args)
234
+
235
+
236
+ async def merged(db, model, values, *, exclude=()):
237
+ old = await inv.require(db, model, values.id) if values.id else None
238
+ supplied = values.model_dump(exclude_unset=old is not None, exclude=set(exclude))
239
+ before = row_values(old) if old else None
240
+ return {**({k: v if v is not None else '' for k, v in before.items()} if old else {}), **supplied}, before
241
+
242
+
243
+ async def execute(db, tool, args):
244
+ """Called only after the MCP gate, with no active transaction for write calls. Does not commit."""
245
+ if not OPERATIONS[tool][1]:
246
+ await inv.lock_writes(db)
247
+ data = await inv.snapshot(db)
248
+ current_revision = revision(data)
249
+ if OPERATIONS[tool][1]:
250
+ return {**await read(db, tool, args, data), 'revision': current_revision}, []
251
+ if args.expected_revision != current_revision:
252
+ raise inv.InventoryError('Inventory changed since it was read. Read again and review the updated operation.')
253
+ changes = []
254
+
255
+ async def save(model, values, fn, *, exclude=(), extra=None):
256
+ fields, before = await merged(db, model, values, exclude=exclude)
257
+ row = await fn(db, {**fields, **(extra or {})})
258
+ changes.append({'record': model.__tablename__, 'before': before, 'after': row_values(row)})
259
+ return row
260
+
261
+ if tool == 'setup_host':
262
+ asset = await save(inv.Asset, args.asset, inv.save_asset)
263
+ for interface in args.interfaces:
264
+ iface = await save(inv.Interface, interface, inv.save_interface, exclude=('allocations',),
265
+ extra={'asset_id': asset.id})
266
+ for allocation in interface.allocations:
267
+ if allocation.interface_id and allocation.interface_id != iface.id:
268
+ raise inv.InventoryError('The allocation must belong to the interface being set up.')
269
+ if allocation.id:
270
+ existing = await inv.require(db, inv.Allocation, allocation.id)
271
+ if existing.interface_id != iface.id:
272
+ raise inv.InventoryError('This allocation belongs to a different interface; use save_ip to reassign it explicitly.')
273
+ await save(inv.Allocation, allocation, inv.save_allocation, extra={'interface_id': iface.id})
274
+ for rid in dict.fromkeys(args.resource_ids):
275
+ old = await db.get(inv.ResourceLink, rid)
276
+ before = row_values(old) if old else None
277
+ row = await inv.link_resource(db, {'resource_id': rid, 'asset_id': asset.id})
278
+ changes.append({'record': inv.ResourceLink.__tablename__, 'before': before, 'after': row_values(row)})
279
+ result = {'asset_id': asset.id}
280
+ elif tool == 'save_subnet':
281
+ subnet = await save(inv.Subnet, args.subnet, inv.save_subnet)
282
+ result = {'subnet_id': subnet.id}
283
+ else:
284
+ allocation = await save(inv.Allocation, args.allocation, inv.save_allocation)
285
+ result = {'allocation_id': allocation.id}
286
+ await db.flush()
287
+ # Refresh collections after edits; the new revision permits the next explicit operation.
288
+ return {**result, 'revision': revision(await inv.snapshot(db)), 'changes': changes}, changes
@@ -0,0 +1,80 @@
1
+ """The TypeSafe System One protocol for Jev: endpoint, pinned model, the question spec and the
2
+ response validator. Pure: no configuration, database or network, so the offline evaluator under
3
+ experiments/ can share it without initialising the broker."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import math
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ ENDPOINT = "https://api.typesafe.ai/v1/systemone"
13
+ MODEL = "jev-1.13.0"
14
+ MAX_RESPONSE_BYTES = 128 * 1024
15
+ QUESTION_PATH = Path(__file__).with_name("semantic_questions.json")
16
+
17
+
18
+ def canonical(value: Any) -> str:
19
+ return json.dumps(value, sort_keys=True, ensure_ascii=True, allow_nan=False, separators=(",", ":"))
20
+
21
+
22
+ def strict_json(text: str):
23
+ def _object(pairs):
24
+ out = {}
25
+ for key, value in pairs:
26
+ if key in out:
27
+ raise ValueError("duplicate JSON key")
28
+ out[key] = value
29
+ return out
30
+
31
+ def _invalid(_):
32
+ raise ValueError("non-finite JSON value")
33
+
34
+ return json.loads(text, object_pairs_hook=_object, parse_constant=_invalid)
35
+
36
+
37
+ _spec: dict | None = None
38
+
39
+
40
+ def questions_spec() -> dict:
41
+ global _spec
42
+ if _spec is None:
43
+ _spec = json.loads(QUESTION_PATH.read_text())
44
+ return _spec
45
+
46
+
47
+ def _probability(value) -> bool:
48
+ return type(value) in (float, int) and math.isfinite(value) and 0 <= value <= 1
49
+
50
+
51
+ def validate_response(body: dict, questions: dict, model: str) -> dict:
52
+ if not isinstance(body, dict) or set(body) != {"model", "answers", "usage"} or body["model"] != model:
53
+ raise ValueError("invalid response envelope")
54
+ answers, usage = body["answers"], body["usage"]
55
+ if not isinstance(answers, dict) or set(answers) != set(questions):
56
+ raise ValueError("answer IDs differ")
57
+ if (not isinstance(usage, dict) or set(usage) != {"input_tokens", "output_tokens"}
58
+ or any(type(v) is not int or not 0 <= v <= 100_000_000 for v in usage.values())):
59
+ raise ValueError("invalid usage")
60
+ for key, question in questions.items():
61
+ answer = answers[key]
62
+ if not isinstance(answer, dict) or answer.get("type") != question["type"]:
63
+ raise ValueError("invalid answer type")
64
+ if answer["type"] == "noul":
65
+ if set(answer) != {"type", "noul"} or not _probability(answer["noul"]):
66
+ raise ValueError("invalid noul")
67
+ elif answer["type"] == "choice":
68
+ if set(answer) != {"type", "choice", "probabilities", "confidence"}:
69
+ raise ValueError("invalid choice fields")
70
+ probs = answer["probabilities"]
71
+ if (not isinstance(probs, dict) or set(probs) != set(question["criteria"])
72
+ or any(not _probability(v) for v in probs.values())
73
+ or not math.isclose(sum(probs.values()), 1, abs_tol=0.015)
74
+ or not isinstance(answer["choice"], str) or answer["choice"] not in probs
75
+ or probs[answer["choice"]] < max(probs.values()) - 1e-9
76
+ or not _probability(answer["confidence"])):
77
+ raise ValueError("invalid choice")
78
+ else:
79
+ raise ValueError("unsupported answer type")
80
+ return body