graphite-code 0.3.0__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 (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,629 @@
1
+ """Immutable provider-neutral route-pool approval and selection authority."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from typing import Any, ClassVar, Final
10
+
11
+ from .contracts import Effort, PermissionMode, PublicRecord, RiskTier
12
+ from .lifecycle import (
13
+ LifecycleProviderId,
14
+ ProviderLifecycleState,
15
+ RuntimeKind,
16
+ )
17
+ from .process_runner import CliProcessFailureDiagnostics
18
+
19
+ MAX_ROUTE_CANDIDATES: Final = 2
20
+ MAX_ROUTE_CAPABILITIES: Final = 32
21
+ _HEX_64 = re.compile(r"^[0-9a-f]{64}$")
22
+ _GIT_OBJECT = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$")
23
+ _SAFE_CAPABILITY = re.compile(r"^[a-z0-9][a-z0-9._-]{0,127}$")
24
+ _RISK_ORDER = {RiskTier.LOW: 0, RiskTier.MEDIUM: 1, RiskTier.HIGH: 2}
25
+ _RUNTIME_BY_PROVIDER = {
26
+ LifecycleProviderId.CLAUDE_CODE: RuntimeKind.LOCAL_CLI,
27
+ LifecycleProviderId.CODEX: RuntimeKind.LOCAL_CLI,
28
+ LifecycleProviderId.OLLAMA: RuntimeKind.LOCAL_HTTP,
29
+ LifecycleProviderId.OPENROUTER: RuntimeKind.REMOTE_HTTPS,
30
+ LifecycleProviderId.ZAI: RuntimeKind.REMOTE_HTTPS,
31
+ }
32
+ _FAILURE_CATEGORIES = frozenset(
33
+ {
34
+ "capacity_unavailable",
35
+ "provider_process_failure",
36
+ "provider_unavailable",
37
+ "provider_protocol",
38
+ "timeout",
39
+ "cancelled",
40
+ }
41
+ )
42
+
43
+
44
+ class RoutePoolError(RuntimeError):
45
+ """Stable route-pool rejection containing no provider diagnostics."""
46
+
47
+ def __init__(self, code: str) -> None:
48
+ self.code = code
49
+ super().__init__(code)
50
+
51
+
52
+ class SideEffectState(StrEnum):
53
+ NONE = "none"
54
+ OBSERVED = "observed"
55
+ UNKNOWN = "unknown"
56
+
57
+
58
+ def failure_category_for_adapter(
59
+ code: object,
60
+ diagnostics: CliProcessFailureDiagnostics | None = None,
61
+ ) -> str:
62
+ """Map one adapter failure to an allowlisted route-attempt category."""
63
+ if isinstance(diagnostics, CliProcessFailureDiagnostics):
64
+ return diagnostics.failure_category
65
+ if isinstance(code, str) and code in {"quota", "capacity_unavailable"}:
66
+ return "capacity_unavailable"
67
+ return "provider_process_failure"
68
+
69
+
70
+ def _identifier(value: object, code: str) -> str:
71
+ if (
72
+ not isinstance(value, str)
73
+ or not value
74
+ or len(value) > 256
75
+ or "\x00" in value
76
+ or any(character.isspace() for character in value)
77
+ ):
78
+ raise RoutePoolError(code)
79
+ return value
80
+
81
+
82
+ def _digest(value: object, code: str, *, optional: bool = False) -> str | None:
83
+ if value is None and optional:
84
+ return None
85
+ if not isinstance(value, str) or _HEX_64.fullmatch(value) is None:
86
+ raise RoutePoolError(code)
87
+ return value
88
+
89
+
90
+ def _integer(
91
+ value: object,
92
+ code: str,
93
+ *,
94
+ minimum: int = 0,
95
+ maximum: int = 10**12,
96
+ ) -> int:
97
+ if (
98
+ isinstance(value, bool)
99
+ or not isinstance(value, int)
100
+ or not minimum <= value <= maximum
101
+ ):
102
+ raise RoutePoolError(code)
103
+ return value
104
+
105
+
106
+ def _capabilities(value: object, code: str) -> tuple[str, ...]:
107
+ if (
108
+ not isinstance(value, (tuple, list))
109
+ or not value
110
+ or len(value) > MAX_ROUTE_CAPABILITIES
111
+ ):
112
+ raise RoutePoolError(code)
113
+ normalized = tuple(value)
114
+ if any(
115
+ not isinstance(item, str) or _SAFE_CAPABILITY.fullmatch(item) is None
116
+ for item in normalized
117
+ ) or len(set(normalized)) != len(normalized):
118
+ raise RoutePoolError(code)
119
+ return tuple(sorted(normalized))
120
+
121
+
122
+ def _canonical_digest(payload: dict[str, Any]) -> str:
123
+ encoded = json.dumps(
124
+ payload,
125
+ sort_keys=True,
126
+ separators=(",", ":"),
127
+ ensure_ascii=True,
128
+ ).encode("utf-8")
129
+ return hashlib.sha256(encoded).hexdigest()
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class ApprovedRouteCandidate(PublicRecord):
134
+ candidate_id: str
135
+ provider: LifecycleProviderId
136
+ runtime_kind: RuntimeKind
137
+ lifecycle_identity_digest: str
138
+ capability_snapshot_digest: str
139
+ model_identity_digest: str
140
+ routing_policy_digest: str | None
141
+ requested_model: str
142
+ effective_model: str
143
+ effort: Effort
144
+ permission_mode: PermissionMode
145
+ risk_ceiling: RiskTier
146
+ trust_policy_digest: str
147
+ capabilities: tuple[str, ...]
148
+ context_window_tokens: int
149
+ snapshot_expires_at: int
150
+
151
+ _public_fields: ClassVar[tuple[str, ...]] = (
152
+ "candidate_id",
153
+ "provider",
154
+ "runtime_kind",
155
+ "lifecycle_identity_digest",
156
+ "capability_snapshot_digest",
157
+ "model_identity_digest",
158
+ "routing_policy_digest",
159
+ "requested_model",
160
+ "effective_model",
161
+ "effort",
162
+ "permission_mode",
163
+ "risk_ceiling",
164
+ "trust_policy_digest",
165
+ "capabilities",
166
+ "context_window_tokens",
167
+ "snapshot_expires_at",
168
+ )
169
+
170
+ def __post_init__(self) -> None:
171
+ try:
172
+ provider = LifecycleProviderId(self.provider)
173
+ runtime = RuntimeKind(self.runtime_kind)
174
+ effort = Effort(self.effort)
175
+ permission = PermissionMode(self.permission_mode)
176
+ risk = RiskTier(self.risk_ceiling)
177
+ except (TypeError, ValueError):
178
+ raise RoutePoolError("route_candidate_invalid") from None
179
+ routing_digest = _digest(
180
+ self.routing_policy_digest,
181
+ "route_candidate_invalid",
182
+ optional=True,
183
+ )
184
+ if (
185
+ _RUNTIME_BY_PROVIDER.get(provider) is not runtime
186
+ or (provider is LifecycleProviderId.OPENROUTER) is (routing_digest is None)
187
+ ):
188
+ raise RoutePoolError("route_candidate_invalid")
189
+ object.__setattr__(self, "candidate_id", _identifier(self.candidate_id, "route_candidate_invalid"))
190
+ object.__setattr__(self, "provider", provider)
191
+ object.__setattr__(self, "runtime_kind", runtime)
192
+ object.__setattr__(self, "effort", effort)
193
+ object.__setattr__(self, "permission_mode", permission)
194
+ object.__setattr__(self, "risk_ceiling", risk)
195
+ for field_name in (
196
+ "lifecycle_identity_digest",
197
+ "capability_snapshot_digest",
198
+ "model_identity_digest",
199
+ "trust_policy_digest",
200
+ ):
201
+ object.__setattr__(
202
+ self,
203
+ field_name,
204
+ _digest(getattr(self, field_name), "route_candidate_invalid"),
205
+ )
206
+ object.__setattr__(self, "routing_policy_digest", routing_digest)
207
+ for field_name in ("requested_model", "effective_model"):
208
+ object.__setattr__(
209
+ self,
210
+ field_name,
211
+ _identifier(getattr(self, field_name), "route_candidate_invalid"),
212
+ )
213
+ object.__setattr__(
214
+ self,
215
+ "capabilities",
216
+ _capabilities(self.capabilities, "route_candidate_invalid"),
217
+ )
218
+ object.__setattr__(
219
+ self,
220
+ "context_window_tokens",
221
+ _integer(
222
+ self.context_window_tokens,
223
+ "route_candidate_invalid",
224
+ minimum=1,
225
+ maximum=10_000_000,
226
+ ),
227
+ )
228
+ object.__setattr__(
229
+ self,
230
+ "snapshot_expires_at",
231
+ _integer(self.snapshot_expires_at, "route_candidate_invalid"),
232
+ )
233
+
234
+ @property
235
+ def digest(self) -> str:
236
+ return _canonical_digest(self.to_dict())
237
+
238
+
239
+ @dataclass(frozen=True, slots=True)
240
+ class ApprovedRoutePool(PublicRecord):
241
+ approval_id: str
242
+ task_id: str
243
+ decision_id: str
244
+ candidates: tuple[ApprovedRouteCandidate, ...]
245
+ required_capabilities: tuple[str, ...]
246
+ task_risk: RiskTier
247
+ permission_mode: PermissionMode
248
+ trust_policy_digest: str
249
+ graph_fingerprint: str
250
+ context_manifest_hash: str
251
+ repository_commit: str
252
+ worktree_id: str
253
+ allow_cross_provider: bool
254
+ allowed_fallback_reasons: tuple[str, ...]
255
+ max_attempts: int
256
+ max_input_tokens: int
257
+ max_output_tokens: int
258
+ max_duration_ms: int
259
+ max_cost_microunits: int | None
260
+ policy_version: str
261
+ issued_at: int
262
+ expires_at: int
263
+ nonce: str
264
+
265
+ _public_fields: ClassVar[tuple[str, ...]] = (
266
+ "approval_id",
267
+ "task_id",
268
+ "decision_id",
269
+ "candidates",
270
+ "required_capabilities",
271
+ "task_risk",
272
+ "permission_mode",
273
+ "trust_policy_digest",
274
+ "graph_fingerprint",
275
+ "context_manifest_hash",
276
+ "repository_commit",
277
+ "worktree_id",
278
+ "allow_cross_provider",
279
+ "allowed_fallback_reasons",
280
+ "max_attempts",
281
+ "max_input_tokens",
282
+ "max_output_tokens",
283
+ "max_duration_ms",
284
+ "max_cost_microunits",
285
+ "policy_version",
286
+ "issued_at",
287
+ "expires_at",
288
+ "nonce",
289
+ )
290
+
291
+ def __post_init__(self) -> None:
292
+ if (
293
+ not isinstance(self.candidates, (tuple, list))
294
+ or not 1 <= len(self.candidates) <= MAX_ROUTE_CANDIDATES
295
+ or any(not isinstance(item, ApprovedRouteCandidate) for item in self.candidates)
296
+ ):
297
+ raise RoutePoolError("route_candidates_invalid")
298
+ candidates = tuple(self.candidates)
299
+ if len({item.candidate_id for item in candidates}) != len(candidates) or len(
300
+ {item.digest for item in candidates}
301
+ ) != len(candidates):
302
+ raise RoutePoolError("route_candidates_invalid")
303
+ try:
304
+ risk = RiskTier(self.task_risk)
305
+ permission = PermissionMode(self.permission_mode)
306
+ except (TypeError, ValueError):
307
+ raise RoutePoolError("route_pool_invalid") from None
308
+ required = _capabilities(self.required_capabilities, "route_pool_invalid")
309
+ if any(
310
+ not set(required).issubset(candidate.capabilities)
311
+ or candidate.permission_mode is not permission
312
+ or _RISK_ORDER[candidate.risk_ceiling] < _RISK_ORDER[risk]
313
+ or candidate.trust_policy_digest != self.trust_policy_digest
314
+ for candidate in candidates
315
+ ):
316
+ raise RoutePoolError("route_pool_invalid")
317
+ if not isinstance(self.allow_cross_provider, bool):
318
+ raise RoutePoolError("route_pool_invalid")
319
+ reasons = tuple(self.allowed_fallback_reasons)
320
+ expected_reasons = ("capacity_unavailable",) if len(candidates) == 2 else ()
321
+ if reasons != expected_reasons:
322
+ raise RoutePoolError("route_pool_invalid")
323
+ attempts = _integer(self.max_attempts, "route_pool_invalid", minimum=1, maximum=2)
324
+ if attempts != len(candidates):
325
+ raise RoutePoolError("route_pool_invalid")
326
+ for field_name in (
327
+ "approval_id",
328
+ "task_id",
329
+ "decision_id",
330
+ "worktree_id",
331
+ "policy_version",
332
+ "nonce",
333
+ ):
334
+ object.__setattr__(
335
+ self,
336
+ field_name,
337
+ _identifier(getattr(self, field_name), "route_pool_invalid"),
338
+ )
339
+ for field_name in (
340
+ "trust_policy_digest",
341
+ "graph_fingerprint",
342
+ "context_manifest_hash",
343
+ ):
344
+ object.__setattr__(
345
+ self,
346
+ field_name,
347
+ _digest(getattr(self, field_name), "route_pool_invalid"),
348
+ )
349
+ if not isinstance(self.repository_commit, str) or _GIT_OBJECT.fullmatch(
350
+ self.repository_commit
351
+ ) is None:
352
+ raise RoutePoolError("route_pool_invalid")
353
+ issued = _integer(self.issued_at, "route_pool_invalid")
354
+ expires = _integer(self.expires_at, "route_pool_invalid")
355
+ if expires <= issued:
356
+ raise RoutePoolError("route_pool_invalid")
357
+ cost = self.max_cost_microunits
358
+ if cost is not None:
359
+ cost = _integer(cost, "route_pool_invalid", minimum=1)
360
+ object.__setattr__(self, "candidates", candidates)
361
+ object.__setattr__(self, "required_capabilities", required)
362
+ object.__setattr__(self, "task_risk", risk)
363
+ object.__setattr__(self, "permission_mode", permission)
364
+ object.__setattr__(self, "allowed_fallback_reasons", reasons)
365
+ object.__setattr__(self, "max_attempts", attempts)
366
+ max_input_tokens = _integer(
367
+ self.max_input_tokens,
368
+ "route_pool_invalid",
369
+ minimum=1,
370
+ maximum=262_144,
371
+ )
372
+ max_output_tokens = _integer(
373
+ self.max_output_tokens,
374
+ "route_pool_invalid",
375
+ minimum=1,
376
+ maximum=32_768,
377
+ )
378
+ max_duration_ms = _integer(
379
+ self.max_duration_ms,
380
+ "route_pool_invalid",
381
+ minimum=1,
382
+ maximum=86_400_000,
383
+ )
384
+ if any(
385
+ max_input_tokens + max_output_tokens > candidate.context_window_tokens
386
+ or candidate.snapshot_expires_at < expires
387
+ for candidate in candidates
388
+ ):
389
+ raise RoutePoolError("route_pool_invalid")
390
+ object.__setattr__(
391
+ self,
392
+ "max_input_tokens",
393
+ max_input_tokens,
394
+ )
395
+ object.__setattr__(
396
+ self,
397
+ "max_output_tokens",
398
+ max_output_tokens,
399
+ )
400
+ object.__setattr__(
401
+ self,
402
+ "max_duration_ms",
403
+ max_duration_ms,
404
+ )
405
+ object.__setattr__(self, "max_cost_microunits", cost)
406
+
407
+ def to_dict(self) -> dict[str, Any]:
408
+ # Explicit two-arg `super()`, NOT the zero-arg form. `slots=True` rebuilds
409
+ # the class, so the `__class__` cell zero-arg `super()` closes over still
410
+ # points at the pre-slots class and raises `TypeError: super(type, obj):
411
+ # obj must be an instance or subtype of type` on Python 3.11 and 3.12.
412
+ # The module-global name resolves at call time, which IS the rebuilt
413
+ # class. CPython fixed the zero-arg form in 3.13; graphite claims 3.11+.
414
+ payload = super(ApprovedRoutePool, self).to_dict()
415
+ payload["candidates"] = [candidate.to_dict() for candidate in self.candidates]
416
+ return payload
417
+
418
+ @property
419
+ def digest(self) -> str:
420
+ return _canonical_digest(self.to_dict())
421
+
422
+
423
+ @dataclass(frozen=True, slots=True)
424
+ class RouteAuthority(PublicRecord):
425
+ candidate_id: str
426
+ provider: LifecycleProviderId
427
+ runtime_kind: RuntimeKind
428
+ lifecycle_identity_digest: str
429
+ capability_snapshot_digest: str
430
+ state: ProviderLifecycleState
431
+
432
+ _public_fields: ClassVar[tuple[str, ...]] = (
433
+ "candidate_id",
434
+ "provider",
435
+ "runtime_kind",
436
+ "lifecycle_identity_digest",
437
+ "capability_snapshot_digest",
438
+ "state",
439
+ )
440
+
441
+ def __post_init__(self) -> None:
442
+ try:
443
+ provider = LifecycleProviderId(self.provider)
444
+ runtime = RuntimeKind(self.runtime_kind)
445
+ state = ProviderLifecycleState(self.state)
446
+ except (TypeError, ValueError):
447
+ raise RoutePoolError("route_authority_invalid") from None
448
+ if _RUNTIME_BY_PROVIDER.get(provider) is not runtime:
449
+ raise RoutePoolError("route_authority_invalid")
450
+ object.__setattr__(self, "candidate_id", _identifier(self.candidate_id, "route_authority_invalid"))
451
+ object.__setattr__(self, "provider", provider)
452
+ object.__setattr__(self, "runtime_kind", runtime)
453
+ object.__setattr__(self, "state", state)
454
+ for field_name in ("lifecycle_identity_digest", "capability_snapshot_digest"):
455
+ object.__setattr__(
456
+ self,
457
+ field_name,
458
+ _digest(getattr(self, field_name), "route_authority_invalid"),
459
+ )
460
+
461
+
462
+ @dataclass(frozen=True, slots=True)
463
+ class RouteAttemptEvidence(PublicRecord):
464
+ candidate_id: str
465
+ candidate_digest: str
466
+ attempt_ordinal: int
467
+ failure_category: str
468
+ accepted_output: bool
469
+ side_effect_state: SideEffectState
470
+ input_tokens: int
471
+ output_tokens: int
472
+ duration_ms: int
473
+ cost_microunits: int | None
474
+
475
+ _public_fields: ClassVar[tuple[str, ...]] = (
476
+ "candidate_id",
477
+ "candidate_digest",
478
+ "attempt_ordinal",
479
+ "failure_category",
480
+ "accepted_output",
481
+ "side_effect_state",
482
+ "input_tokens",
483
+ "output_tokens",
484
+ "duration_ms",
485
+ "cost_microunits",
486
+ )
487
+
488
+ def __post_init__(self) -> None:
489
+ if (
490
+ not isinstance(self.failure_category, str)
491
+ or self.failure_category not in _FAILURE_CATEGORIES
492
+ or not isinstance(self.accepted_output, bool)
493
+ ):
494
+ raise RoutePoolError("route_attempt_invalid")
495
+ try:
496
+ side_effects = SideEffectState(self.side_effect_state)
497
+ except (TypeError, ValueError):
498
+ raise RoutePoolError("route_attempt_invalid") from None
499
+ object.__setattr__(self, "candidate_id", _identifier(self.candidate_id, "route_attempt_invalid"))
500
+ object.__setattr__(
501
+ self,
502
+ "candidate_digest",
503
+ _digest(self.candidate_digest, "route_attempt_invalid"),
504
+ )
505
+ object.__setattr__(
506
+ self,
507
+ "attempt_ordinal",
508
+ _integer(self.attempt_ordinal, "route_attempt_invalid", minimum=1, maximum=2),
509
+ )
510
+ object.__setattr__(self, "side_effect_state", side_effects)
511
+ for field_name in ("input_tokens", "output_tokens", "duration_ms"):
512
+ object.__setattr__(
513
+ self,
514
+ field_name,
515
+ _integer(getattr(self, field_name), "route_attempt_invalid"),
516
+ )
517
+ cost = self.cost_microunits
518
+ if cost is not None:
519
+ cost = _integer(cost, "route_attempt_invalid")
520
+ object.__setattr__(self, "cost_microunits", cost)
521
+
522
+
523
+ @dataclass(frozen=True, slots=True)
524
+ class RouteSelection:
525
+ candidate: ApprovedRouteCandidate
526
+ attempt_ordinal: int
527
+ remaining_input_tokens: int
528
+ remaining_output_tokens: int
529
+ remaining_duration_ms: int
530
+ remaining_cost_microunits: int | None
531
+
532
+
533
+ def _validate_authority(
534
+ candidate: ApprovedRouteCandidate,
535
+ authority: RouteAuthority,
536
+ *,
537
+ now: int,
538
+ ) -> None:
539
+ if authority.state is not ProviderLifecycleState.ACTIVE:
540
+ raise RoutePoolError("route_inactive")
541
+ if (
542
+ authority.provider is not candidate.provider
543
+ or authority.runtime_kind is not candidate.runtime_kind
544
+ or authority.candidate_id != candidate.candidate_id
545
+ ):
546
+ raise RoutePoolError("route_authority_invalid")
547
+ if authority.lifecycle_identity_digest != candidate.lifecycle_identity_digest:
548
+ raise RoutePoolError("route_identity_changed")
549
+ if authority.capability_snapshot_digest != candidate.capability_snapshot_digest:
550
+ raise RoutePoolError("route_snapshot_changed")
551
+ if candidate.snapshot_expires_at <= now:
552
+ raise RoutePoolError("route_snapshot_expired")
553
+
554
+
555
+ def select_route(
556
+ pool: ApprovedRoutePool,
557
+ authorities: tuple[RouteAuthority, ...],
558
+ attempts: tuple[RouteAttemptEvidence, ...],
559
+ *,
560
+ now: int,
561
+ ) -> RouteSelection:
562
+ """Select one exact approved candidate; never discover or substitute routes."""
563
+ if not isinstance(pool, ApprovedRoutePool):
564
+ raise RoutePoolError("route_pool_invalid")
565
+ current_time = _integer(now, "route_pool_time_invalid")
566
+ if current_time >= pool.expires_at:
567
+ raise RoutePoolError("route_pool_expired")
568
+ if (
569
+ not isinstance(authorities, tuple)
570
+ or len(authorities) != len(pool.candidates)
571
+ or any(not isinstance(item, RouteAuthority) for item in authorities)
572
+ or tuple(item.candidate_id for item in authorities)
573
+ != tuple(item.candidate_id for item in pool.candidates)
574
+ ):
575
+ raise RoutePoolError("route_authority_invalid")
576
+ if (
577
+ not isinstance(attempts, tuple)
578
+ or len(attempts) >= pool.max_attempts
579
+ or any(not isinstance(item, RouteAttemptEvidence) for item in attempts)
580
+ ):
581
+ raise RoutePoolError("route_pool_attempts_exhausted")
582
+ ordinal = len(attempts) + 1
583
+ candidate = pool.candidates[len(attempts)]
584
+ _validate_authority(candidate, authorities[len(attempts)], now=current_time)
585
+ used_input = sum(item.input_tokens for item in attempts)
586
+ used_output = sum(item.output_tokens for item in attempts)
587
+ used_duration = sum(item.duration_ms for item in attempts)
588
+ costs = tuple(item.cost_microunits for item in attempts)
589
+ if pool.max_cost_microunits is None:
590
+ if any(value is not None for value in costs):
591
+ raise RoutePoolError("route_pool_cost_invalid")
592
+ used_cost: int | None = None
593
+ else:
594
+ if any(value is None for value in costs):
595
+ raise RoutePoolError("route_pool_cost_invalid")
596
+ used_cost = sum(value for value in costs if value is not None)
597
+ if attempts and (
598
+ used_input >= pool.max_input_tokens
599
+ or used_output >= pool.max_output_tokens
600
+ or used_duration >= pool.max_duration_ms
601
+ or used_cost is not None
602
+ and used_cost >= pool.max_cost_microunits
603
+ ):
604
+ raise RoutePoolError("route_pool_budget_exhausted")
605
+ if attempts:
606
+ previous = attempts[0]
607
+ if previous.attempt_ordinal != 1 or previous.candidate_id != pool.candidates[0].candidate_id:
608
+ raise RoutePoolError("route_attempt_invalid")
609
+ if previous.candidate_digest != pool.candidates[0].digest:
610
+ raise RoutePoolError("route_attempt_invalid")
611
+ if previous.failure_category not in pool.allowed_fallback_reasons:
612
+ raise RoutePoolError("fallback_reason_denied")
613
+ if previous.accepted_output:
614
+ raise RoutePoolError("fallback_output_accepted")
615
+ if previous.side_effect_state is not SideEffectState.NONE:
616
+ raise RoutePoolError("fallback_side_effects")
617
+ if (
618
+ candidate.provider is not pool.candidates[0].provider
619
+ and not pool.allow_cross_provider
620
+ ):
621
+ raise RoutePoolError("cross_provider_denied")
622
+ return RouteSelection(
623
+ candidate,
624
+ ordinal,
625
+ pool.max_input_tokens - used_input,
626
+ pool.max_output_tokens - used_output,
627
+ pool.max_duration_ms - used_duration,
628
+ None if used_cost is None else pool.max_cost_microunits - used_cost,
629
+ )