agentloop-profiler 0.1.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 (166) hide show
  1. agentloop/__init__.py +136 -0
  2. agentloop/ablation_protocol.py +229 -0
  3. agentloop/ablations.py +564 -0
  4. agentloop/aggregate_cli.py +88 -0
  5. agentloop/aggregate_stats.py +175 -0
  6. agentloop/aggregates.py +532 -0
  7. agentloop/audit.py +39 -0
  8. agentloop/autoinstrument.py +145 -0
  9. agentloop/batch_experiments.py +295 -0
  10. agentloop/batching.py +95 -0
  11. agentloop/budget_types.py +335 -0
  12. agentloop/budgets.py +539 -0
  13. agentloop/calibration.py +872 -0
  14. agentloop/checkpoint_state.py +306 -0
  15. agentloop/checkpoint_store.py +86 -0
  16. agentloop/ci.py +202 -0
  17. agentloop/cli.py +1314 -0
  18. agentloop/client.py +228 -0
  19. agentloop/completion.py +345 -0
  20. agentloop/completion_types.py +199 -0
  21. agentloop/config.py +50 -0
  22. agentloop/context_controls.py +443 -0
  23. agentloop/context_experiments.py +337 -0
  24. agentloop/context_types.py +216 -0
  25. agentloop/costs.py +667 -0
  26. agentloop/cursor_validation.py +48 -0
  27. agentloop/decorators.py +445 -0
  28. agentloop/demo.py +300 -0
  29. agentloop/doctor.py +341 -0
  30. agentloop/drift.py +361 -0
  31. agentloop/drift_cli.py +147 -0
  32. agentloop/drift_types.py +110 -0
  33. agentloop/drift_windows.py +340 -0
  34. agentloop/entrypoint.py +201 -0
  35. agentloop/estimates.py +133 -0
  36. agentloop/events.py +75 -0
  37. agentloop/experiment_artifacts.py +80 -0
  38. agentloop/experiment_observations.py +169 -0
  39. agentloop/experiment_reports.py +545 -0
  40. agentloop/experiment_types.py +355 -0
  41. agentloop/experiments.py +392 -0
  42. agentloop/exporters.py +117 -0
  43. agentloop/finding_benchmark_types.py +131 -0
  44. agentloop/finding_benchmarks.py +424 -0
  45. agentloop/findings.py +419 -0
  46. agentloop/graph.py +464 -0
  47. agentloop/harness.py +825 -0
  48. agentloop/harness_bench/__init__.py +1 -0
  49. agentloop/harness_bench/cli.py +77 -0
  50. agentloop/harness_bench/drivers.py +96 -0
  51. agentloop/harness_bench/offline.py +507 -0
  52. agentloop/harness_evidence.py +585 -0
  53. agentloop/harness_usage.py +91 -0
  54. agentloop/html_report.py +860 -0
  55. agentloop/integrations/__init__.py +1 -0
  56. agentloop/integrations/crewai.py +144 -0
  57. agentloop/integrations/harbor/__init__.py +1 -0
  58. agentloop/integrations/harbor/atif.py +982 -0
  59. agentloop/integrations/harbor/atif_validation.py +329 -0
  60. agentloop/integrations/harbor/cli.py +207 -0
  61. agentloop/integrations/harbor/manifest.py +86 -0
  62. agentloop/integrations/harbor/trial_evidence.py +196 -0
  63. agentloop/integrations/harbor/trials.py +1109 -0
  64. agentloop/integrations/harbor/verifier.py +118 -0
  65. agentloop/integrations/langgraph.py +150 -0
  66. agentloop/integrations/langgraph_harness.py +466 -0
  67. agentloop/integrations/langgraph_recovery.py +366 -0
  68. agentloop/integrations/omnigent/__init__.py +1 -0
  69. agentloop/integrations/omnigent/cli.py +78 -0
  70. agentloop/integrations/omnigent/evidence.py +93 -0
  71. agentloop/integrations/omnigent/telemetry.py +422 -0
  72. agentloop/integrations/openai.py +415 -0
  73. agentloop/integrations/openai_agents.py +291 -0
  74. agentloop/integrations/sdk.py +82 -0
  75. agentloop/integrations/vercel_ai.py +83 -0
  76. agentloop/interoperability/__init__.py +5 -0
  77. agentloop/interoperability/artifacts.py +112 -0
  78. agentloop/interoperability/cli.py +59 -0
  79. agentloop/interoperability/cohort_evidence.py +175 -0
  80. agentloop/interoperability/cohorts.py +681 -0
  81. agentloop/interoperability/contracts.py +427 -0
  82. agentloop/interoperability/coordination.py +554 -0
  83. agentloop/interoperability/evidence.py +215 -0
  84. agentloop/interoperability/otlp_jsonl.py +775 -0
  85. agentloop/interoperability/otlp_validation.py +496 -0
  86. agentloop/interoperability/registry.py +561 -0
  87. agentloop/interoperability/validation.py +333 -0
  88. agentloop/intervention_service.py +110 -0
  89. agentloop/intervention_store.py +118 -0
  90. agentloop/interventions.py +190 -0
  91. agentloop/issues.py +90 -0
  92. agentloop/judgment_adapters.py +125 -0
  93. agentloop/judgment_benchmark_types.py +98 -0
  94. agentloop/judgment_benchmarks.py +606 -0
  95. agentloop/judgment_types.py +241 -0
  96. agentloop/judgment_views.py +38 -0
  97. agentloop/judgments.py +342 -0
  98. agentloop/loop_guards.py +274 -0
  99. agentloop/loop_types.py +66 -0
  100. agentloop/markdown.py +102 -0
  101. agentloop/metrics.py +342 -0
  102. agentloop/migrations.py +303 -0
  103. agentloop/model_routing.py +470 -0
  104. agentloop/onboarding.py +315 -0
  105. agentloop/operations.py +44 -0
  106. agentloop/optimizer.py +199 -0
  107. agentloop/otel.py +715 -0
  108. agentloop/otel_ids.py +81 -0
  109. agentloop/otel_semantics.py +134 -0
  110. agentloop/parallelism.py +141 -0
  111. agentloop/patches.py +603 -0
  112. agentloop/plan_export.py +126 -0
  113. agentloop/policy_promotion.py +325 -0
  114. agentloop/promotion_evidence.py +304 -0
  115. agentloop/promotion_types.py +209 -0
  116. agentloop/quality.py +446 -0
  117. agentloop/ranking.py +529 -0
  118. agentloop/recovery_evidence.py +96 -0
  119. agentloop/replay.py +753 -0
  120. agentloop/retention.py +375 -0
  121. agentloop/retention_types.py +113 -0
  122. agentloop/reuse_experiments.py +247 -0
  123. agentloop/routing_checks.py +116 -0
  124. agentloop/routing_types.py +241 -0
  125. agentloop/rules.py +396 -0
  126. agentloop/runtime.py +286 -0
  127. agentloop/savings.py +158 -0
  128. agentloop/scheduling_plan.py +99 -0
  129. agentloop/scheduling_types.py +169 -0
  130. agentloop/schema.py +257 -0
  131. agentloop/semantic_rules.py +200 -0
  132. agentloop/semantic_waste.py +345 -0
  133. agentloop/semantic_waste_types.py +88 -0
  134. agentloop/server.py +494 -0
  135. agentloop/store.py +1429 -0
  136. agentloop/structural_experiments.py +431 -0
  137. agentloop/structural_types.py +113 -0
  138. agentloop/structured_quality.py +1020 -0
  139. agentloop/studies.py +629 -0
  140. agentloop/study_statistics.py +58 -0
  141. agentloop/substitution_evidence.py +199 -0
  142. agentloop/substitution_export.py +190 -0
  143. agentloop/substitution_reports.py +407 -0
  144. agentloop/substitution_types.py +284 -0
  145. agentloop/substitutions.py +267 -0
  146. agentloop/timing.py +142 -0
  147. agentloop/tokens.py +188 -0
  148. agentloop/tool_scheduling.py +410 -0
  149. agentloop/trace_sources.py +31 -0
  150. agentloop/tracer.py +494 -0
  151. agentloop/value.py +306 -0
  152. agentloop/version.py +3 -0
  153. agentloop/workflow_types.py +264 -0
  154. agentloop/workflows.py +284 -0
  155. agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
  156. agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
  157. agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
  158. agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
  159. agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
  160. agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
  161. agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
  162. dashboard/__init__.py +5 -0
  163. dashboard/app.py +922 -0
  164. dashboard/streamlit_compat.py +63 -0
  165. dashboard/trace_upload.py +57 -0
  166. dashboard/value_view.py +105 -0
