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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- 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
|
+
)
|