fluid-decisions 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.
- fluid/__init__.py +3 -0
- fluid/audit.py +45 -0
- fluid/cli.py +151 -0
- fluid/client.py +376 -0
- fluid/hook.py +56 -0
- fluid/lanes.yaml +8 -0
- fluid/ledger.py +251 -0
- fluid/mcp_server.py +59 -0
- fluid/packs/classify.yaml +19 -0
- fluid/packs/gate.yaml +67 -0
- fluid/packs/route.yaml +17 -0
- fluid/packs/verify.yaml +11 -0
- fluid/policy.py +432 -0
- fluid/scoping.py +146 -0
- fluid/service.py +461 -0
- fluid/serving.py +304 -0
- fluid_decisions-0.1.0.data/data/share/fluid-decisions/models.yaml +15 -0
- fluid_decisions-0.1.0.dist-info/METADATA +196 -0
- fluid_decisions-0.1.0.dist-info/RECORD +22 -0
- fluid_decisions-0.1.0.dist-info/WHEEL +5 -0
- fluid_decisions-0.1.0.dist-info/entry_points.txt +3 -0
- fluid_decisions-0.1.0.dist-info/top_level.txt +1 -0
fluid/__init__.py
ADDED
fluid/audit.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Append-only decision records; state bodies are never persisted."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def data_directory() -> Path:
|
|
13
|
+
return Path(os.environ.get('FLUID_DATA_DIR', '~/.fluid-decisions')).expanduser()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def append_record(path: Path, record: dict[str, Any]) -> None:
|
|
17
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
18
|
+
raw = (json.dumps(record, allow_nan=False, separators=(',', ':')) + '\n').encode()
|
|
19
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
20
|
+
try:
|
|
21
|
+
# A single append avoids interleaving between concurrent short records.
|
|
22
|
+
if os.write(fd, raw) != len(raw):
|
|
23
|
+
raise OSError('incomplete audit append')
|
|
24
|
+
finally:
|
|
25
|
+
os.close(fd)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Audit:
|
|
29
|
+
def __init__(self, path: Path | None = None):
|
|
30
|
+
self.path = path or data_directory() / 'audit.jsonl'
|
|
31
|
+
|
|
32
|
+
def record(self, decision: dict[str, Any], *, state: Any = None) -> None:
|
|
33
|
+
fields = ('job', 'lane', 'pack_version', 'verdict', 'answers', 'timings',
|
|
34
|
+
'usage', 'reasons', 'passes', 'flagged', 'complete', 'taint_cleared',
|
|
35
|
+
'clearance_reason', 'session_id_sha256', 'static_injection_rule',
|
|
36
|
+
'windows', 'first_flagged_window', 'window_answers', 'session_assessment',
|
|
37
|
+
'check', 'checked', 'budget_source', 'expected_ms', 'resolved', 'scoping',
|
|
38
|
+
'budget')
|
|
39
|
+
record = {key: decision[key] for key in fields if key in decision}
|
|
40
|
+
record['timestamp'] = datetime.now(timezone.utc).isoformat()
|
|
41
|
+
if os.environ.get('FLUID_AUDIT_STATE') == '1':
|
|
42
|
+
raw = json.dumps(state, sort_keys=True, ensure_ascii=False).encode()
|
|
43
|
+
record['state'] = {'sha256': hashlib.sha256(raw).hexdigest(),
|
|
44
|
+
'preview': '[state content redacted]', 'bytes': len(raw)}
|
|
45
|
+
append_record(self.path, record)
|
fluid/cli.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Command line surfaces for Fluid Decisions.
|
|
2
|
+
|
|
3
|
+
The CLI deliberately has a small JSON contract: input and output are JSON, while
|
|
4
|
+
the exit status communicates the decision class to shell hooks.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import base64
|
|
10
|
+
import json
|
|
11
|
+
import mimetypes
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .service import Decisions
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _service() -> Any:
|
|
20
|
+
return Decisions()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _json(value: Any) -> None:
|
|
24
|
+
print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_json(value: str | None, filename: str | None, *, stdin: Any = None) -> Any:
|
|
28
|
+
if value is not None and filename is not None:
|
|
29
|
+
raise ValueError("use only one of --state and --file")
|
|
30
|
+
if filename:
|
|
31
|
+
text = Path(filename).read_text(encoding="utf-8")
|
|
32
|
+
elif value is not None:
|
|
33
|
+
text = value
|
|
34
|
+
else:
|
|
35
|
+
text = (sys.stdin if stdin is None else stdin).read()
|
|
36
|
+
if not text.strip():
|
|
37
|
+
raise ValueError("JSON input is required")
|
|
38
|
+
return json.loads(text)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _media(paths: list[str], kind: str) -> list[str]:
|
|
42
|
+
result = []
|
|
43
|
+
for filename in paths:
|
|
44
|
+
content = Path(filename).read_bytes()
|
|
45
|
+
mime = mimetypes.guess_type(filename)[0] or ("image/png" if kind == "image" else "audio/wav")
|
|
46
|
+
result.append(f"data:{mime};base64,{base64.b64encode(content).decode('ascii')}")
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _result_exit(result: Any, command: str) -> int:
|
|
51
|
+
if not isinstance(result, dict):
|
|
52
|
+
return 2
|
|
53
|
+
if result.get("complete") is False:
|
|
54
|
+
return 2
|
|
55
|
+
verdict = result.get("verdict")
|
|
56
|
+
if command == "verify":
|
|
57
|
+
findings = result.get("findings", result.get("claims", [])) or []
|
|
58
|
+
if verdict in {"deny", "high"} or any(
|
|
59
|
+
isinstance(item, dict) and item.get("verdict") in {"deny", "high", "flagged"}
|
|
60
|
+
for item in findings
|
|
61
|
+
):
|
|
62
|
+
return 1
|
|
63
|
+
if verdict in {"deny", "high"}:
|
|
64
|
+
return 1
|
|
65
|
+
if verdict in {"ask", "incomplete"}:
|
|
66
|
+
return 2
|
|
67
|
+
if result.get("error") or result.get("incomplete"):
|
|
68
|
+
return 2
|
|
69
|
+
if command == "health" and any(
|
|
70
|
+
isinstance(result.get(lane), dict) and result[lane].get("error") for lane in ("max",)
|
|
71
|
+
):
|
|
72
|
+
return 2
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _parser() -> argparse.ArgumentParser:
|
|
77
|
+
parser = argparse.ArgumentParser(prog="fluid")
|
|
78
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
79
|
+
for name in ("classify", "route"):
|
|
80
|
+
command = sub.add_parser(name)
|
|
81
|
+
command.add_argument("--state")
|
|
82
|
+
command.add_argument("--file")
|
|
83
|
+
command.add_argument("--destinations", help="JSON object for route")
|
|
84
|
+
command.add_argument("--image", action="append", default=[])
|
|
85
|
+
command.add_argument("--audio")
|
|
86
|
+
gate = sub.add_parser("gate")
|
|
87
|
+
gate.add_argument("--tool", required=True)
|
|
88
|
+
gate.add_argument("--input", required=True, help="tool input JSON")
|
|
89
|
+
gate.add_argument("--user-request", default="")
|
|
90
|
+
gate.add_argument("--session")
|
|
91
|
+
verify = sub.add_parser("verify")
|
|
92
|
+
verify.add_argument("--source")
|
|
93
|
+
verify.add_argument("--source-file")
|
|
94
|
+
verify.add_argument("--claim", action="append", required=True)
|
|
95
|
+
session = sub.add_parser("session")
|
|
96
|
+
session_sub = session.add_subparsers(dest="session_command", required=True)
|
|
97
|
+
clear = session_sub.add_parser("clear")
|
|
98
|
+
clear.add_argument("--session", required=True)
|
|
99
|
+
clear.add_argument("--reason", default="operator clearance")
|
|
100
|
+
recheck = session_sub.add_parser("recheck")
|
|
101
|
+
recheck.add_argument("--session", required=True)
|
|
102
|
+
recheck.add_argument("--file", help="Original result text; stdin if omitted")
|
|
103
|
+
sub.add_parser("health")
|
|
104
|
+
return parser
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: list[str] | None = None) -> int:
|
|
108
|
+
parser = _parser()
|
|
109
|
+
try:
|
|
110
|
+
args = parser.parse_args(argv)
|
|
111
|
+
service = _service()
|
|
112
|
+
try:
|
|
113
|
+
if args.command in {"classify", "route"}:
|
|
114
|
+
state = _read_json(args.state, args.file)
|
|
115
|
+
images = _media(args.image, "image") if args.image else None
|
|
116
|
+
audio = _media([args.audio], "audio")[0] if args.audio else None
|
|
117
|
+
if args.command == "classify":
|
|
118
|
+
result = service.classify(state, images=images, audio=audio)
|
|
119
|
+
else:
|
|
120
|
+
destinations = json.loads(args.destinations) if args.destinations else None
|
|
121
|
+
result = service.route(state, destinations=destinations, images=images, audio=audio)
|
|
122
|
+
elif args.command == "gate":
|
|
123
|
+
result = service.gate(args.tool, json.loads(args.input), user_request=args.user_request, session_id=args.session)
|
|
124
|
+
elif args.command == "verify":
|
|
125
|
+
source = args.source or (Path(args.source_file).read_text(encoding="utf-8") if args.source_file else None)
|
|
126
|
+
if source is None:
|
|
127
|
+
raise ValueError("--source or --source-file is required")
|
|
128
|
+
result = service.verify(source, args.claim)
|
|
129
|
+
elif args.command == "session" and args.session_command == "clear":
|
|
130
|
+
result = service.clear_session(args.session, reason=args.reason)
|
|
131
|
+
elif args.command == "session" and args.session_command == "recheck":
|
|
132
|
+
if args.file:
|
|
133
|
+
text = Path(args.file).read_bytes().decode('utf-8')
|
|
134
|
+
elif hasattr(sys.stdin, 'buffer'):
|
|
135
|
+
text = sys.stdin.buffer.read().decode('utf-8')
|
|
136
|
+
else:
|
|
137
|
+
text = sys.stdin.read()
|
|
138
|
+
result = service.post_result(text, session_id=args.session, recheck=True)
|
|
139
|
+
else:
|
|
140
|
+
result = service.health()
|
|
141
|
+
finally:
|
|
142
|
+
service.close()
|
|
143
|
+
_json(result)
|
|
144
|
+
return _result_exit(result, args.command)
|
|
145
|
+
except Exception as exc:
|
|
146
|
+
_json({"verdict": "ask", "error": {"reason": "invalid_input", "message": str(exc)}})
|
|
147
|
+
return 2
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__": # pragma: no cover
|
|
151
|
+
raise SystemExit(main())
|
fluid/client.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
"""Small HTTP client for the d1 System One decision endpoint."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClientError(Exception):
|
|
17
|
+
def __init__(self, reason: str, message: str = "client error"):
|
|
18
|
+
self.reason = reason
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _probabilities(value: Any, label: str) -> None:
|
|
23
|
+
if not isinstance(value, dict) or not value:
|
|
24
|
+
raise ClientError("invalid_response", f"invalid response: {label} probability distribution")
|
|
25
|
+
numbers = list(value.values())
|
|
26
|
+
if any(not isinstance(number, (int, float)) or isinstance(number, bool) or not math.isfinite(number) or not 0 <= number <= 1 for number in numbers):
|
|
27
|
+
raise ClientError("invalid_response", f"invalid response: {label} probability distribution")
|
|
28
|
+
if not math.isclose(sum(numbers), 1.0, abs_tol=1e-4):
|
|
29
|
+
raise ClientError("invalid_response", f"invalid response: {label} probability distribution")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _confidence(answer: dict[str, Any], label: str) -> None:
|
|
33
|
+
value = answer.get("confidence")
|
|
34
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or not 0 <= value <= 1:
|
|
35
|
+
raise ClientError("invalid_response", f"invalid response: confidence for {label}")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_response(response: Any, questions: dict[str, dict[str, Any]]) -> None:
|
|
39
|
+
if not isinstance(response, dict):
|
|
40
|
+
raise ClientError("invalid_response", "invalid response: expected object")
|
|
41
|
+
answers = response.get("answers")
|
|
42
|
+
usage = response.get("usage")
|
|
43
|
+
if not isinstance(answers, dict) or not isinstance(usage, dict) or usage.get("output_tokens") != 0:
|
|
44
|
+
raise ClientError("invalid_response", "invalid response: answers or usage")
|
|
45
|
+
if set(answers) != set(questions):
|
|
46
|
+
raise ClientError("invalid_response", "invalid response: answer set does not match questions")
|
|
47
|
+
for name, question in questions.items():
|
|
48
|
+
answer = answers.get(name)
|
|
49
|
+
expected = question.get("type")
|
|
50
|
+
if expected not in {"noul", "choice", "score"}:
|
|
51
|
+
raise ClientError("invalid_response", f"invalid response: unsupported question type for {name}")
|
|
52
|
+
if not isinstance(answer, dict) or answer.get("type") != expected:
|
|
53
|
+
raise ClientError("invalid_response", f"invalid response: answer type for {name}")
|
|
54
|
+
if expected == "noul":
|
|
55
|
+
value = answer.get("noul")
|
|
56
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or not 0 <= value <= 1:
|
|
57
|
+
raise ClientError("invalid_response", f"invalid response: noul answer for {name}")
|
|
58
|
+
elif expected == "choice":
|
|
59
|
+
probabilities = answer.get("probabilities")
|
|
60
|
+
_probabilities(probabilities, name)
|
|
61
|
+
_confidence(answer, name)
|
|
62
|
+
criteria = question.get("criteria")
|
|
63
|
+
probability_map = cast(dict[str, Any], probabilities)
|
|
64
|
+
if not isinstance(criteria, dict) or set(probability_map) != set(criteria):
|
|
65
|
+
raise ClientError("invalid_response", f"invalid response: choice criteria for {name}")
|
|
66
|
+
if answer.get("choice") not in probability_map:
|
|
67
|
+
raise ClientError("invalid_response", f"invalid response: choice answer for {name}")
|
|
68
|
+
elif expected == "score":
|
|
69
|
+
probabilities = answer.get("probabilities")
|
|
70
|
+
_probabilities(probabilities, name)
|
|
71
|
+
probability_map = cast(dict[str, Any], probabilities)
|
|
72
|
+
_confidence(answer, name)
|
|
73
|
+
criteria = question.get("criteria")
|
|
74
|
+
criteria_list = cast(list[Any], criteria) if isinstance(criteria, list) else []
|
|
75
|
+
expected_keys = {str(index) for index in range(len(criteria_list))}
|
|
76
|
+
if not expected_keys or set(probability_map) != expected_keys:
|
|
77
|
+
raise ClientError("invalid_response", f"invalid response: score criteria for {name}")
|
|
78
|
+
if not isinstance(answer.get("score"), (int, float)) or isinstance(answer.get("score"), bool) or not math.isfinite(answer["score"]) or not 0 <= answer["score"] <= len(criteria_list) - 1:
|
|
79
|
+
raise ClientError("invalid_response", f"invalid response: score answer for {name}")
|
|
80
|
+
legend = answer.get("legend")
|
|
81
|
+
if not isinstance(legend, dict) or set(legend) != expected_keys or any(legend[str(index)] != criteria_list[index] for index in range(len(criteria_list))):
|
|
82
|
+
raise ClientError("invalid_response", f"invalid response: score legend for {name}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def enrich_response(response: dict[str, Any], lane: str, client_ms: float, questions: dict[str, dict[str, Any]]) -> dict[str, Any]:
|
|
86
|
+
result = copy.deepcopy(response)
|
|
87
|
+
for name, answer in result["answers"].items():
|
|
88
|
+
if answer["type"] == "noul":
|
|
89
|
+
answer["decisiveness"] = 2 * abs(answer["noul"] - 0.5)
|
|
90
|
+
elif answer["type"] in {"choice", "score"}:
|
|
91
|
+
answer["top_probability"] = max(answer["probabilities"].values())
|
|
92
|
+
result["lane"] = lane
|
|
93
|
+
timings = result.get("timings")
|
|
94
|
+
result["timings"] = dict(timings) if isinstance(timings, dict) else {}
|
|
95
|
+
result["timings"]["client_ms"] = round(client_ms, 1)
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _default_urls() -> dict[str, str]:
|
|
100
|
+
path = Path(__file__).with_name("lanes.yaml")
|
|
101
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
102
|
+
configured = cast(dict[str, Any], raw.get("lanes", raw))
|
|
103
|
+
return {lane: str(cast(dict[str, Any], configured[lane])["url"]).rstrip("/") for lane in ("max", "omni") if isinstance(configured.get(lane), dict) and configured[lane].get("url")}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _valid_url(value: str) -> bool:
|
|
107
|
+
parsed = urlparse(value)
|
|
108
|
+
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _connection_reset(error: BaseException) -> bool:
|
|
112
|
+
seen: set[int] = set()
|
|
113
|
+
current: BaseException | None = error
|
|
114
|
+
while current is not None and id(current) not in seen:
|
|
115
|
+
seen.add(id(current))
|
|
116
|
+
if isinstance(current, OSError) and getattr(current, "errno", None) in {54, 104}:
|
|
117
|
+
return True
|
|
118
|
+
current = current.__cause__ or current.__context__
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class D1Client:
|
|
123
|
+
def __init__(self, lanes_file: str | os.PathLike[str] | None = None, transport: httpx.BaseTransport | None = None):
|
|
124
|
+
selected = lanes_file or os.environ.get("FLUID_LANES_FILE")
|
|
125
|
+
if selected:
|
|
126
|
+
self.urls = {}
|
|
127
|
+
path = Path(selected)
|
|
128
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
129
|
+
configured = cast(dict[str, Any], raw.get("lanes", raw))
|
|
130
|
+
for lane in ("max", "omni"):
|
|
131
|
+
if isinstance(configured.get(lane), dict) and configured[lane].get("url"):
|
|
132
|
+
self.urls[lane] = str(configured[lane]["url"]).rstrip("/")
|
|
133
|
+
else:
|
|
134
|
+
self.urls = _default_urls()
|
|
135
|
+
for lane in ("max", "omni"):
|
|
136
|
+
override = os.environ.get("FD_MAX_URL" if lane == "max" else "FD_OMNI_URL")
|
|
137
|
+
if override is not None:
|
|
138
|
+
if not override.strip():
|
|
139
|
+
self.urls.pop(lane, None)
|
|
140
|
+
else:
|
|
141
|
+
self.urls[lane] = override.rstrip("/")
|
|
142
|
+
if any(not _valid_url(url) for url in self.urls.values()):
|
|
143
|
+
raise ValueError("lane URLs must be absolute HTTP(S) URLs")
|
|
144
|
+
self.first_timeout = float(os.environ.get("FD_FIRST_TIMEOUT", "8"))
|
|
145
|
+
self.timeout = float(os.environ.get("FD_TIMEOUT", "1"))
|
|
146
|
+
if not math.isfinite(self.first_timeout) or not math.isfinite(self.timeout) or self.first_timeout <= 0 or self.timeout <= 0:
|
|
147
|
+
raise ValueError("client timeouts must be finite and greater than zero")
|
|
148
|
+
post_timeout = os.environ.get("FD_POST_TIMEOUT")
|
|
149
|
+
self.post_timeout: float | None = None
|
|
150
|
+
if post_timeout is not None:
|
|
151
|
+
self.post_timeout = float(post_timeout)
|
|
152
|
+
if not math.isfinite(self.post_timeout) or self.post_timeout <= 0:
|
|
153
|
+
raise ValueError("FD_POST_TIMEOUT must be finite and greater than zero")
|
|
154
|
+
call_timeout = os.environ.get("FD_CALL_TIMEOUT")
|
|
155
|
+
self.call_timeout: float | None = None
|
|
156
|
+
if call_timeout is not None:
|
|
157
|
+
self.call_timeout = float(call_timeout)
|
|
158
|
+
if not math.isfinite(self.call_timeout) or self.call_timeout <= 0:
|
|
159
|
+
raise ValueError("FD_CALL_TIMEOUT must be finite and greater than zero")
|
|
160
|
+
self._seen: set[str] = set()
|
|
161
|
+
self.last_warmup_ms: float | None = None
|
|
162
|
+
self.cost_models: dict[str, dict[str, float]] = {}
|
|
163
|
+
self.warmup_errors: dict[str, str] = {}
|
|
164
|
+
self._http = httpx.Client(transport=transport, trust_env=False)
|
|
165
|
+
|
|
166
|
+
def __enter__(self) -> "D1Client":
|
|
167
|
+
return self
|
|
168
|
+
|
|
169
|
+
def __exit__(self, *_: Any) -> None:
|
|
170
|
+
self.close()
|
|
171
|
+
|
|
172
|
+
def close(self) -> None:
|
|
173
|
+
self._http.close()
|
|
174
|
+
|
|
175
|
+
def _lane(self, lane: str | None, images: Any = None, audio: Any = None) -> str:
|
|
176
|
+
if images and audio is not None:
|
|
177
|
+
raise ClientError("invalid_response", "images and audio cannot be combined")
|
|
178
|
+
selected = lane or ("omni" if audio is not None else "max")
|
|
179
|
+
if selected not in self.urls:
|
|
180
|
+
raise ClientError("lane_down", "requested lane is unavailable")
|
|
181
|
+
if audio is not None and selected != "omni":
|
|
182
|
+
raise ClientError("invalid_response", "audio requires the omni lane")
|
|
183
|
+
return selected
|
|
184
|
+
|
|
185
|
+
def _request(self, lane: str, method: str, path: str, json_body: dict[str, Any] | None = None, *, consume_budget: bool = True, budget_s: float | None = None) -> tuple[dict[str, Any], float]:
|
|
186
|
+
if budget_s is not None and (not math.isfinite(budget_s) or budget_s <= 0):
|
|
187
|
+
raise ValueError("budget_s must be finite and greater than zero")
|
|
188
|
+
budget = budget_s if budget_s is not None else ((self.first_timeout if lane not in self._seen else self.timeout) if consume_budget else self.timeout)
|
|
189
|
+
if consume_budget:
|
|
190
|
+
self._seen.add(lane)
|
|
191
|
+
started = time.perf_counter()
|
|
192
|
+
attempts = 0
|
|
193
|
+
while True:
|
|
194
|
+
remaining = budget - (time.perf_counter() - started)
|
|
195
|
+
if remaining <= 0:
|
|
196
|
+
raise ClientError("timeout", "decision request timed out")
|
|
197
|
+
try:
|
|
198
|
+
response = self._http.request(method, self.urls[lane] + path, json=json_body, timeout=remaining)
|
|
199
|
+
break
|
|
200
|
+
except httpx.TimeoutException as exc:
|
|
201
|
+
raise ClientError("timeout", "decision request timed out") from exc
|
|
202
|
+
except httpx.RemoteProtocolError as exc:
|
|
203
|
+
if attempts:
|
|
204
|
+
raise ClientError("lane_down", "decision lane disconnected") from exc
|
|
205
|
+
attempts += 1
|
|
206
|
+
except httpx.ReadError as exc:
|
|
207
|
+
if not _connection_reset(exc) or attempts:
|
|
208
|
+
raise ClientError("lane_down", "decision lane disconnected") from exc
|
|
209
|
+
attempts += 1
|
|
210
|
+
except httpx.ConnectError as exc:
|
|
211
|
+
raise ClientError("lane_down", "decision lane is unavailable") from exc
|
|
212
|
+
except httpx.HTTPError as exc:
|
|
213
|
+
raise ClientError("lane_down", "decision lane request failed") from exc
|
|
214
|
+
elapsed = (time.perf_counter() - started) * 1000
|
|
215
|
+
if response.status_code != 200:
|
|
216
|
+
raise ClientError("http_error", f"decision lane returned HTTP {response.status_code}")
|
|
217
|
+
try:
|
|
218
|
+
payload = response.json()
|
|
219
|
+
except ValueError as exc:
|
|
220
|
+
raise ClientError("invalid_response", "invalid response: JSON object expected") from exc
|
|
221
|
+
if not isinstance(payload, dict):
|
|
222
|
+
raise ClientError("invalid_response", "invalid response: JSON object expected")
|
|
223
|
+
return payload, elapsed
|
|
224
|
+
|
|
225
|
+
def system_one(self, state: Any, questions: dict[str, dict[str, Any]], *, images: list[str] | None = None, audio: str | None = None, lane: str | None = None, budget_s: float | None = None) -> dict[str, Any]:
|
|
226
|
+
selected = self._lane(lane, images, audio)
|
|
227
|
+
body: dict[str, Any] = {"model": "d1", "state": state, "questions": questions}
|
|
228
|
+
if images is not None:
|
|
229
|
+
body["images"] = images
|
|
230
|
+
if audio is not None:
|
|
231
|
+
body["audio"] = audio
|
|
232
|
+
payload, elapsed = self._request(selected, "POST", "/v1/systemone", body, budget_s=budget_s)
|
|
233
|
+
validate_response(payload, questions)
|
|
234
|
+
return enrich_response(payload, selected, elapsed, questions)
|
|
235
|
+
|
|
236
|
+
def system_one_batch(self, requests: list[dict[str, Any]], *, lane: str | None = None, budget_s: float | None = None) -> dict[str, Any]:
|
|
237
|
+
if not isinstance(requests, list) or not requests:
|
|
238
|
+
raise ClientError("invalid_response", "requests must be a non-empty list")
|
|
239
|
+
if any(not isinstance(item, dict) or not isinstance(item.get("questions"), dict) or not item["questions"] for item in requests):
|
|
240
|
+
raise ClientError("invalid_response", "batch requests must contain question objects")
|
|
241
|
+
has_audio = any(item.get("audio") is not None for item in requests)
|
|
242
|
+
has_text = any(item.get("audio") is None for item in requests)
|
|
243
|
+
if any(item.get("images") and item.get("audio") is not None for item in requests):
|
|
244
|
+
raise ClientError("invalid_response", "images and audio cannot be combined")
|
|
245
|
+
if lane is None and has_audio and has_text:
|
|
246
|
+
raise ClientError("invalid_response", "mixed audio and text batches require an explicit omni lane")
|
|
247
|
+
selected = self._lane(lane, None, "audio" if has_audio else None)
|
|
248
|
+
body_requests = [dict(item) for item in requests]
|
|
249
|
+
payload, elapsed = self._request(selected, "POST", "/v1/systemone/batch", {"requests": body_requests}, budget_s=budget_s)
|
|
250
|
+
results = payload.get("results") if isinstance(payload, dict) else None
|
|
251
|
+
if not isinstance(results, list) or len(results) != len(requests):
|
|
252
|
+
raise ClientError("invalid_response", "invalid response: batch results")
|
|
253
|
+
enriched = []
|
|
254
|
+
for request, result in zip(requests, results):
|
|
255
|
+
questions = request.get("questions")
|
|
256
|
+
if not isinstance(questions, dict):
|
|
257
|
+
raise ClientError("invalid_response", "invalid response: batch question set")
|
|
258
|
+
validate_response(result, questions)
|
|
259
|
+
enriched.append(enrich_response(result, selected, elapsed, questions))
|
|
260
|
+
return {"results": enriched, "timings": {**(payload.get("timings") or {}), "client_ms": round(elapsed, 1)}, "lane": selected}
|
|
261
|
+
|
|
262
|
+
def warmup(self, lane: str = "max") -> dict[str, Any]:
|
|
263
|
+
started = time.perf_counter()
|
|
264
|
+
self.warmup_errors.pop(lane, None)
|
|
265
|
+
self.cost_models.pop(lane, None)
|
|
266
|
+
try:
|
|
267
|
+
result = self.system_one("Warm-up state.", {"warmup": {"type": "noul", "instructions": "Is this a warm-up?"}}, lane=lane)
|
|
268
|
+
except ClientError as exc:
|
|
269
|
+
self.warmup_errors[lane] = exc.reason
|
|
270
|
+
self.last_warmup_ms = round((time.perf_counter() - started) * 1000, 1)
|
|
271
|
+
raise
|
|
272
|
+
try:
|
|
273
|
+
from .policy import load_pack
|
|
274
|
+
from .scoping import strip_scopes
|
|
275
|
+
post = load_pack("gate")["post_result"]
|
|
276
|
+
policy = post["policy"]
|
|
277
|
+
width = policy["window_chars"]
|
|
278
|
+
fraction = policy["warmup_probe_fraction"]
|
|
279
|
+
questions = post["questions"]
|
|
280
|
+
filler = "This is ordinary benign result text used for latency measurement. "
|
|
281
|
+
quarter = max(1, int(width * fraction))
|
|
282
|
+
probe_timings: list[tuple[int, float]] = []
|
|
283
|
+
for length in (width, quarter):
|
|
284
|
+
probe = self.system_one_batch(
|
|
285
|
+
[{"state": (filler * ((length // len(filler)) + 1))[:length], "questions": questions}],
|
|
286
|
+
lane=lane,
|
|
287
|
+
budget_s=self.first_timeout,
|
|
288
|
+
)
|
|
289
|
+
# system_one_batch reports one elapsed time for the request.
|
|
290
|
+
probe_timings.append((length, float(probe["timings"]["client_ms"])))
|
|
291
|
+
(x1, y1), (x2, y2) = probe_timings
|
|
292
|
+
slope = max(0.0, (y1 - y2) / (x1 - x2)) if x1 != x2 else 0.0
|
|
293
|
+
intercept = max(0.0, y1 - slope * x1, y2 - slope * x2)
|
|
294
|
+
model = {"intercept_ms": intercept, "ms_per_char": slope, "ms_per_question": 0.0}
|
|
295
|
+
# One extra probe isolates the marginal per-question cost: the same
|
|
296
|
+
# quarter-window state asked the gate pack's full question set,
|
|
297
|
+
# compared with the two post-result questions already probed at that
|
|
298
|
+
# length. A failed gate probe leaves ms_per_question at 0.0 rather
|
|
299
|
+
# than discarding the char-based model.
|
|
300
|
+
try:
|
|
301
|
+
gate_questions = strip_scopes(load_pack("gate")["questions"])
|
|
302
|
+
gate_probe = self.system_one_batch(
|
|
303
|
+
[{"state": (filler * ((quarter // len(filler)) + 1))[:quarter], "questions": gate_questions}],
|
|
304
|
+
lane=lane,
|
|
305
|
+
budget_s=self.first_timeout,
|
|
306
|
+
)
|
|
307
|
+
gate_ms = float(gate_probe["timings"]["client_ms"])
|
|
308
|
+
span = len(gate_questions) - len(questions)
|
|
309
|
+
if span > 0:
|
|
310
|
+
model["ms_per_question"] = max(0.0, (gate_ms - y2) / span)
|
|
311
|
+
except (ClientError, OSError, ValueError, KeyError, TypeError) as probe_exc:
|
|
312
|
+
self.warmup_errors[lane] = "per_question_probe:" + getattr(probe_exc, "reason", type(probe_exc).__name__)
|
|
313
|
+
self.cost_models[lane] = model
|
|
314
|
+
except (ClientError, OSError, ValueError, KeyError, TypeError) as exc:
|
|
315
|
+
self.cost_models.pop(lane, None)
|
|
316
|
+
self.warmup_errors[lane] = getattr(exc, "reason", type(exc).__name__)
|
|
317
|
+
self.last_warmup_ms = round((time.perf_counter() - started) * 1000, 1)
|
|
318
|
+
return result
|
|
319
|
+
|
|
320
|
+
def request_budget(self, total_chars: int, question_count: int, policy: dict[str, Any],
|
|
321
|
+
lane: str = "max", *, override: float | None = None) -> dict[str, Any]:
|
|
322
|
+
"""Derive a per-call budget from the measured cost model and a YAML policy.
|
|
323
|
+
|
|
324
|
+
Expected cost is ``intercept_ms + ms_per_char * total_chars +
|
|
325
|
+
ms_per_question * question_count``. ``override`` is an optional fixed
|
|
326
|
+
budget (from ``FD_CALL_TIMEOUT`` or ``FD_POST_TIMEOUT``); it is still
|
|
327
|
+
capped by the policy. When no cost model exists the budget falls back to
|
|
328
|
+
the fixed timeout and ``budget_source`` records that. A known over-cap
|
|
329
|
+
cost (or a fixed override smaller than the required budget) sets
|
|
330
|
+
``exceeds_budget`` so the caller can decline to send."""
|
|
331
|
+
if not isinstance(total_chars, int) or isinstance(total_chars, bool) or total_chars < 0:
|
|
332
|
+
raise ValueError("total_chars must be a non-negative integer")
|
|
333
|
+
if not isinstance(question_count, int) or isinstance(question_count, bool) or question_count < 0:
|
|
334
|
+
raise ValueError("question_count must be a non-negative integer")
|
|
335
|
+
try:
|
|
336
|
+
margin = float(policy["budget_margin"])
|
|
337
|
+
maximum = float(policy["max_budget_s"])
|
|
338
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
339
|
+
raise ValueError("budget policy is invalid") from exc
|
|
340
|
+
if not math.isfinite(margin) or margin <= 0 or not math.isfinite(maximum) or maximum <= 0:
|
|
341
|
+
raise ValueError("budget policy must be finite and greater than zero")
|
|
342
|
+
model = self.cost_models.get(lane)
|
|
343
|
+
expected_ms = None
|
|
344
|
+
required_s = None
|
|
345
|
+
if model is not None:
|
|
346
|
+
per_question = float(model.get("ms_per_question", 0.0))
|
|
347
|
+
expected_ms = max(0.0, model["intercept_ms"] + model["ms_per_char"] * total_chars
|
|
348
|
+
+ per_question * question_count)
|
|
349
|
+
required_s = margin * expected_ms / 1000.0
|
|
350
|
+
if override is not None:
|
|
351
|
+
source = "override"
|
|
352
|
+
budget_s = min(override, maximum)
|
|
353
|
+
elif required_s is not None:
|
|
354
|
+
source = "measured"
|
|
355
|
+
budget_s = min(max(self.timeout, required_s), maximum)
|
|
356
|
+
else:
|
|
357
|
+
source = "fallback"
|
|
358
|
+
budget_s = min(override if override is not None else self.timeout, maximum)
|
|
359
|
+
exceeds = required_s is not None and (required_s > maximum or (override is not None and override < required_s))
|
|
360
|
+
return {"budget_s": budget_s, "budget_source": source, "expected_ms": expected_ms,
|
|
361
|
+
"exceeds_budget": exceeds, "total_chars": total_chars, "questions": question_count}
|
|
362
|
+
|
|
363
|
+
def post_result_budget(self, total_chars: int, policy: dict[str, Any], lane: str = "max") -> dict[str, Any]:
|
|
364
|
+
"""Thin wrapper preserving IP-003 behaviour: post-result questions carry
|
|
365
|
+
no per-question term, and ``FD_POST_TIMEOUT`` is the fixed override."""
|
|
366
|
+
return self.request_budget(total_chars, 0, policy, lane, override=self.post_timeout)
|
|
367
|
+
|
|
368
|
+
def health(self) -> dict[str, Any]:
|
|
369
|
+
result: dict[str, Any] = {"last_warmup_ms": self.last_warmup_ms, "cost_models": copy.deepcopy(self.cost_models), "warmup_errors": dict(self.warmup_errors)}
|
|
370
|
+
for lane in self.urls:
|
|
371
|
+
try:
|
|
372
|
+
payload, elapsed = self._request(lane, "GET", "/health", consume_budget=False)
|
|
373
|
+
result[lane] = {**payload, "lane": lane, "timings": {"client_ms": round(elapsed, 1)}}
|
|
374
|
+
except ClientError as exc:
|
|
375
|
+
result[lane] = {"lane": lane, "error": {"reason": exc.reason, "message": str(exc)}}
|
|
376
|
+
return result
|
fluid/hook.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""One-shot stdin hook adapter for pre-tool and post-result events."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .cli import _result_exit, _service
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ask(reason: str, message: str) -> dict[str, Any]:
|
|
12
|
+
return {"verdict": "ask", "error": {"reason": reason, "message": message}}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def handle(event: Any, service: Any | None = None) -> dict[str, Any]:
|
|
16
|
+
if not isinstance(event, dict):
|
|
17
|
+
return _ask("invalid_input", "hook event must be a JSON object")
|
|
18
|
+
kind = event.get("event")
|
|
19
|
+
try:
|
|
20
|
+
if kind == "pre_tool":
|
|
21
|
+
if not isinstance(event.get("tool_name"), str) or not isinstance(event.get("tool_input"), dict):
|
|
22
|
+
return _ask("invalid_input", "pre_tool requires tool_name and object tool_input")
|
|
23
|
+
return (service or _service()).gate(
|
|
24
|
+
event["tool_name"], event["tool_input"], user_request=event.get("user_request", ""), session_id=event.get("session_id")
|
|
25
|
+
)
|
|
26
|
+
if kind == "post_result":
|
|
27
|
+
if not isinstance(event.get("result"), str) or not isinstance(event.get("session_id"), str):
|
|
28
|
+
return _ask("invalid_input", "post_result requires session_id and string result")
|
|
29
|
+
return (service or _service()).post_result(event["result"], session_id=event["session_id"], tool_name=event.get("tool_name", ""))
|
|
30
|
+
return _ask("invalid_input", "event must be pre_tool or post_result")
|
|
31
|
+
except Exception as exc:
|
|
32
|
+
return _ask("service_failure", str(exc))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def main() -> int:
|
|
36
|
+
try:
|
|
37
|
+
event = json.loads(sys.stdin.read())
|
|
38
|
+
except Exception as exc:
|
|
39
|
+
result = _ask("invalid_input", f"invalid JSON: {exc}")
|
|
40
|
+
print(json.dumps(result, separators=(",", ":")))
|
|
41
|
+
return 2
|
|
42
|
+
service = None
|
|
43
|
+
try:
|
|
44
|
+
service = _service()
|
|
45
|
+
result = handle(event, service)
|
|
46
|
+
except Exception:
|
|
47
|
+
result = _ask('service_failure', 'hook configuration or service unavailable')
|
|
48
|
+
finally:
|
|
49
|
+
if service is not None:
|
|
50
|
+
service.close()
|
|
51
|
+
print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
|
|
52
|
+
return _result_exit(result, 'hook')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__": # pragma: no cover
|
|
56
|
+
raise SystemExit(main())
|