@@ -0,0 +1,247 @@
1
+ """Exact-key reuse from a frozen prior-result snapshot; no cache service."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from agentloop.ablation_protocol import timestamp
8
+ from agentloop.budget_types import ResourceUsage
9
+ from agentloop.context_experiments import _path, _value
10
+ from agentloop.context_types import identifier
11
+ from agentloop.experiment_observations import observation, record_experiment_observations
12
+ from agentloop.experiment_types import (
13
+ ExperimentResult,
14
+ ExperimentRunner,
15
+ canonical,
16
+ fingerprint,
17
+ reference,
18
+ )
19
+ from agentloop.tracer import current_trace
20
+
21
+ REUSE_KEY = "agentloop.reuse_experiment"
22
+
23
+
24
+ @dataclass(frozen=True, init=False)
25
+ class ReuseEntry:
26
+ _json: str = field(repr=False)
27
+ _result: ExperimentResult = field(repr=False)
28
+
29
+ def __init__(
30
+ self,
31
+ key,
32
+ output,
33
+ *,
34
+ key_version=None,
35
+ implementation_ref=None,
36
+ implementation_version=None,
37
+ configuration_ref=None,
38
+ data_ref=None,
39
+ invalidation_epoch=None,
40
+ source_ref=None,
41
+ created_at=None,
42
+ expires_at=None,
43
+ ):
44
+ for name, value in (
45
+ ("key_version", key_version),
46
+ ("implementation_ref", implementation_ref),
47
+ ("implementation_version", implementation_version),
48
+ ("configuration_ref", configuration_ref),
49
+ ("data_ref", data_ref),
50
+ ("invalidation_epoch", invalidation_epoch),
51
+ ("source_ref", source_ref),
52
+ ):
53
+ if value is not None:
54
+ reference(value, name)
55
+ for value in (created_at, expires_at):
56
+ if value is not None:
57
+ timestamp(value)
58
+ result = ExperimentResult(output)
59
+ data = {
60
+ "key_hash": fingerprint(key),
61
+ "output_hash": fingerprint(result.output),
62
+ "key_version": key_version,
63
+ "implementation_ref": implementation_ref,
64
+ "implementation_version": implementation_version,
65
+ "configuration_ref": configuration_ref,
66
+ "data_ref": data_ref,
67
+ "invalidation_epoch": invalidation_epoch,
68
+ "source_ref": source_ref,
69
+ "created_at": created_at,
70
+ "expires_at": expires_at,
71
+ }
72
+ object.__setattr__(self, "_json", canonical(data))
73
+ object.__setattr__(self, "_result", result)
74
+
75
+ @property
76
+ def output(self):
77
+ return self._result.output
78
+
79
+ def declaration(self):
80
+ import json
81
+
82
+ return json.loads(self._json)
83
+
84
+
85
+ def result_reuse_runner(
86
+ candidate_id,
87
+ backend,
88
+ *,
89
+ entries,
90
+ key_paths,
91
+ key_version,
92
+ data_ref,
93
+ invalidation_epoch,
94
+ as_of,
95
+ on_unavailable="recompute",
96
+ reservation=None,
97
+ ):
98
+ if (
99
+ type(backend) is not ExperimentRunner
100
+ or not isinstance(entries, (list, tuple))
101
+ or len(entries) > 1000
102
+ or any(type(entry) is not ReuseEntry for entry in entries)
103
+ ):
104
+ raise ValueError("reuse requires an explicit backend and bounded frozen entry snapshot")
105
+ if not isinstance(key_paths, (list, tuple)) or not 1 <= len(key_paths) <= 32:
106
+ raise ValueError("reuse keys require explicit bounded input paths")
107
+ paths = tuple(_path(path) for path in key_paths)
108
+ if len(set(paths)) != len(paths):
109
+ raise ValueError("reuse key paths must be unique")
110
+ identifier(key_version, "key_version")
111
+ reference(data_ref, "data_ref")
112
+ reference(invalidation_epoch, "invalidation_epoch")
113
+ instant = timestamp(as_of)
114
+ if on_unavailable not in {"recompute", "error"}:
115
+ raise ValueError("unknown reuse fallback policy")
116
+ entries = tuple(entries)
117
+ declarations = [entry.declaration() for entry in entries]
118
+ if sum(len(canonical(entry.output).encode("utf-8")) for entry in entries) > 10_000_000:
119
+ raise ValueError("reuse snapshot exceeds ten million output bytes")
120
+ config = {
121
+ "schema_version": "1.0",
122
+ "backend": backend.declaration(),
123
+ "key_paths": paths,
124
+ "key_version": key_version,
125
+ "data_ref": data_ref,
126
+ "invalidation_epoch": invalidation_epoch,
127
+ "as_of": as_of,
128
+ "on_unavailable": on_unavailable,
129
+ "entries": declarations,
130
+ "snapshot_semantics": "read_only_prior_results_no_implicit_fill",
131
+ }
132
+ config_hash = fingerprint(config)
133
+ source = "reuse-config:" + config_hash
134
+ indexed = {}
135
+ for entry, declared in zip(entries, declarations):
136
+ indexed.setdefault(declared["key_hash"], []).append((entry, declared))
137
+ required = {
138
+ "key_version": key_version,
139
+ "implementation_ref": backend.implementation_ref,
140
+ "implementation_version": backend.version,
141
+ "configuration_ref": backend.configuration_ref,
142
+ "data_ref": data_ref,
143
+ "invalidation_epoch": invalidation_epoch,
144
+ }
145
+
146
+ def invoke(request):
147
+ if backend.declaration() != config["backend"]:
148
+ raise ValueError("reuse backend binding changed after configuration")
149
+ trace = current_trace()
150
+ if trace is None:
151
+ raise ValueError("reuse candidate requires an active experiment")
152
+ receipt = {
153
+ "schema_version": "1.0",
154
+ "config_hash": config_hash,
155
+ "key_hash": None,
156
+ "state": "key_unavailable",
157
+ "entry_refs": [],
158
+ "fallback": False,
159
+ "as_of": as_of,
160
+ }
161
+ trace.metadata[REUSE_KEY] = receipt
162
+ valid = []
163
+ try:
164
+ payload = request.inputs
165
+ key = [_value(payload, path) for path in paths]
166
+ except ValueError:
167
+ matches = []
168
+ else:
169
+ receipt["key_hash"] = fingerprint(key)
170
+ matches = indexed.get(receipt["key_hash"], [])
171
+ receipt["state"] = "miss"
172
+ states = []
173
+ for entry, declared in matches:
174
+ receipt["entry_refs"].append(
175
+ {
176
+ "source_ref": declared["source_ref"],
177
+ "entry_hash": fingerprint(declared),
178
+ "output_hash": declared["output_hash"],
179
+ }
180
+ )
181
+ if any(
182
+ declared[key] is None
183
+ for key in (*required, "source_ref", "created_at", "expires_at")
184
+ ):
185
+ states.append("unknown_evidence")
186
+ elif any(declared[key] != value for key, value in required.items()):
187
+ states.append("invalidated")
188
+ elif timestamp(declared["created_at"]) >= timestamp(declared["expires_at"]):
189
+ states.append("invalid_lifetime")
190
+ elif timestamp(declared["created_at"]) > instant:
191
+ states.append("future_entry")
192
+ elif timestamp(declared["expires_at"]) <= instant:
193
+ states.append("stale")
194
+ else:
195
+ valid.append((entry, declared))
196
+ if valid:
197
+ newest = max(timestamp(declared["created_at"]) for _, declared in valid)
198
+ selected = [
199
+ (entry, declared)
200
+ for entry, declared in valid
201
+ if timestamp(declared["created_at"]) == newest
202
+ ]
203
+ if len({declared["output_hash"] for _, declared in selected}) == 1:
204
+ chosen = min(selected, key=lambda item: fingerprint(item[1]))
205
+ receipt.update(state="hit", selected_entry_hash=fingerprint(chosen[1]))
206
+ else:
207
+ receipt["state"] = "ambiguous"
208
+ elif states:
209
+ receipt["state"] = sorted(states)[0]
210
+ receipt["unusable_entry_states"] = sorted(states)
211
+ hit = receipt["state"] == "hit"
212
+ expired = not hit and on_unavailable == "recompute" and request.remaining_s <= 0
213
+ receipt["fallback"] = not hit and on_unavailable == "recompute" and not expired
214
+ record_experiment_observations(
215
+ {
216
+ "cache_hit": observation(hit, unit="boolean", source_ref=source),
217
+ "cache_state": observation(receipt["state"], unit="state", source_ref=source),
218
+ "cache_fallback_invoked": observation(
219
+ receipt["fallback"], unit="boolean", source_ref=source
220
+ ),
221
+ "cache_fill_cost_included": observation(
222
+ False, kind="declared", unit="boolean", source_ref=source
223
+ ),
224
+ }
225
+ )
226
+ if hit:
227
+ # No provider token work is performed by a snapshot hit. Lookup and
228
+ # prior fill operating costs remain unknown/excluded, not free.
229
+ # token_provenance is an accounting enum, not a credential.
230
+ return ExperimentResult(
231
+ chosen[0].output,
232
+ usage=ResourceUsage(tokens=0, token_provenance="user_supplied", complete=True), # nosec B106
233
+ )
234
+ if expired:
235
+ raise TimeoutError("reuse fallback deadline expired")
236
+ if on_unavailable == "error":
237
+ raise ValueError("reuse evidence is unavailable under the declared key contract")
238
+ return backend.invoke(request)
239
+
240
+ return ExperimentRunner(
241
+ candidate_id,
242
+ "1.0",
243
+ "agentloop.result-reuse:1.0",
244
+ "sha256:" + config_hash,
245
+ invoke,
246
+ reservation=reservation if reservation is not None else backend.reservation,
247
+ )
@@ -0,0 +1,116 @@
1
+ """Conservative checks before an explicitly configured model substitution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+
7
+ from agentloop.context_types import EXACT_COUNTS, ContextTokenCount
8
+ from agentloop.harness import _invoke_usage_reader
9
+
10
+
11
+ def check_route(backend, payload, *, streaming):
12
+ capabilities = backend.capabilities
13
+ reasons = []
14
+ if capabilities.adapter != "json_chat_v1":
15
+ reasons.append("unsupported_adapter")
16
+ if not set(payload) <= set(capabilities.parameters):
17
+ reasons.append("unsupported_parameters")
18
+ if streaming and (not capabilities.streaming or backend.stream is None):
19
+ reasons.append("streaming_unsupported")
20
+ if payload.get("stream", False) is not streaming:
21
+ reasons.append("stream_mode_mismatch")
22
+ messages = payload.get("messages")
23
+ modalities, has_tools = set(), bool(payload.get("tools"))
24
+ if not isinstance(messages, list) or not messages or len(messages) > 512:
25
+ reasons.append("invalid_messages")
26
+ messages = []
27
+ for message in messages:
28
+ if (
29
+ not isinstance(message, dict)
30
+ or not isinstance(message.get("role"), str)
31
+ or message.get("role")
32
+ not in {
33
+ "system",
34
+ "developer",
35
+ "user",
36
+ "assistant",
37
+ "tool",
38
+ }
39
+ ):
40
+ reasons.append("unsupported_message")
41
+ continue
42
+ has_tools = has_tools or message["role"] == "tool" or bool(message.get("tool_calls"))
43
+ content = message.get("content")
44
+ if isinstance(content, str):
45
+ modalities.add("text")
46
+ elif content is None and message["role"] == "assistant" and message.get("tool_calls"):
47
+ modalities.add("text")
48
+ elif isinstance(content, list) and content:
49
+ for part in content:
50
+ kind = part.get("type") if isinstance(part, dict) else None
51
+ mapped = (
52
+ {"text": "text", "image_url": "image", "input_audio": "audio"}.get(kind)
53
+ if isinstance(kind, str)
54
+ else None
55
+ )
56
+ if mapped is None:
57
+ reasons.append("unsupported_content")
58
+ else:
59
+ modalities.add(mapped)
60
+ else:
61
+ reasons.append("unsupported_content")
62
+ if not modalities <= set(capabilities.modalities):
63
+ reasons.append("modality_unsupported")
64
+ if has_tools and not capabilities.tool_calling:
65
+ reasons.append("tools_unsupported")
66
+ if payload.get("tools") is not None and (
67
+ not isinstance(payload["tools"], list)
68
+ or not payload["tools"]
69
+ or any(
70
+ not isinstance(tool, dict)
71
+ or tool.get("type") != "function"
72
+ or not isinstance(tool.get("function"), dict)
73
+ for tool in payload["tools"]
74
+ )
75
+ ):
76
+ reasons.append("unsupported_tool_format")
77
+ response = payload.get("response_format", {"type": "text"})
78
+ mode = response.get("type") if isinstance(response, dict) else None
79
+ if not isinstance(mode, str) or mode not in capabilities.output_modes:
80
+ reasons.append("output_mode_unsupported")
81
+ if mode == "json_schema" and (
82
+ not isinstance(response.get("json_schema"), dict)
83
+ or not isinstance(response["json_schema"].get("schema"), dict)
84
+ ):
85
+ reasons.append("invalid_output_schema")
86
+ bounds = [payload[key] for key in ("max_tokens", "max_completion_tokens") if key in payload]
87
+ output_bound = (
88
+ bounds[0] if len(bounds) == 1 and type(bounds[0]) is int and bounds[0] > 0 else None
89
+ )
90
+ if output_bound is None:
91
+ reasons.append("output_bound_unavailable")
92
+ count = ContextTokenCount()
93
+ count_attempted = not reasons
94
+ if count_attempted:
95
+ try:
96
+ value = _invoke_usage_reader(backend.token_counter, deepcopy(payload))
97
+ if type(value) is ContextTokenCount:
98
+ count = value
99
+ else:
100
+ reasons.append("invalid_token_count")
101
+ except Exception:
102
+ reasons.append("token_counter_failed")
103
+ if count.provenance not in EXACT_COUNTS or count.value is None:
104
+ reasons.append("context_count_unverified")
105
+ elif output_bound is not None and count.value + output_bound > capabilities.context_window:
106
+ reasons.append("context_limit")
107
+ return {
108
+ "supported": not reasons,
109
+ "reasons": sorted(set(reasons)),
110
+ "capability_reference": capabilities.reference,
111
+ "adapter": capabilities.adapter,
112
+ "input_tokens": count.to_dict(),
113
+ "token_count_attempted": count_attempted,
114
+ "output_token_bound": output_bound,
115
+ "context_window": capabilities.context_window,
116
+ }
@@ -0,0 +1,241 @@
1
+ """Owned requests and explicit provider/model capability declarations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import asdict, dataclass, field
7
+ from typing import Any, Callable
8
+
9
+ from agentloop.budget_types import DispatchOptions, ResourceUsage
10
+ from agentloop.context_types import ContextTokenCount, canonical, identifier, synchronous
11
+
12
+ ROUTING_VERSION = "1.0"
13
+ ROUTING_KEY = "agentloop.model_routing"
14
+ FAILURE_KINDS = frozenset({"rate_limit", "unavailable", "timeout", "provider_error"})
15
+
16
+
17
+ def model_name(value):
18
+ if (
19
+ not isinstance(value, str)
20
+ or not value
21
+ or len(value) > 512
22
+ or any(ord(char) < 32 for char in value)
23
+ ):
24
+ raise ValueError("model identity must be a bounded nonempty string")
25
+
26
+
27
+ class RoutingError(ValueError):
28
+ """A fixed failure category without provider payloads."""
29
+
30
+ def __init__(self, reason):
31
+ allowed = {
32
+ "invalid_request",
33
+ "unsupported_route",
34
+ "request_model_mismatch",
35
+ "invalid_response",
36
+ "inspection_failed",
37
+ "evidence_limit",
38
+ "invalid_trace_evidence",
39
+ "recursive_route",
40
+ "unsupported_stream",
41
+ "context_adapter_nesting",
42
+ }
43
+ self.reason_code = (
44
+ reason if isinstance(reason, str) and reason in allowed else "invalid_request"
45
+ )
46
+ super().__init__(f"Model routing failed: {self.reason_code}")
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class ModelIdentity:
51
+ provider: str
52
+ model: str
53
+
54
+ def __post_init__(self):
55
+ identifier(self.provider, "provider")
56
+ model_name(self.model)
57
+
58
+ def to_dict(self):
59
+ return asdict(self)
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ModelCapabilities:
64
+ reference: str
65
+ context_window: int
66
+ parameters: tuple[str, ...] = ("model", "messages", "max_tokens")
67
+ output_modes: tuple[str, ...] = ("text",)
68
+ modalities: tuple[str, ...] = ("text",)
69
+ tool_calling: bool = False
70
+ streaming: bool = False
71
+ adapter: str = "json_chat_v1"
72
+
73
+ def __post_init__(self):
74
+ identifier(self.reference, "capability reference")
75
+ if type(self.context_window) is not int or self.context_window < 1:
76
+ raise ValueError("context_window must be positive")
77
+ for name, allowed in (
78
+ ("output_modes", {"text", "json_object", "json_schema"}),
79
+ ("modalities", {"text", "image", "audio"}),
80
+ ("parameters", None),
81
+ ):
82
+ values = getattr(self, name)
83
+ if (
84
+ not isinstance(values, (tuple, list, set, frozenset))
85
+ or not values
86
+ or len(values) > 64
87
+ or any(
88
+ not isinstance(value, str) or not value or len(value) > 128 for value in values
89
+ )
90
+ ):
91
+ raise ValueError("capabilities require bounded string collections")
92
+ if allowed is not None and not set(values) <= allowed:
93
+ raise ValueError("unsupported capability declaration")
94
+ object.__setattr__(self, name, tuple(sorted(set(values))))
95
+ if (
96
+ type(self.tool_calling) is not bool
97
+ or type(self.streaming) is not bool
98
+ or not isinstance(self.adapter, str)
99
+ ):
100
+ raise ValueError("invalid adapter capability")
101
+
102
+ def to_dict(self):
103
+ return json.loads(canonical(asdict(self)))
104
+
105
+
106
+ @dataclass(frozen=True, init=False)
107
+ class RoutingRequest:
108
+ _json: str = field(repr=False)
109
+
110
+ def __init__(self, payload):
111
+ try:
112
+ if not isinstance(payload, dict):
113
+ raise ValueError()
114
+ canonical(payload)
115
+ encoded = json.dumps(
116
+ payload, ensure_ascii=False, separators=(",", ":"), allow_nan=False
117
+ )
118
+ if len(encoded.encode("utf-8")) > 1_000_000:
119
+ raise ValueError()
120
+ model_name(payload.get("model"))
121
+ except (ValueError, TypeError, RecursionError):
122
+ raise RoutingError("invalid_request") from None
123
+ object.__setattr__(self, "_json", encoded)
124
+
125
+ @property
126
+ def payload(self):
127
+ return json.loads(self._json)
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class RouteResponseInfo:
132
+ usage: ResourceUsage | None = None
133
+ reported_model: str | None = None
134
+
135
+ def __post_init__(self):
136
+ if self.usage is not None and type(self.usage) is not ResourceUsage:
137
+ raise ValueError("response usage must be normalized ResourceUsage")
138
+ if self.reported_model is not None:
139
+ model_name(self.reported_model)
140
+
141
+
142
+ class RouteProviderError(Exception):
143
+ """Host adapter classification permitting only configured failure fallbacks.
144
+
145
+ An SDK adapter may raise this from its original SDK exception. Usage is an
146
+ exclusive snapshot when available; absent billing is never inferred as zero.
147
+ """
148
+
149
+ def __init__(self, category, *, info=None):
150
+ if (
151
+ not isinstance(category, str)
152
+ or category not in FAILURE_KINDS
153
+ or (info is not None and type(info) is not RouteResponseInfo)
154
+ ):
155
+ raise ValueError("invalid provider failure declaration")
156
+ self.category = category
157
+ self.info = info or RouteResponseInfo()
158
+ super().__init__(f"Routed provider failure: {category}")
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class ModelBackend:
163
+ identity: ModelIdentity
164
+ capabilities: ModelCapabilities
165
+ invoke: Callable[[dict], Any] = field(repr=False, compare=False)
166
+ token_counter: Callable[[dict], ContextTokenCount] = field(repr=False, compare=False)
167
+ inspect_response: Callable[[Any], RouteResponseInfo] = field(repr=False, compare=False)
168
+ stream: Callable[[dict], Any] | None = field(default=None, repr=False, compare=False)
169
+ dispatch: DispatchOptions = field(default_factory=DispatchOptions)
170
+
171
+ def __post_init__(self):
172
+ if (
173
+ type(self.identity) is not ModelIdentity
174
+ or type(self.capabilities) is not ModelCapabilities
175
+ or type(self.dispatch) is not DispatchOptions
176
+ ):
177
+ raise ValueError("backend requires typed identity, capabilities and dispatch")
178
+ if any(
179
+ not synchronous(callback)
180
+ for callback in (self.invoke, self.token_counter, self.inspect_response)
181
+ ):
182
+ raise ValueError("backend callbacks must be synchronous")
183
+ if self.stream is not None and not synchronous(self.stream):
184
+ raise ValueError("stream factory must be synchronous and return an SDK iterator")
185
+ if self.capabilities.streaming and self.stream is None:
186
+ raise ValueError("streaming capability requires an explicit SDK stream factory")
187
+
188
+
189
+ @dataclass(frozen=True)
190
+ class RoutingConfig:
191
+ route_id: str
192
+ version: str
193
+ branch_id: str
194
+ quality_ref: str
195
+ original: ModelIdentity
196
+ target: ModelIdentity
197
+ allowlist: tuple[ModelIdentity, ...]
198
+ fallbacks: tuple[ModelIdentity, ...] = ()
199
+ fallback_on: tuple[str, ...] = ()
200
+ on_unsupported: str = "error"
201
+ allow_cross_provider: bool = False
202
+
203
+ def __post_init__(self):
204
+ for name in ("route_id", "version", "branch_id", "quality_ref"):
205
+ identifier(getattr(self, name), name)
206
+ if type(self.original) is not ModelIdentity or type(self.target) is not ModelIdentity:
207
+ raise ValueError("route requires typed original and target models")
208
+ for name, maximum in (("allowlist", 32), ("fallbacks", 2)):
209
+ values = getattr(self, name)
210
+ if (
211
+ not isinstance(values, (list, tuple))
212
+ or len(values) > maximum
213
+ or any(type(item) is not ModelIdentity for item in values)
214
+ or len(set(values)) != len(values)
215
+ ):
216
+ raise ValueError("route models must be unique and bounded")
217
+ object.__setattr__(self, name, tuple(values))
218
+ if (
219
+ not {self.original, self.target, *self.fallbacks} <= set(self.allowlist)
220
+ or self.target in self.fallbacks
221
+ ):
222
+ raise ValueError(
223
+ "every route endpoint must be allowlisted; fallback cannot repeat target"
224
+ )
225
+ if not isinstance(self.fallback_on, (list, tuple)) or any(
226
+ not isinstance(value, str) or value not in FAILURE_KINDS for value in self.fallback_on
227
+ ):
228
+ raise ValueError("fallback requires explicit supported failure categories")
229
+ object.__setattr__(self, "fallback_on", tuple(sorted(set(self.fallback_on))))
230
+ if (
231
+ self.on_unsupported not in {"error", "original"}
232
+ or type(self.allow_cross_provider) is not bool
233
+ ):
234
+ raise ValueError("invalid unsupported/cross-provider policy")
235
+ if not self.allow_cross_provider and any(
236
+ item.provider != self.original.provider for item in (self.target, *self.fallbacks)
237
+ ):
238
+ raise ValueError("cross-provider routing requires explicit configuration")
239
+
240
+ def to_dict(self):
241
+ return json.loads(canonical(asdict(self)))