consequa-x 0.1.0a2__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.
- consequa_x/__init__.py +22 -0
- consequa_x/__main__.py +3 -0
- consequa_x/adapters/__init__.py +5 -0
- consequa_x/adapters/ollama.py +350 -0
- consequa_x/attribution.py +407 -0
- consequa_x/benchmark/__init__.py +1 -0
- consequa_x/benchmark/assessment_metrics.py +54 -0
- consequa_x/benchmark/calibration.py +787 -0
- consequa_x/benchmark/calibration_export.py +139 -0
- consequa_x/benchmark/calibration_mechanisms.py +266 -0
- consequa_x/benchmark/decision_coalition.py +254 -0
- consequa_x/benchmark/decision_contrastive.py +448 -0
- consequa_x/benchmark/decision_replay.py +329 -0
- consequa_x/benchmark/decision_shapley.py +628 -0
- consequa_x/benchmark/evaluation.py +187 -0
- consequa_x/benchmark/interaction_export.py +220 -0
- consequa_x/benchmark/interaction_mechanisms.py +192 -0
- consequa_x/benchmark/interval_comparison.py +276 -0
- consequa_x/benchmark/intervals.py +164 -0
- consequa_x/benchmark/payments.py +426 -0
- consequa_x/benchmark/policy_experiment.py +298 -0
- consequa_x/benchmark/policy_runner.py +234 -0
- consequa_x/benchmark/runner.py +241 -0
- consequa_x/benchmark/study.py +183 -0
- consequa_x/benchmark/targets.py +149 -0
- consequa_x/benchmark/templates.py +102 -0
- consequa_x/cli.py +1191 -0
- consequa_x/explanation.py +130 -0
- consequa_x/graph.py +142 -0
- consequa_x/models.py +532 -0
- consequa_x/policies.py +248 -0
- consequa_x/py.typed +0 -0
- consequa_x/schema.py +159 -0
- consequa_x/schemas/trace-v0.1.json +543 -0
- consequa_x/verification.py +192 -0
- consequa_x-0.1.0a2.dist-info/METADATA +303 -0
- consequa_x-0.1.0a2.dist-info/RECORD +41 -0
- consequa_x-0.1.0a2.dist-info/WHEEL +5 -0
- consequa_x-0.1.0a2.dist-info/entry_points.txt +2 -0
- consequa_x-0.1.0a2.dist-info/licenses/LICENSE +21 -0
- consequa_x-0.1.0a2.dist-info/top_level.txt +1 -0
consequa_x/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Evidence-aware consequence explanations for agent applications."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0a2"
|
|
4
|
+
|
|
5
|
+
from .attribution import EffectEstimate, Intervention, leave_one_out, recency_attribution
|
|
6
|
+
from .explanation import ConsequenceExplanation, explain
|
|
7
|
+
from .graph import ConsequenceGraph, build_graph
|
|
8
|
+
from .models import (
|
|
9
|
+
Action, AgentAssessment, AssessmentStatus, Event, EventType, Observation,
|
|
10
|
+
OriginStatus, OutcomeContract, Trace, VerificationResult, VerificationStatus,
|
|
11
|
+
)
|
|
12
|
+
from .policies import Policy, PolicyDecision, PolicyError, PolicyReply, PolicyRequest, ScriptedRefundPolicy
|
|
13
|
+
from .verification import RefundVerifier, Verifier, verify
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Action", "AgentAssessment", "AssessmentStatus", "ConsequenceExplanation",
|
|
17
|
+
"ConsequenceGraph", "EffectEstimate", "Event", "EventType", "Intervention",
|
|
18
|
+
"Observation", "OriginStatus", "OutcomeContract", "RefundVerifier", "Trace",
|
|
19
|
+
"Policy", "PolicyDecision", "PolicyError", "PolicyReply", "PolicyRequest", "ScriptedRefundPolicy",
|
|
20
|
+
"VerificationResult", "VerificationStatus", "Verifier", "build_graph", "explain",
|
|
21
|
+
"leave_one_out", "recency_attribution", "verify",
|
|
22
|
+
]
|
consequa_x/__main__.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Local Ollama policy adapter; calls never include simulator truth.
|
|
2
|
+
|
|
3
|
+
The adapter uses /api/chat with a JSON schema and disabled thinking. A supplied
|
|
4
|
+
seed records an attempted reproducibility control; it does not establish that
|
|
5
|
+
model generation is deterministic across Ollama versions or hardware.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from copy import deepcopy
|
|
11
|
+
import hashlib
|
|
12
|
+
from http.client import HTTPException
|
|
13
|
+
import ipaddress
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import socket
|
|
17
|
+
from types import MappingProxyType
|
|
18
|
+
from urllib.error import HTTPError, URLError
|
|
19
|
+
from urllib.parse import urlsplit
|
|
20
|
+
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
|
21
|
+
|
|
22
|
+
from ..policies import (
|
|
23
|
+
PolicyError, PolicyReply, PolicyRequest, decision_from_json, get_decision_schema,
|
|
24
|
+
strict_json_loads, validate_reply, validate_seed,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PROMPT_VERSION = "refund-policy-v1"
|
|
29
|
+
MAX_RESPONSE_BYTES = 1024 * 1024
|
|
30
|
+
|
|
31
|
+
_SYSTEM_PROMPT = """You operate a refund agent in an isolated simulated payment system.
|
|
32
|
+
Use only the supplied task, visible context, and returned tool responses.
|
|
33
|
+
The standard policy allows a requested refund for orders at most 30 days old.
|
|
34
|
+
If supplied, VIP customer memory together with a supplied VIP exception policy
|
|
35
|
+
may extend eligibility to 60 days. Do not infer missing customer requests,
|
|
36
|
+
customer status, or exception policies. Context can include irrelevant text.
|
|
37
|
+
At select, choose refund or stop. At after_response, choose retry or stop, within
|
|
38
|
+
the remaining attempt budget; any retry follows the supplied retry instruction.
|
|
39
|
+
A successful response may support a success assessment, an error a failure
|
|
40
|
+
assessment. A timeout does not prove whether an effect occurred. No independent
|
|
41
|
+
state readback is supplied to you. Report your assessment based on visible data.
|
|
42
|
+
Return exactly the JSON object described by the supplied schema. Include a brief
|
|
43
|
+
reported reason in rationale; do not include internal reasoning or extra text.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
_SYSTEM_PROMPT_V2 = """You operate a refund agent in an isolated simulated payment system.
|
|
47
|
+
Use only the supplied task, visible context, and returned tool responses.
|
|
48
|
+
Each request is stateless and contains the complete visible response history.
|
|
49
|
+
Do not use memory of earlier calls or invent additional observations.
|
|
50
|
+
Use the supplied customer_request, vip_memory, and vip_policy strings as current
|
|
51
|
+
visible context for eligibility; do not invent a second confirmation requirement.
|
|
52
|
+
An empty context string supplies no fact or instruction. Other context can be
|
|
53
|
+
irrelevant and does not override the refund policy.
|
|
54
|
+
The standard policy allows a requested refund for orders at most 30 days old.
|
|
55
|
+
When supplied VIP customer memory identifies the customer as VIP and a supplied
|
|
56
|
+
VIP exception policy permits refunds through 60 days, eligibility extends to
|
|
57
|
+
60 days. Do not infer missing customer requests, status, or exception policies.
|
|
58
|
+
At select, choose refund or stop based on the visible request and eligibility.
|
|
59
|
+
attempts_remaining is the authoritative number of additional tool attempts
|
|
60
|
+
available now, not the number of attempts already made. Do not subtract the
|
|
61
|
+
length of the response history from it again.
|
|
62
|
+
Response time_ms values are simulated timestamps, not timeout durations.
|
|
63
|
+
task.deadline_ms and task.horizon_ms are absolute simulated contract bounds,
|
|
64
|
+
not durations for a tool call or limits on model generation time.
|
|
65
|
+
At after_response, stop after a successful acknowledgement. Choose retry only
|
|
66
|
+
when the supplied retry_instruction permits it and attempts_remaining is
|
|
67
|
+
positive; otherwise choose stop. A retry without idempotency can create duplicate
|
|
68
|
+
effects. Never infer that a timed-out attempt had no effect.
|
|
69
|
+
Assessment describes whether the requested refund effect occurred, separately
|
|
70
|
+
from eligibility and the decision to retry or stop. At select, no outcome has
|
|
71
|
+
been observed, so report unknown. A successful acknowledgement may support a
|
|
72
|
+
reported success assessment, but cannot independently verify the hidden effect.
|
|
73
|
+
An error may support a failure assessment. After a timeout without a successful
|
|
74
|
+
acknowledgement, report unknown whether retrying or stopping. No independent
|
|
75
|
+
state readback or evaluator truth is supplied to you.
|
|
76
|
+
Return exactly the JSON object described by the supplied schema. Include a brief
|
|
77
|
+
reported reason in rationale; do not include internal reasoning or extra text.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
# Versions name complete, immutable prompt profiles so historical runs remain
|
|
81
|
+
# reproducible when a later profile adds policy guidance. The v1 aliases above
|
|
82
|
+
# preserve the original default and its exact prompt bytes.
|
|
83
|
+
PROMPT_PROFILES = MappingProxyType({
|
|
84
|
+
PROMPT_VERSION: _SYSTEM_PROMPT,
|
|
85
|
+
"refund-policy-v2": _SYSTEM_PROMPT_V2,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _prompt_profile(version: str) -> tuple[str, str]:
|
|
90
|
+
if type(version) is not str or version not in PROMPT_PROFILES:
|
|
91
|
+
raise PolicyError("prompt_version must name a supported Ollama prompt profile")
|
|
92
|
+
return version, PROMPT_PROFILES[version]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _NoRedirects(HTTPRedirectHandler):
|
|
96
|
+
def redirect_request(self, request, fp, code, msg, headers, newurl):
|
|
97
|
+
raise PolicyError("Ollama redirects are disabled")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _local_base_url(value: str) -> str:
|
|
101
|
+
if type(value) is not str or not value or value.strip() != value:
|
|
102
|
+
raise PolicyError("base_url must be a local HTTP URL")
|
|
103
|
+
try:
|
|
104
|
+
parsed = urlsplit(value)
|
|
105
|
+
port = parsed.port
|
|
106
|
+
host = parsed.hostname
|
|
107
|
+
except ValueError as error:
|
|
108
|
+
raise PolicyError("base_url must be a local HTTP URL") from error
|
|
109
|
+
if (parsed.scheme != "http" or not host or parsed.username is not None
|
|
110
|
+
or parsed.password is not None or "?" in value or "#" in value
|
|
111
|
+
or parsed.path not in ("", "/") or any(c.isspace() for c in value)):
|
|
112
|
+
raise PolicyError("base_url must be an HTTP loopback origin without credentials or a path")
|
|
113
|
+
if "%" in host:
|
|
114
|
+
raise PolicyError("Scoped IP addresses are not supported for local Ollama")
|
|
115
|
+
if host.lower() != "localhost":
|
|
116
|
+
try:
|
|
117
|
+
if not ipaddress.ip_address(host).is_loopback:
|
|
118
|
+
raise ValueError("not loopback")
|
|
119
|
+
except ValueError as error:
|
|
120
|
+
raise PolicyError("Only localhost or literal loopback IP addresses are allowed") from error
|
|
121
|
+
if port is not None and not 1 <= port <= 65535:
|
|
122
|
+
raise PolicyError("base_url port must be in 1..65535")
|
|
123
|
+
return value.rstrip("/")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _model_name(value: str) -> str:
|
|
127
|
+
if type(value) is not str or not value.strip() or value.strip() != value:
|
|
128
|
+
raise PolicyError("model must be a nonempty model name")
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _normalized_model(value: str) -> str:
|
|
133
|
+
# Ollama's documented omitted tag defaults only to latest.
|
|
134
|
+
return value.removesuffix(":latest")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _reject_remote_routing(envelope: dict) -> None:
|
|
138
|
+
# These fields are declared on both ShowResponse and ChatResponse in the
|
|
139
|
+
# official Ollama api/types.go. Missing/empty fields are normal for locals.
|
|
140
|
+
for key in ("remote_model", "remote_host"):
|
|
141
|
+
if key in envelope:
|
|
142
|
+
if type(envelope[key]) is not str:
|
|
143
|
+
raise PolicyError("Local Ollama remote-routing metadata is malformed")
|
|
144
|
+
if envelope[key]:
|
|
145
|
+
raise PolicyError("Ollama model declares remote routing; select a local model")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class OllamaPolicy:
|
|
149
|
+
"""Structured policy calls to a user-selected local Ollama model.
|
|
150
|
+
|
|
151
|
+
Construction performs no network request, model download, or installation.
|
|
152
|
+
Environment proxies and all redirects are disabled for these local requests.
|
|
153
|
+
Prompt selection is fixed per instance; the original v1 profile is the
|
|
154
|
+
default, and later profiles require an explicit prompt_version.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
def __init__(self, model: str, *, base_url: str = "http://127.0.0.1:11434",
|
|
158
|
+
temperature: float = 0.2, timeout: float = 120,
|
|
159
|
+
num_predict: int = 512, prompt_version: str = PROMPT_VERSION):
|
|
160
|
+
self._prompt_version, self._system_prompt = _prompt_profile(prompt_version)
|
|
161
|
+
model = _model_name(model)
|
|
162
|
+
for label, value in (("temperature", temperature), ("timeout", timeout)):
|
|
163
|
+
if type(value) not in (int, float) or not math.isfinite(value):
|
|
164
|
+
raise PolicyError(f"{label} must be a finite number")
|
|
165
|
+
if not 0 <= temperature <= 2:
|
|
166
|
+
raise PolicyError("temperature must be in 0..2")
|
|
167
|
+
if timeout <= 0:
|
|
168
|
+
raise PolicyError("timeout must be positive")
|
|
169
|
+
if type(num_predict) is not int or num_predict <= 0:
|
|
170
|
+
raise PolicyError("num_predict must be a positive integer")
|
|
171
|
+
self.model = model
|
|
172
|
+
self.base_url = _local_base_url(base_url)
|
|
173
|
+
self.temperature = float(temperature)
|
|
174
|
+
self.timeout = float(timeout)
|
|
175
|
+
self.num_predict = num_predict
|
|
176
|
+
self._opener = build_opener(ProxyHandler({}), _NoRedirects())
|
|
177
|
+
self._inspection_key: tuple[str, str] | None = None
|
|
178
|
+
self._local_inspection: dict | None = None
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def prompt_version(self) -> str:
|
|
182
|
+
return self._prompt_version
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def system_prompt(self) -> str:
|
|
186
|
+
return self._system_prompt
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def metadata(self) -> dict:
|
|
190
|
+
result = {
|
|
191
|
+
"provider": "ollama", "requested_model": self.model,
|
|
192
|
+
"base_url": self.base_url, "temperature": self.temperature,
|
|
193
|
+
"timeout_seconds": self.timeout, "num_predict": self.num_predict,
|
|
194
|
+
"prompt_version": self.prompt_version,
|
|
195
|
+
}
|
|
196
|
+
if self._inspection_key == (self.base_url.rstrip("/"), self.model):
|
|
197
|
+
result["local_inspection"] = deepcopy(self._local_inspection)
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
def _request(self, path: str, payload: dict | None = None) -> dict:
|
|
201
|
+
body = None if payload is None else json.dumps(
|
|
202
|
+
payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
|
203
|
+
).encode("utf-8")
|
|
204
|
+
request = Request(_local_base_url(self.base_url) + path, data=body,
|
|
205
|
+
headers={"Content-Type": "application/json", "Accept": "application/json"})
|
|
206
|
+
try:
|
|
207
|
+
with self._opener.open(request, timeout=self.timeout) as response:
|
|
208
|
+
content = response.read(MAX_RESPONSE_BYTES + 1)
|
|
209
|
+
except HTTPError as error:
|
|
210
|
+
# Never incorporate provider response bodies into a diagnostic.
|
|
211
|
+
raise PolicyError(f"Local Ollama returned HTTP {error.code}") from None
|
|
212
|
+
except (URLError, TimeoutError, socket.timeout, OSError, HTTPException):
|
|
213
|
+
raise PolicyError("Could not reach local Ollama; check that it is running") from None
|
|
214
|
+
if len(content) > MAX_RESPONSE_BYTES:
|
|
215
|
+
raise PolicyError("Local Ollama response exceeds the 1 MiB limit")
|
|
216
|
+
try:
|
|
217
|
+
envelope = strict_json_loads(content.decode("utf-8"))
|
|
218
|
+
except UnicodeDecodeError:
|
|
219
|
+
raise PolicyError("Local Ollama response is not UTF-8 JSON") from None
|
|
220
|
+
if type(envelope) is not dict:
|
|
221
|
+
raise PolicyError("Local Ollama response must be a JSON object")
|
|
222
|
+
return envelope
|
|
223
|
+
|
|
224
|
+
def list_models(self) -> tuple[dict, ...]:
|
|
225
|
+
"""List local names/model identities/digests without loading a model."""
|
|
226
|
+
envelope = self._request("/api/tags")
|
|
227
|
+
models = envelope.get("models")
|
|
228
|
+
if type(models) is not list:
|
|
229
|
+
raise PolicyError("Local Ollama model list is malformed")
|
|
230
|
+
records = []
|
|
231
|
+
for entry in models:
|
|
232
|
+
if type(entry) is not dict or type(entry.get("name")) is not str or not entry["name"]:
|
|
233
|
+
raise PolicyError("Local Ollama model list is malformed")
|
|
234
|
+
record = {"name": entry["name"]}
|
|
235
|
+
for key in ("model", "digest"):
|
|
236
|
+
if key in entry:
|
|
237
|
+
if type(entry[key]) is not str or not entry[key].strip():
|
|
238
|
+
raise PolicyError("Local Ollama model list is malformed")
|
|
239
|
+
record[key] = entry[key]
|
|
240
|
+
records.append(record)
|
|
241
|
+
return tuple(records)
|
|
242
|
+
|
|
243
|
+
def validate_local_model(self, *, force: bool = False) -> dict:
|
|
244
|
+
"""Inspect model metadata before generation and reject remote routing.
|
|
245
|
+
|
|
246
|
+
This read-only /api/show request neither downloads nor loads the model.
|
|
247
|
+
The successful result is cached for the current model and origin. Pass
|
|
248
|
+
force=True to refresh after changing the daemon's model tag in place.
|
|
249
|
+
Metadata is a daemon declaration, not proof of its execution environment.
|
|
250
|
+
"""
|
|
251
|
+
if type(force) is not bool:
|
|
252
|
+
raise PolicyError("force must be a boolean")
|
|
253
|
+
key = (_local_base_url(self.base_url), _model_name(self.model))
|
|
254
|
+
if not force and self._inspection_key == key:
|
|
255
|
+
return deepcopy(self._local_inspection)
|
|
256
|
+
# Do not retain a previously valid inspection across configuration changes.
|
|
257
|
+
self._inspection_key = None
|
|
258
|
+
self._local_inspection = None
|
|
259
|
+
envelope = self._request("/api/show", {"model": key[1], "verbose": False})
|
|
260
|
+
_reject_remote_routing(envelope)
|
|
261
|
+
if type(envelope.get("model_info")) is not dict:
|
|
262
|
+
raise PolicyError("Local Ollama model inspection is malformed")
|
|
263
|
+
details = envelope.get("details", {})
|
|
264
|
+
capabilities = envelope.get("capabilities", [])
|
|
265
|
+
if type(details) is not dict or type(capabilities) is not list or any(
|
|
266
|
+
type(capability) is not str for capability in capabilities
|
|
267
|
+
):
|
|
268
|
+
raise PolicyError("Local Ollama model inspection is malformed")
|
|
269
|
+
selected_details = {}
|
|
270
|
+
for name in ("format", "family", "parameter_size", "quantization_level"):
|
|
271
|
+
if name in details:
|
|
272
|
+
if type(details[name]) is not str:
|
|
273
|
+
raise PolicyError("Local Ollama model details are malformed")
|
|
274
|
+
selected_details[name] = details[name]
|
|
275
|
+
if "families" in details:
|
|
276
|
+
families = details["families"]
|
|
277
|
+
if type(families) is not list or any(type(family) is not str for family in families):
|
|
278
|
+
raise PolicyError("Local Ollama model details are malformed")
|
|
279
|
+
selected_details["families"] = list(families)
|
|
280
|
+
inspection = {
|
|
281
|
+
"endpoint": "/api/show", "requested_model": key[1],
|
|
282
|
+
"no_declared_remote_routing": True,
|
|
283
|
+
"routing_fields_present": [name for name in ("remote_model", "remote_host")
|
|
284
|
+
if name in envelope],
|
|
285
|
+
"details": selected_details, "capabilities": list(capabilities),
|
|
286
|
+
"inspection_sha256": hashlib.sha256(json.dumps(
|
|
287
|
+
envelope, sort_keys=True, ensure_ascii=False, allow_nan=False,
|
|
288
|
+
separators=(",", ":")
|
|
289
|
+
).encode("utf-8")).hexdigest(),
|
|
290
|
+
}
|
|
291
|
+
self._inspection_key = key
|
|
292
|
+
self._local_inspection = inspection
|
|
293
|
+
return deepcopy(inspection)
|
|
294
|
+
|
|
295
|
+
def decide(self, request: PolicyRequest, *, seed: int) -> PolicyReply:
|
|
296
|
+
if not isinstance(request, PolicyRequest):
|
|
297
|
+
raise PolicyError("Expected a PolicyRequest")
|
|
298
|
+
validate_seed(seed)
|
|
299
|
+
# Export revalidates mutable JSON containers before making the request.
|
|
300
|
+
public_request = PolicyRequest(request.stage, request.task, request.context,
|
|
301
|
+
request.responses, request.attempts_remaining).to_dict()
|
|
302
|
+
request_text = json.dumps(public_request, sort_keys=True, ensure_ascii=False,
|
|
303
|
+
separators=(",", ":"), allow_nan=False)
|
|
304
|
+
schema = get_decision_schema(request)
|
|
305
|
+
self.validate_local_model()
|
|
306
|
+
payload = {
|
|
307
|
+
"model": self.model, "stream": False, "think": False, "format": schema,
|
|
308
|
+
"messages": [
|
|
309
|
+
{"role": "system", "content": self.system_prompt},
|
|
310
|
+
{"role": "user", "content": request_text},
|
|
311
|
+
],
|
|
312
|
+
"options": {"seed": seed, "temperature": self.temperature,
|
|
313
|
+
"num_predict": self.num_predict},
|
|
314
|
+
}
|
|
315
|
+
envelope = self._request("/api/chat", payload)
|
|
316
|
+
_reject_remote_routing(envelope)
|
|
317
|
+
message = envelope.get("message")
|
|
318
|
+
if envelope.get("done") is not True or type(message) is not dict:
|
|
319
|
+
raise PolicyError("Local Ollama did not return a completed chat response")
|
|
320
|
+
if message.get("role") != "assistant" or type(message.get("content")) is not str:
|
|
321
|
+
raise PolicyError("Local Ollama chat message is malformed")
|
|
322
|
+
if message.get("tool_calls"):
|
|
323
|
+
raise PolicyError("Local Ollama returned unsupported tool calls")
|
|
324
|
+
model = envelope.get("model")
|
|
325
|
+
if type(model) is not str or not model.strip() or model.strip() != model:
|
|
326
|
+
raise PolicyError("Local Ollama response is missing its model identity")
|
|
327
|
+
if _normalized_model(model) != _normalized_model(self.model):
|
|
328
|
+
raise PolicyError("Local Ollama returned a different model than requested")
|
|
329
|
+
decision = decision_from_json(message["content"])
|
|
330
|
+
metadata = {
|
|
331
|
+
**self.metadata, "returned_model": model, "seed": seed,
|
|
332
|
+
"request_sha256": hashlib.sha256(request_text.encode("utf-8")).hexdigest(),
|
|
333
|
+
"system_prompt_sha256": hashlib.sha256(self.system_prompt.encode("utf-8")).hexdigest(),
|
|
334
|
+
"request_payload_sha256": hashlib.sha256(json.dumps(
|
|
335
|
+
payload, ensure_ascii=False, allow_nan=False, separators=(",", ":")
|
|
336
|
+
).encode("utf-8")).hexdigest(),
|
|
337
|
+
}
|
|
338
|
+
# Ollama declares counts and nanosecond durations; preserve only valid ints.
|
|
339
|
+
for key in ("total_duration", "load_duration", "prompt_eval_count", "prompt_eval_duration",
|
|
340
|
+
"eval_count", "eval_duration"):
|
|
341
|
+
if key in envelope:
|
|
342
|
+
value = envelope[key]
|
|
343
|
+
if type(value) is not int or value < 0:
|
|
344
|
+
raise PolicyError(f"Local Ollama {key} must be a nonnegative integer")
|
|
345
|
+
metadata[key] = value
|
|
346
|
+
if "done_reason" in envelope:
|
|
347
|
+
if type(envelope["done_reason"]) is not str:
|
|
348
|
+
raise PolicyError("Local Ollama done_reason must be a string")
|
|
349
|
+
metadata["done_reason"] = envelope["done_reason"]
|
|
350
|
+
return validate_reply(request, PolicyReply(decision, metadata))
|