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.
- agentloop/__init__.py +136 -0
- agentloop/ablation_protocol.py +229 -0
- agentloop/ablations.py +564 -0
- agentloop/aggregate_cli.py +88 -0
- agentloop/aggregate_stats.py +175 -0
- agentloop/aggregates.py +532 -0
- agentloop/audit.py +39 -0
- agentloop/autoinstrument.py +145 -0
- agentloop/batch_experiments.py +295 -0
- agentloop/batching.py +95 -0
- agentloop/budget_types.py +335 -0
- agentloop/budgets.py +539 -0
- agentloop/calibration.py +872 -0
- agentloop/checkpoint_state.py +306 -0
- agentloop/checkpoint_store.py +86 -0
- agentloop/ci.py +202 -0
- agentloop/cli.py +1314 -0
- agentloop/client.py +228 -0
- agentloop/completion.py +345 -0
- agentloop/completion_types.py +199 -0
- agentloop/config.py +50 -0
- agentloop/context_controls.py +443 -0
- agentloop/context_experiments.py +337 -0
- agentloop/context_types.py +216 -0
- agentloop/costs.py +667 -0
- agentloop/cursor_validation.py +48 -0
- agentloop/decorators.py +445 -0
- agentloop/demo.py +300 -0
- agentloop/doctor.py +341 -0
- agentloop/drift.py +361 -0
- agentloop/drift_cli.py +147 -0
- agentloop/drift_types.py +110 -0
- agentloop/drift_windows.py +340 -0
- agentloop/entrypoint.py +201 -0
- agentloop/estimates.py +133 -0
- agentloop/events.py +75 -0
- agentloop/experiment_artifacts.py +80 -0
- agentloop/experiment_observations.py +169 -0
- agentloop/experiment_reports.py +545 -0
- agentloop/experiment_types.py +355 -0
- agentloop/experiments.py +392 -0
- agentloop/exporters.py +117 -0
- agentloop/finding_benchmark_types.py +131 -0
- agentloop/finding_benchmarks.py +424 -0
- agentloop/findings.py +419 -0
- agentloop/graph.py +464 -0
- agentloop/harness.py +825 -0
- agentloop/harness_bench/__init__.py +1 -0
- agentloop/harness_bench/cli.py +77 -0
- agentloop/harness_bench/drivers.py +96 -0
- agentloop/harness_bench/offline.py +507 -0
- agentloop/harness_evidence.py +585 -0
- agentloop/harness_usage.py +91 -0
- agentloop/html_report.py +860 -0
- agentloop/integrations/__init__.py +1 -0
- agentloop/integrations/crewai.py +144 -0
- agentloop/integrations/harbor/__init__.py +1 -0
- agentloop/integrations/harbor/atif.py +982 -0
- agentloop/integrations/harbor/atif_validation.py +329 -0
- agentloop/integrations/harbor/cli.py +207 -0
- agentloop/integrations/harbor/manifest.py +86 -0
- agentloop/integrations/harbor/trial_evidence.py +196 -0
- agentloop/integrations/harbor/trials.py +1109 -0
- agentloop/integrations/harbor/verifier.py +118 -0
- agentloop/integrations/langgraph.py +150 -0
- agentloop/integrations/langgraph_harness.py +466 -0
- agentloop/integrations/langgraph_recovery.py +366 -0
- agentloop/integrations/omnigent/__init__.py +1 -0
- agentloop/integrations/omnigent/cli.py +78 -0
- agentloop/integrations/omnigent/evidence.py +93 -0
- agentloop/integrations/omnigent/telemetry.py +422 -0
- agentloop/integrations/openai.py +415 -0
- agentloop/integrations/openai_agents.py +291 -0
- agentloop/integrations/sdk.py +82 -0
- agentloop/integrations/vercel_ai.py +83 -0
- agentloop/interoperability/__init__.py +5 -0
- agentloop/interoperability/artifacts.py +112 -0
- agentloop/interoperability/cli.py +59 -0
- agentloop/interoperability/cohort_evidence.py +175 -0
- agentloop/interoperability/cohorts.py +681 -0
- agentloop/interoperability/contracts.py +427 -0
- agentloop/interoperability/coordination.py +554 -0
- agentloop/interoperability/evidence.py +215 -0
- agentloop/interoperability/otlp_jsonl.py +775 -0
- agentloop/interoperability/otlp_validation.py +496 -0
- agentloop/interoperability/registry.py +561 -0
- agentloop/interoperability/validation.py +333 -0
- agentloop/intervention_service.py +110 -0
- agentloop/intervention_store.py +118 -0
- agentloop/interventions.py +190 -0
- agentloop/issues.py +90 -0
- agentloop/judgment_adapters.py +125 -0
- agentloop/judgment_benchmark_types.py +98 -0
- agentloop/judgment_benchmarks.py +606 -0
- agentloop/judgment_types.py +241 -0
- agentloop/judgment_views.py +38 -0
- agentloop/judgments.py +342 -0
- agentloop/loop_guards.py +274 -0
- agentloop/loop_types.py +66 -0
- agentloop/markdown.py +102 -0
- agentloop/metrics.py +342 -0
- agentloop/migrations.py +303 -0
- agentloop/model_routing.py +470 -0
- agentloop/onboarding.py +315 -0
- agentloop/operations.py +44 -0
- agentloop/optimizer.py +199 -0
- agentloop/otel.py +715 -0
- agentloop/otel_ids.py +81 -0
- agentloop/otel_semantics.py +134 -0
- agentloop/parallelism.py +141 -0
- agentloop/patches.py +603 -0
- agentloop/plan_export.py +126 -0
- agentloop/policy_promotion.py +325 -0
- agentloop/promotion_evidence.py +304 -0
- agentloop/promotion_types.py +209 -0
- agentloop/quality.py +446 -0
- agentloop/ranking.py +529 -0
- agentloop/recovery_evidence.py +96 -0
- agentloop/replay.py +753 -0
- agentloop/retention.py +375 -0
- agentloop/retention_types.py +113 -0
- agentloop/reuse_experiments.py +247 -0
- agentloop/routing_checks.py +116 -0
- agentloop/routing_types.py +241 -0
- agentloop/rules.py +396 -0
- agentloop/runtime.py +286 -0
- agentloop/savings.py +158 -0
- agentloop/scheduling_plan.py +99 -0
- agentloop/scheduling_types.py +169 -0
- agentloop/schema.py +257 -0
- agentloop/semantic_rules.py +200 -0
- agentloop/semantic_waste.py +345 -0
- agentloop/semantic_waste_types.py +88 -0
- agentloop/server.py +494 -0
- agentloop/store.py +1429 -0
- agentloop/structural_experiments.py +431 -0
- agentloop/structural_types.py +113 -0
- agentloop/structured_quality.py +1020 -0
- agentloop/studies.py +629 -0
- agentloop/study_statistics.py +58 -0
- agentloop/substitution_evidence.py +199 -0
- agentloop/substitution_export.py +190 -0
- agentloop/substitution_reports.py +407 -0
- agentloop/substitution_types.py +284 -0
- agentloop/substitutions.py +267 -0
- agentloop/timing.py +142 -0
- agentloop/tokens.py +188 -0
- agentloop/tool_scheduling.py +410 -0
- agentloop/trace_sources.py +31 -0
- agentloop/tracer.py +494 -0
- agentloop/value.py +306 -0
- agentloop/version.py +3 -0
- agentloop/workflow_types.py +264 -0
- agentloop/workflows.py +284 -0
- agentloop_profiler-0.1.0.dist-info/METADATA +513 -0
- agentloop_profiler-0.1.0.dist-info/RECORD +166 -0
- agentloop_profiler-0.1.0.dist-info/WHEEL +5 -0
- agentloop_profiler-0.1.0.dist-info/entry_points.txt +2 -0
- agentloop_profiler-0.1.0.dist-info/licenses/LICENSE +201 -0
- agentloop_profiler-0.1.0.dist-info/licenses/THIRD_PARTY_LICENSES.md +101 -0
- agentloop_profiler-0.1.0.dist-info/top_level.txt +2 -0
- dashboard/__init__.py +5 -0
- dashboard/app.py +922 -0
- dashboard/streamlit_compat.py +63 -0
- dashboard/trace_upload.py +57 -0
- dashboard/value_view.py +105 -0
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Explicit model routes and bounded, independently metered provider attempts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
from threading import RLock
|
|
10
|
+
from time import perf_counter
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
from agentloop.budget_types import ResourceUsage
|
|
15
|
+
from agentloop.context_types import fingerprint
|
|
16
|
+
from agentloop.harness import (
|
|
17
|
+
Decision,
|
|
18
|
+
HarnessControlError,
|
|
19
|
+
HarnessRun,
|
|
20
|
+
Hook,
|
|
21
|
+
Policy,
|
|
22
|
+
_invoke_usage_reader,
|
|
23
|
+
)
|
|
24
|
+
from agentloop.routing_checks import check_route
|
|
25
|
+
from agentloop.routing_types import (
|
|
26
|
+
ROUTING_KEY,
|
|
27
|
+
ROUTING_VERSION,
|
|
28
|
+
ModelBackend,
|
|
29
|
+
RouteProviderError,
|
|
30
|
+
RouteResponseInfo,
|
|
31
|
+
RoutingConfig,
|
|
32
|
+
RoutingError,
|
|
33
|
+
RoutingRequest,
|
|
34
|
+
)
|
|
35
|
+
from agentloop.tracer import current_trace
|
|
36
|
+
|
|
37
|
+
_ACTIVE = ContextVar("agentloop_model_route", default=None)
|
|
38
|
+
_EVIDENCE_LOCK = RLock()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class _RouteAdmission:
|
|
43
|
+
config: RoutingConfig
|
|
44
|
+
|
|
45
|
+
def __call__(self, context):
|
|
46
|
+
if context.branch_id != self.config.branch_id:
|
|
47
|
+
return Decision("continue", "route_not_selected")
|
|
48
|
+
active = _ACTIVE.get()
|
|
49
|
+
if (
|
|
50
|
+
active is None
|
|
51
|
+
or active["used"]
|
|
52
|
+
or active["config"] != self.config
|
|
53
|
+
or active["run_id"] != context.run_id
|
|
54
|
+
):
|
|
55
|
+
return Decision("deny", "route_adapter_required")
|
|
56
|
+
active["used"] = True
|
|
57
|
+
active["attempt"]["harness_call_id"] = context.call_id
|
|
58
|
+
return Decision("continue", "explicit_route_admitted")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def routing_policy(config):
|
|
62
|
+
if type(config) is not RoutingConfig:
|
|
63
|
+
raise ValueError("routing policy requires RoutingConfig")
|
|
64
|
+
return Policy(
|
|
65
|
+
config.route_id,
|
|
66
|
+
config.version,
|
|
67
|
+
_RouteAdmission(config),
|
|
68
|
+
hooks=frozenset({Hook("model")}),
|
|
69
|
+
actions=frozenset({"continue", "deny"}),
|
|
70
|
+
priority=50,
|
|
71
|
+
configuration=config.to_dict(),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class _Frame:
|
|
77
|
+
value: object = None
|
|
78
|
+
info: RouteResponseInfo | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ModelRouter:
|
|
82
|
+
"""One supported synchronous or streaming model boundary per actual attempt.
|
|
83
|
+
|
|
84
|
+
Backends are trusted host-owned SDK adapters, not dynamically loaded code.
|
|
85
|
+
No router decision establishes task quality. Automatic SDK retries must be
|
|
86
|
+
disabled or fully accounted for by the host adapter's usage contract.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, run, *, config, backends, max_records=256):
|
|
90
|
+
if not isinstance(run, HarnessRun) or type(config) is not RoutingConfig:
|
|
91
|
+
raise ValueError("typed run and routing configuration required")
|
|
92
|
+
if (
|
|
93
|
+
not isinstance(backends, (list, tuple))
|
|
94
|
+
or not backends
|
|
95
|
+
or any(type(item) is not ModelBackend for item in backends)
|
|
96
|
+
):
|
|
97
|
+
raise ValueError("explicit typed backends required")
|
|
98
|
+
registry = {item.identity: item for item in backends}
|
|
99
|
+
if (
|
|
100
|
+
len(registry) != len(backends)
|
|
101
|
+
or not {config.original, config.target, *config.fallbacks} <= registry.keys()
|
|
102
|
+
or not registry.keys() <= set(config.allowlist)
|
|
103
|
+
):
|
|
104
|
+
raise ValueError("backends must be unique, complete and allowlisted")
|
|
105
|
+
if type(max_records) is not int or not 1 <= max_records <= 10000:
|
|
106
|
+
raise ValueError("max_records must be bounded")
|
|
107
|
+
expected = routing_policy(config)
|
|
108
|
+
if run.harness.config.mode != "disabled" and not any(
|
|
109
|
+
policy.config_hash == expected.config_hash and type(policy.evaluate) is _RouteAdmission
|
|
110
|
+
for policy in run.harness.config.policies
|
|
111
|
+
):
|
|
112
|
+
raise ValueError("register the matching routing_policy before binding")
|
|
113
|
+
self._run, self._config, self._backends = run, config, MappingProxyType(registry)
|
|
114
|
+
self._max_records, self._policy_hash = max_records, expected.config_hash
|
|
115
|
+
self._records, self._lock, self._capture_failed = {}, RLock(), False
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def run(self):
|
|
119
|
+
return self._run
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def config(self):
|
|
123
|
+
return self._config
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def backends(self):
|
|
127
|
+
return self._backends
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def max_records(self):
|
|
131
|
+
return self._max_records
|
|
132
|
+
|
|
133
|
+
def export_evidence(self):
|
|
134
|
+
with self._lock:
|
|
135
|
+
return {"schema_version": ROUTING_VERSION, "records": deepcopy(self._records)}
|
|
136
|
+
|
|
137
|
+
def _info(self, backend, value, attempt):
|
|
138
|
+
try:
|
|
139
|
+
info = _invoke_usage_reader(backend.inspect_response, value)
|
|
140
|
+
if type(info) is not RouteResponseInfo:
|
|
141
|
+
if inspect.iscoroutine(info):
|
|
142
|
+
info.close()
|
|
143
|
+
raise ValueError()
|
|
144
|
+
except Exception:
|
|
145
|
+
attempt["inspection_error"] = "response_inspection_failed"
|
|
146
|
+
if attempt["usage"] is not None:
|
|
147
|
+
attempt["last_known_usage"] = attempt["usage"]
|
|
148
|
+
return self._record_info(backend, RouteResponseInfo(ResourceUsage()), attempt)
|
|
149
|
+
return self._record_info(backend, info, attempt)
|
|
150
|
+
|
|
151
|
+
def _record_info(self, backend, info, attempt):
|
|
152
|
+
usage = info.usage
|
|
153
|
+
if usage is not None and usage.usage_id is not None:
|
|
154
|
+
usage = replace(
|
|
155
|
+
usage, usage_id="route:" + fingerprint([backend.identity.provider, usage.usage_id])
|
|
156
|
+
)
|
|
157
|
+
info = RouteResponseInfo(usage, info.reported_model)
|
|
158
|
+
if info.reported_model is not None:
|
|
159
|
+
attempt["provider_reported_model"] = info.reported_model
|
|
160
|
+
if usage is not None:
|
|
161
|
+
attempt["usage"] = usage.to_dict()
|
|
162
|
+
return info
|
|
163
|
+
|
|
164
|
+
def _invoke(self, backend, payload, attempt):
|
|
165
|
+
attempt["provider_invoked"] = True
|
|
166
|
+
result = backend.invoke(deepcopy(payload))
|
|
167
|
+
if inspect.isawaitable(result) or hasattr(result, "__next__"):
|
|
168
|
+
close = getattr(result, "close", None)
|
|
169
|
+
if callable(close):
|
|
170
|
+
close()
|
|
171
|
+
raise RoutingError("invalid_response")
|
|
172
|
+
return _Frame(result, self._info(backend, result, attempt))
|
|
173
|
+
|
|
174
|
+
def _stream_frames(self, backend, payload, attempt, state):
|
|
175
|
+
source, failure = None, None
|
|
176
|
+
try:
|
|
177
|
+
attempt["provider_invoked"] = True
|
|
178
|
+
source = backend.stream(deepcopy(payload))
|
|
179
|
+
if inspect.isawaitable(source):
|
|
180
|
+
close = getattr(source, "close", None)
|
|
181
|
+
if callable(close):
|
|
182
|
+
close()
|
|
183
|
+
raise RoutingError("unsupported_stream")
|
|
184
|
+
for chunk in source:
|
|
185
|
+
info = self._info(backend, chunk, attempt)
|
|
186
|
+
attempt["chunks"] += 1
|
|
187
|
+
yield _Frame(chunk, info)
|
|
188
|
+
except BaseException as exc:
|
|
189
|
+
failure = exc
|
|
190
|
+
raise
|
|
191
|
+
finally:
|
|
192
|
+
close = getattr(source, "close", None)
|
|
193
|
+
if callable(close):
|
|
194
|
+
try:
|
|
195
|
+
close()
|
|
196
|
+
except BaseException as exc:
|
|
197
|
+
attempt["cleanup_error"] = "stream_close_failed"
|
|
198
|
+
if isinstance(exc, RouteProviderError):
|
|
199
|
+
state["close_info"] = exc.info
|
|
200
|
+
if failure is None:
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
def _attempt(self, backend, payload, attempt, streaming, fallback):
|
|
204
|
+
dispatch = (
|
|
205
|
+
replace(backend.dispatch, retry_source="harness") if fallback else backend.dispatch
|
|
206
|
+
)
|
|
207
|
+
state = {}
|
|
208
|
+
|
|
209
|
+
def error_usage(error):
|
|
210
|
+
info = state.get("close_info")
|
|
211
|
+
if info is None and isinstance(error, RouteProviderError):
|
|
212
|
+
info = error.info
|
|
213
|
+
return self._record_info(backend, info, attempt).usage if info is not None else None
|
|
214
|
+
|
|
215
|
+
wrapped = self.run.wrap(
|
|
216
|
+
self._stream_frames if streaming else self._invoke,
|
|
217
|
+
boundary="model",
|
|
218
|
+
branch_id=self.config.branch_id,
|
|
219
|
+
dispatch=dispatch,
|
|
220
|
+
usage_reader=lambda frame: frame.info.usage if frame.info is not None else None,
|
|
221
|
+
error_usage_reader=error_usage,
|
|
222
|
+
)
|
|
223
|
+
binding = {
|
|
224
|
+
"config": self.config,
|
|
225
|
+
"run_id": self.run.run_id,
|
|
226
|
+
"attempt": attempt,
|
|
227
|
+
"used": False,
|
|
228
|
+
}
|
|
229
|
+
iterator = None
|
|
230
|
+
start = perf_counter()
|
|
231
|
+
try:
|
|
232
|
+
if not streaming:
|
|
233
|
+
token = _ACTIVE.set(binding)
|
|
234
|
+
try:
|
|
235
|
+
frame = wrapped(backend, payload, attempt)
|
|
236
|
+
finally:
|
|
237
|
+
_ACTIVE.reset(token)
|
|
238
|
+
attempt["status"] = "completed"
|
|
239
|
+
yield frame.value
|
|
240
|
+
return
|
|
241
|
+
iterator = wrapped(backend, payload, attempt, state)
|
|
242
|
+
while True:
|
|
243
|
+
token = _ACTIVE.set(binding)
|
|
244
|
+
try:
|
|
245
|
+
frame = next(iterator)
|
|
246
|
+
except StopIteration:
|
|
247
|
+
attempt["status"] = "completed"
|
|
248
|
+
return
|
|
249
|
+
finally:
|
|
250
|
+
_ACTIVE.reset(token)
|
|
251
|
+
yield frame.value
|
|
252
|
+
except BaseException as exc:
|
|
253
|
+
attempt["status"] = (
|
|
254
|
+
"cancelled"
|
|
255
|
+
if not isinstance(exc, Exception)
|
|
256
|
+
else "blocked"
|
|
257
|
+
if isinstance(exc, HarnessControlError)
|
|
258
|
+
else "failed"
|
|
259
|
+
)
|
|
260
|
+
attempt["reason"] = (
|
|
261
|
+
exc.category
|
|
262
|
+
if isinstance(exc, RouteProviderError)
|
|
263
|
+
else "harness_control"
|
|
264
|
+
if isinstance(exc, HarnessControlError)
|
|
265
|
+
else "cancelled"
|
|
266
|
+
if not isinstance(exc, Exception)
|
|
267
|
+
else "provider_error"
|
|
268
|
+
)
|
|
269
|
+
raise
|
|
270
|
+
finally:
|
|
271
|
+
try:
|
|
272
|
+
if iterator is not None:
|
|
273
|
+
iterator.close()
|
|
274
|
+
finally:
|
|
275
|
+
attempt["latency_ms"] = (perf_counter() - start) * 1000
|
|
276
|
+
|
|
277
|
+
def _drive(self, request, streaming):
|
|
278
|
+
if type(request) is not RoutingRequest:
|
|
279
|
+
raise TypeError("router requires RoutingRequest")
|
|
280
|
+
payload = request.payload
|
|
281
|
+
if payload["model"] != self.config.original.model:
|
|
282
|
+
raise RoutingError("request_model_mismatch")
|
|
283
|
+
if payload.get("stream", False) is not streaming:
|
|
284
|
+
raise RoutingError("unsupported_stream")
|
|
285
|
+
original = self.backends[self.config.original]
|
|
286
|
+
if self.run.harness.config.mode == "disabled":
|
|
287
|
+
if streaming:
|
|
288
|
+
if original.stream is None:
|
|
289
|
+
raise RoutingError("unsupported_stream")
|
|
290
|
+
source = original.stream(payload)
|
|
291
|
+
try:
|
|
292
|
+
for chunk in source:
|
|
293
|
+
yield chunk
|
|
294
|
+
finally:
|
|
295
|
+
close = getattr(source, "close", None)
|
|
296
|
+
if callable(close):
|
|
297
|
+
close()
|
|
298
|
+
else:
|
|
299
|
+
yield original.invoke(payload)
|
|
300
|
+
return
|
|
301
|
+
if _ACTIVE.get() is not None:
|
|
302
|
+
raise RoutingError("recursive_route")
|
|
303
|
+
# Both adapters otherwise count the same physical call. An explicit
|
|
304
|
+
# shared dispatch integration is required before composing them.
|
|
305
|
+
from agentloop.context_controls import _ACTIVE as _CONTEXT_ACTIVE
|
|
306
|
+
|
|
307
|
+
if _CONTEXT_ACTIVE.get() is not None:
|
|
308
|
+
raise RoutingError("context_adapter_nesting")
|
|
309
|
+
identity = "route_" + uuid4().hex
|
|
310
|
+
receipt = {
|
|
311
|
+
"record_id": identity,
|
|
312
|
+
"route_id": self.config.route_id,
|
|
313
|
+
"route_version": self.config.version,
|
|
314
|
+
"route_config_hash": self._policy_hash,
|
|
315
|
+
"quality_ref": self.config.quality_ref,
|
|
316
|
+
"mode": self.run.harness.config.mode,
|
|
317
|
+
"requested": self.config.original.to_dict(),
|
|
318
|
+
"configured_target": self.config.target.to_dict(),
|
|
319
|
+
"original_hash": fingerprint(payload),
|
|
320
|
+
"status": "pending",
|
|
321
|
+
"reason": None,
|
|
322
|
+
"capability_checks": [],
|
|
323
|
+
"attempts": [],
|
|
324
|
+
}
|
|
325
|
+
trace = current_trace()
|
|
326
|
+
with self._lock, _EVIDENCE_LOCK:
|
|
327
|
+
if self._capture_failed:
|
|
328
|
+
raise RoutingError("invalid_trace_evidence")
|
|
329
|
+
if len(self._records) >= self.max_records:
|
|
330
|
+
raise RoutingError("evidence_limit")
|
|
331
|
+
if trace is not None:
|
|
332
|
+
envelope = trace.metadata.setdefault(
|
|
333
|
+
ROUTING_KEY, {"schema_version": ROUTING_VERSION, "records": {}}
|
|
334
|
+
)
|
|
335
|
+
if (
|
|
336
|
+
not isinstance(envelope, dict)
|
|
337
|
+
or envelope.get("schema_version") != ROUTING_VERSION
|
|
338
|
+
or not isinstance(envelope.get("records"), dict)
|
|
339
|
+
or len(envelope["records"]) >= 10000
|
|
340
|
+
):
|
|
341
|
+
raise RoutingError("invalid_trace_evidence")
|
|
342
|
+
envelope["records"][identity] = deepcopy(receipt)
|
|
343
|
+
self._records[identity] = deepcopy(receipt)
|
|
344
|
+
failure = None
|
|
345
|
+
started = perf_counter()
|
|
346
|
+
try:
|
|
347
|
+
kind = "generator" if streaming else "sync"
|
|
348
|
+
if (
|
|
349
|
+
kind not in self.run.harness.capabilities.execution_kinds
|
|
350
|
+
or not {Hook("model"), Hook("model", "after")}
|
|
351
|
+
<= self.run.harness.capabilities.hooks
|
|
352
|
+
):
|
|
353
|
+
receipt["reason"] = "unsupported_harness_lifecycle"
|
|
354
|
+
raise RoutingError("unsupported_route")
|
|
355
|
+
target_payload = {**payload, "model": self.config.target.model}
|
|
356
|
+
checks = check_route(
|
|
357
|
+
self.backends[self.config.target], target_payload, streaming=streaming
|
|
358
|
+
)
|
|
359
|
+
receipt["capability_checks"].append({"model": self.config.target.to_dict(), **checks})
|
|
360
|
+
if self.run.harness.config.mode == "shadow":
|
|
361
|
+
choices, receipt["reason"] = (self.config.original,), "shadow_original"
|
|
362
|
+
elif not checks["supported"]:
|
|
363
|
+
if self.config.on_unsupported == "error":
|
|
364
|
+
receipt["reason"] = "unsupported_route"
|
|
365
|
+
raise RoutingError("unsupported_route")
|
|
366
|
+
choices, receipt["reason"] = (self.config.original,), "unsupported_original"
|
|
367
|
+
else:
|
|
368
|
+
choices, receipt["reason"] = (
|
|
369
|
+
(self.config.target, *self.config.fallbacks),
|
|
370
|
+
"explicit_configuration",
|
|
371
|
+
)
|
|
372
|
+
for index, selected in enumerate(choices):
|
|
373
|
+
backend = self.backends[selected]
|
|
374
|
+
candidate = {**payload, "model": selected.model}
|
|
375
|
+
if index:
|
|
376
|
+
checks = check_route(backend, candidate, streaming=streaming)
|
|
377
|
+
receipt["capability_checks"].append({"model": selected.to_dict(), **checks})
|
|
378
|
+
if not checks["supported"]:
|
|
379
|
+
receipt["reason"] = "fallback_unsupported"
|
|
380
|
+
raise RoutingError("unsupported_route")
|
|
381
|
+
if streaming and backend.stream is None:
|
|
382
|
+
raise RoutingError("unsupported_stream")
|
|
383
|
+
attempt = {
|
|
384
|
+
"index": index,
|
|
385
|
+
"selected": selected.to_dict(),
|
|
386
|
+
"capability_reference": backend.capabilities.reference,
|
|
387
|
+
"request_hash": fingerprint(candidate),
|
|
388
|
+
"provider_reported_model": None,
|
|
389
|
+
"provider_invoked": False,
|
|
390
|
+
"harness_call_id": None,
|
|
391
|
+
"status": "pending",
|
|
392
|
+
"reason": None,
|
|
393
|
+
"usage": None,
|
|
394
|
+
"chunks": 0,
|
|
395
|
+
"latency_ms": None,
|
|
396
|
+
}
|
|
397
|
+
receipt["attempts"].append(attempt)
|
|
398
|
+
emitted = False
|
|
399
|
+
iterator = self._attempt(backend, candidate, attempt, streaming, bool(index))
|
|
400
|
+
try:
|
|
401
|
+
for value in iterator:
|
|
402
|
+
emitted = True
|
|
403
|
+
yield value
|
|
404
|
+
receipt["status"] = "completed"
|
|
405
|
+
return
|
|
406
|
+
except RouteProviderError as exc:
|
|
407
|
+
if (
|
|
408
|
+
emitted
|
|
409
|
+
or exc.category not in self.config.fallback_on
|
|
410
|
+
or index + 1 == len(choices)
|
|
411
|
+
or self.run.stopped
|
|
412
|
+
):
|
|
413
|
+
raise
|
|
414
|
+
receipt["reason"] = "configured_failure_fallback"
|
|
415
|
+
finally:
|
|
416
|
+
iterator.close()
|
|
417
|
+
except BaseException as exc:
|
|
418
|
+
failure = exc
|
|
419
|
+
receipt["status"] = (
|
|
420
|
+
"cancelled"
|
|
421
|
+
if not isinstance(exc, Exception)
|
|
422
|
+
else "blocked"
|
|
423
|
+
if isinstance(exc, HarnessControlError)
|
|
424
|
+
else "failed"
|
|
425
|
+
)
|
|
426
|
+
receipt["error_code"] = (
|
|
427
|
+
exc.reason_code
|
|
428
|
+
if isinstance(exc, RoutingError)
|
|
429
|
+
else exc.category
|
|
430
|
+
if isinstance(exc, RouteProviderError)
|
|
431
|
+
else "cancelled"
|
|
432
|
+
if not isinstance(exc, Exception)
|
|
433
|
+
else "harness_control"
|
|
434
|
+
if isinstance(exc, HarnessControlError)
|
|
435
|
+
else "provider_error"
|
|
436
|
+
)
|
|
437
|
+
raise
|
|
438
|
+
finally:
|
|
439
|
+
receipt["latency_ms"] = (perf_counter() - started) * 1000
|
|
440
|
+
with self._lock, _EVIDENCE_LOCK:
|
|
441
|
+
capture_error = False
|
|
442
|
+
if trace is not None:
|
|
443
|
+
try:
|
|
444
|
+
current = trace.metadata[ROUTING_KEY]
|
|
445
|
+
if (
|
|
446
|
+
current is not envelope
|
|
447
|
+
or current.get("schema_version") != ROUTING_VERSION
|
|
448
|
+
or not isinstance(current["records"], dict)
|
|
449
|
+
):
|
|
450
|
+
raise ValueError()
|
|
451
|
+
current["records"][identity] = deepcopy(receipt)
|
|
452
|
+
except (KeyError, TypeError, ValueError):
|
|
453
|
+
capture_error = self._capture_failed = True
|
|
454
|
+
receipt["capture_error"] = "invalid_trace_evidence"
|
|
455
|
+
self._records[identity] = deepcopy(receipt)
|
|
456
|
+
if capture_error and failure is None:
|
|
457
|
+
raise RoutingError("invalid_trace_evidence")
|
|
458
|
+
|
|
459
|
+
def __call__(self, request):
|
|
460
|
+
values = self._drive(request, False)
|
|
461
|
+
result = None
|
|
462
|
+
try:
|
|
463
|
+
for result in values:
|
|
464
|
+
pass
|
|
465
|
+
finally:
|
|
466
|
+
values.close()
|
|
467
|
+
return result
|
|
468
|
+
|
|
469
|
+
def stream(self, request):
|
|
470
|
+
yield from self._drive(request, True)
|