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,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)))
|