functualize-decision-jev 0.1.0__tar.gz

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.
@@ -0,0 +1,150 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ *.egg-info/
7
+ *.egg
8
+ dist/
9
+ build/
10
+ *.whl
11
+
12
+ # Agents
13
+ .spec/archive/
14
+ .spec/scrutiny-reports/
15
+ .spec/verification-reports/
16
+ .spec/proposals/
17
+ .spec/plans/
18
+ .spec/.agentic-coding
19
+ .spec/STATE.md
20
+ # A one-hour spec-gate bypass token, not a repository artifact. The *ledger* of
21
+ # uses (.spec/exemptions.log) is committed and is the real mitigation; the token
22
+ # itself is per-session, and committing it would hand everyone else a live
23
+ # bypass. Ignored because the documented escape hatch otherwise sits in
24
+ # `git status` waiting to be added by accident.
25
+ .spec/EXEMPT
26
+ .opencode/
27
+
28
+
29
+ # Virtual environments
30
+ .venv/
31
+ venv/
32
+ ENV/
33
+
34
+ # Testing
35
+ .coverage
36
+ .pytest_cache/
37
+ htmlcov/
38
+ .hypothesis/
39
+ snapshot_report.html
40
+ _*_result*.txt
41
+ _debug.txt
42
+ _tui_debug.txt
43
+ _tui_eval_debug.txt
44
+
45
+ # IDE
46
+ .idea/
47
+ *.swp
48
+ *.swo
49
+ *~
50
+ *.code-workspace
51
+
52
+ # Coding-agent tooling state (guards — these dirs are not part of the repo)
53
+ .kiro/
54
+ .moai/
55
+
56
+ # OS
57
+ .DS_Store
58
+ Thumbs.db
59
+
60
+ # Environment / secrets
61
+ .env
62
+ .env.*
63
+ !.env.example
64
+
65
+ # Agent scratch space (test output, temp scripts)
66
+ tmp/
67
+
68
+ # Local-only files (not for the repo)
69
+ *.local.md
70
+ *.local.*
71
+
72
+ # Personal notes
73
+ HUMAN_NOTE.md
74
+
75
+ # Distribution
76
+ dist/
77
+
78
+ # Documentation site build output
79
+ site/
80
+
81
+ # uv
82
+ .python-version
83
+ .functualize/cache.json
84
+ .functualize_cache.json
85
+ .todos/
86
+ .sidecar/
87
+ .sidecar-agent
88
+ .sidecar-task
89
+ .sidecar-pr
90
+ .sidecar-start.sh
91
+ .sidecar-base
92
+ .td-root
93
+ .functualize/
94
+ # ...except the committed example plugin, which the blanket rule above swallowed.
95
+ # Git will not descend into an excluded directory, so every level must be un-excluded
96
+ # in turn, and each level re-ignores its own contents so that only the plugin sources
97
+ # become trackable — never the cache, state or lock files a run drops beside them.
98
+ !examples/plugins/file_based_plugin/.functualize/
99
+ examples/plugins/file_based_plugin/.functualize/*
100
+ !examples/plugins/file_based_plugin/.functualize/plugins/
101
+ examples/plugins/file_based_plugin/.functualize/plugins/*
102
+ !examples/plugins/file_based_plugin/.functualize/plugins/*.py
103
+ # Same chain for the monorepo plugin-sharing example. Its whole subject is a
104
+ # plugin living at a shared root, so the one file that must be committed is
105
+ # the one the blanket rule above hides.
106
+ !examples/project/shared_plugins/.functualize/
107
+ examples/project/shared_plugins/.functualize/*
108
+ !examples/project/shared_plugins/.functualize/plugins/
109
+ examples/project/shared_plugins/.functualize/plugins/*
110
+ !examples/project/shared_plugins/.functualize/plugins/*.py
111
+ .import_linter_cache/
112
+ .mypy_cache/
113
+ .pytest_cache/
114
+ .ruff_cache/
115
+
116
+ # OmO / OpenCode agent run-continuation scratch state
117
+ .omo/
118
+ .mcp.json
119
+
120
+ # Internal pre-release audit reports (contain session IDs / local infra notes)
121
+ .release/
122
+ .worktrees/
123
+ .claude/settings.local.json
124
+
125
+ # Agent index directories. Which parts are tracked is decided by ONE property:
126
+ # whether the stored paths are portable.
127
+ #
128
+ # graphify graph.json holds only repo-relative paths, so it travels into every
129
+ # worktree and every ephemeral cloud checkout for free. Tracked.
130
+ # Its cache/, manifest.json and .graphify_root are machine-local
131
+ # (mtimes, absolute paths), so the rule below is a whitelist: ignore
132
+ # everything, then un-ignore the two portable artifacts.
133
+ # zvec-grep index entries are keyed by ABSOLUTE path — copying one and rewriting
134
+ # its manifest yields 0% coverage and a full re-embed. Never tracked;
135
+ # each workspace builds its own (~21s scoped).
136
+ # serena cache/ pickles hold absolute file:// URIs, so those and
137
+ # project.local.yml are machine-local; serena's own
138
+ # .serena/.gitignore already excludes cache/ and project.local.yml.
139
+ # memories/ is plain text and portable, but not tracked anyway: it
140
+ # duplicated contract docs that already have a committed home
141
+ # (`.claude/rules/spec-workflow.md`, `.spec/ARCHITECTURE.md`,
142
+ # AGENTS.md) and went stale the moment those changed underneath
143
+ # it. project.yml stays tracked and portable.
144
+ graphify-out/*
145
+ !graphify-out/graph.json
146
+ !graphify-out/GRAPH_REPORT.md
147
+ .zvec-grep/
148
+ .serena/cache/
149
+ .serena/project.local.yml
150
+ .serena/memories/
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.5
2
+ Name: functualize-decision-jev
3
+ Version: 0.1.0
4
+ Summary: Experimental decision-provider plugin for functualize — proposes a choice through the Jev model service
5
+ Author-email: Mohammad Hakim Adiprasetya <viltohmyst@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.11
14
+ Requires-Dist: functualize<1.0.0,>=0.1.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
17
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
18
+ Description-Content-Type: text/markdown
19
+
20
+ # functualize-decision-jev
21
+
22
+ > **Status: Tier 3 — experimental.** The Phase 1 decision-provider adapter;
23
+ > requires `OPENCODE_API_KEY`. It proposes a candidate answer to a `choice`
24
+ > question through the Jev model service, and the gate that asked applies its
25
+ > own declared thresholds — the provider never decides whether its proposal is
26
+ > acted on. The package is an empty scaffold today: the provider, its wire
27
+ > mapping and the plugin that registers the `decision` gate strategy arrive in
28
+ > later steps of the same change.
@@ -0,0 +1,9 @@
1
+ # functualize-decision-jev
2
+
3
+ > **Status: Tier 3 — experimental.** The Phase 1 decision-provider adapter;
4
+ > requires `OPENCODE_API_KEY`. It proposes a candidate answer to a `choice`
5
+ > question through the Jev model service, and the gate that asked applies its
6
+ > own declared thresholds — the provider never decides whether its proposal is
7
+ > acted on. The package is an empty scaffold today: the provider, its wire
8
+ > mapping and the plugin that registers the `decision` gate strategy arrive in
9
+ > later steps of the same change.
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "functualize-decision-jev"
3
+ version = "0.1.0"
4
+ description = "Experimental decision-provider plugin for functualize — proposes a choice through the Jev model service"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ authors = [
8
+ { name = "Mohammad Hakim Adiprasetya", email = "viltohmyst@gmail.com" }
9
+ ]
10
+ requires-python = ">=3.11"
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Programming Language :: Python :: 3.13",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = [
20
+ "functualize>=0.1.0,<1.0.0",
21
+ ]
22
+
23
+ [project.entry-points."functualize.plugins"]
24
+ jev = "functualize_decision_jev:JevPlugin"
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pytest>=7.4.0",
29
+ "pytest-cov>=4.1.0",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.uv.sources]
37
+ functualize = { workspace = true }
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/functualize_decision_jev"]
@@ -0,0 +1,10 @@
1
+ """Functualize decision provider backed by the Jev model service.
2
+
3
+ Experimental. ``JevPlugin`` is loaded through the ``functualize.plugins`` entry
4
+ point and registers the ``decision`` gate strategy, answered by
5
+ ``JevDecisionProvider``; the provider needs ``OPENCODE_API_KEY`` at call time.
6
+ """
7
+
8
+ from functualize_decision_jev._plugin import JevPlugin
9
+
10
+ __all__ = ["JevPlugin"]
@@ -0,0 +1,91 @@
1
+ """The Jev decision plugin — registers the ``decision`` gate strategy.
2
+
3
+ Registered via entry point ``functualize.plugins`` with name ``"jev"``. At
4
+ ``APP_READY`` it resolves ``JevConfig`` from the ``[jev]`` config section and
5
+ registers ``DecisionGateResolver(JevDecisionProvider(config))`` as the
6
+ ``decision`` strategy, so a ``Gate(decide=...)`` in any workflow of the app can
7
+ be answered by a proposal that clears the workflow's own thresholds.
8
+
9
+ It registers nothing else: no preset, no DI provider, and no credential. The
10
+ key is read by the provider at call time, from the environment, never here —
11
+ so an app with the plugin installed and no key still boots, and a decision gate
12
+ records ``not_configured`` and falls through to a person.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from typing import TYPE_CHECKING
19
+
20
+ from functualize.plugin import DecisionGateResolver
21
+
22
+ if TYPE_CHECKING:
23
+ from functualize.plugin import PluginHost
24
+ from functualize_decision_jev._provider import JevConfig
25
+
26
+ __all__ = ["JevPlugin"]
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ #: The config section `JevConfig` is resolved from.
31
+ _SECTION = "jev"
32
+
33
+
34
+ class JevPlugin:
35
+ """Plugin that answers ``decision`` gates with the Jev provider."""
36
+
37
+ name: str = "jev"
38
+ version: str = "0.1.0"
39
+ description: str = (
40
+ "Experimental decision provider — proposes a gate's choice through Jev"
41
+ )
42
+
43
+ def __call__(self, app: PluginHost) -> None:
44
+ """Defer registration to ``APP_READY``.
45
+
46
+ Plugins load before the config resolution chain is built, so resolving
47
+ ``[jev]`` here would always fall back to the defaults and a configured
48
+ section would be ignored without a word. Walks start after boot, so a
49
+ strategy registered at ``APP_READY`` is in place before any gate is
50
+ reached.
51
+ """
52
+ app.hooks.on_ready(self._on_app_ready)
53
+
54
+ def _on_app_ready(self, app: PluginHost) -> None:
55
+ # Imported here, not at module level: the provider's transport pulls in
56
+ # `urllib.request` and `http.client`, which the loader would otherwise
57
+ # charge to plugin loading on every boot of every app that has this
58
+ # package installed.
59
+ from functualize_decision_jev._provider import JevDecisionProvider
60
+
61
+ provider = JevDecisionProvider(self._resolve_config(app))
62
+ app.gates.register_gate_strategy("decision", DecisionGateResolver(provider))
63
+
64
+ def _resolve_config(self, app: PluginHost) -> JevConfig:
65
+ """``[jev]`` as a ``JevConfig``, or the defaults when it cannot be used.
66
+
67
+ An absent section resolves to the defaults without an error. A section
68
+ that is present but unusable — an unknown key, a timeout that is not a
69
+ number — falls back to the defaults as the sibling plugins do, but says
70
+ so at warning level: a misconfiguration that silently changed nothing
71
+ would be indistinguishable from one that worked.
72
+ """
73
+ from functualize_decision_jev._provider import JevConfig
74
+
75
+ try:
76
+ resolved = app.configuration.resolve_model(_SECTION, JevConfig)
77
+ if not isinstance(resolved, JevConfig):
78
+ raise TypeError(f"resolved {type(resolved).__name__}, not JevConfig")
79
+ return JevConfig(
80
+ model=str(resolved.model),
81
+ endpoint=str(resolved.endpoint),
82
+ timeout_seconds=float(resolved.timeout_seconds),
83
+ )
84
+ except Exception as exc:
85
+ logger.warning(
86
+ "JevPlugin: the [%s] config section could not be used (%s); "
87
+ "using the defaults",
88
+ _SECTION,
89
+ exc,
90
+ )
91
+ return JevConfig()
@@ -0,0 +1,233 @@
1
+ """The Jev decision provider, and the transport port it sends through.
2
+
3
+ ``JevDecisionProvider.choose`` is one round trip: read the credential, build
4
+ the body (``_wire``), send it once through a ``JevTransport``, and map what came
5
+ back (``_wire`` again). It never waits and never tries again — a rate limit or
6
+ an outage is reported as a ``DecisionUnavailableError`` and the caller decides
7
+ what happens next.
8
+
9
+ The transport is a port so that everything above the socket is tested with a
10
+ fake that records what it was sent; ``UrllibTransport`` is the production
11
+ implementation, standard library only.
12
+
13
+ **The credential is read at call time**, through a zero-argument supplier that
14
+ defaults to the ``OPENCODE_API_KEY`` environment variable — never at
15
+ construction, at import, or from a config file — so nothing this module
16
+ constructs holds it, and no ``repr`` can show it.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import http.client
22
+ import json
23
+ import os
24
+ import time
25
+ import urllib.error
26
+ import urllib.request
27
+ from collections.abc import Callable, Mapping
28
+ from dataclasses import dataclass
29
+ from email.message import Message
30
+ from importlib.metadata import version
31
+ from typing import Protocol, runtime_checkable
32
+
33
+ from functualize.plugin import (
34
+ ChoiceRequest,
35
+ DecisionFailure,
36
+ DecisionResult,
37
+ DecisionUnavailableError,
38
+ )
39
+ from functualize_decision_jev import _wire
40
+
41
+ __all__ = [
42
+ "JevConfig",
43
+ "JevDecisionProvider",
44
+ "JevTransport",
45
+ "UrllibTransport",
46
+ "WireResponse",
47
+ ]
48
+
49
+ _PROVIDER = "jev"
50
+ _DISTRIBUTION = "functualize-decision-jev"
51
+ _CREDENTIAL_VARIABLE = "OPENCODE_API_KEY"
52
+
53
+
54
+ @runtime_checkable
55
+ class JevTransport(Protocol):
56
+ """Sends one request and returns the response, whatever its status.
57
+
58
+ Raises only for a transport failure — no response at all — and then only
59
+ ``DecisionUnavailableError`` with ``kind=UNREACHABLE``.
60
+ """
61
+
62
+ def post(
63
+ self, url: str, body: bytes, headers: Mapping[str, str], timeout: float
64
+ ) -> WireResponse: ...
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class WireResponse:
69
+ """One HTTP response, decoded."""
70
+
71
+ status: int
72
+ body: str
73
+ headers: Mapping[str, str] # keys lower-cased
74
+
75
+
76
+ class UrllibTransport:
77
+ """The production ``JevTransport``: one ``POST`` through ``urllib.request``.
78
+
79
+ A ``4xx``/``5xx`` is a response, not a failure, so ``HTTPError`` is read
80
+ rather than raised: what it means is ``_wire.failure_for``'s decision.
81
+ """
82
+
83
+ def post(
84
+ self, url: str, body: bytes, headers: Mapping[str, str], timeout: float
85
+ ) -> WireResponse:
86
+ request = urllib.request.Request(
87
+ url, data=body, headers=dict(headers), method="POST"
88
+ )
89
+ try:
90
+ with urllib.request.urlopen(request, timeout=timeout) as response:
91
+ return _response(response.status, response.read(), response.headers)
92
+ except urllib.error.HTTPError as error:
93
+ return _response(error.code, error.read(), error.headers)
94
+ # URLError and TimeoutError are both OSError; HTTPException is the
95
+ # connection dropping mid-response (IncompleteRead, BadStatusLine),
96
+ # which is equally "no usable response" and would otherwise escape the
97
+ # port as something other than a DecisionUnavailableError.
98
+ except (OSError, http.client.HTTPException) as error:
99
+ raise DecisionUnavailableError(
100
+ kind=DecisionFailure.UNREACHABLE,
101
+ provider=_PROVIDER,
102
+ status=None,
103
+ detail=str(error),
104
+ ) from error
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class JevConfig:
109
+ """The provider's settings, resolved from config section ``[jev]``."""
110
+
111
+ model: str = "jev-1.13-free"
112
+ endpoint: str = "https://opencode.ai/zen/v1/systemone"
113
+ timeout_seconds: float = 30.0
114
+
115
+
116
+ class JevDecisionProvider:
117
+ """A ``DecisionProvider`` that proposes a choice through Jev."""
118
+
119
+ name: str = _PROVIDER
120
+
121
+ def __init__(
122
+ self,
123
+ config: JevConfig = JevConfig(), # noqa: B008 — frozen, so one shared default is safe
124
+ *,
125
+ transport: JevTransport | None = None,
126
+ credential: Callable[[], str | None] | None = None,
127
+ ) -> None:
128
+ self._config = config
129
+ self._transport = transport if transport is not None else UrllibTransport()
130
+ # A supplier, not a value: it is called inside `choose`, so the key is
131
+ # read at call time and never held by this object.
132
+ self._credential = credential if credential is not None else _from_environment
133
+ self._user_agent = f"{_DISTRIBUTION}/{version(_DISTRIBUTION)}"
134
+
135
+ def __repr__(self) -> str:
136
+ return f"{type(self).__name__}(config={self._config!r})"
137
+
138
+ def choose(self, request: ChoiceRequest) -> DecisionResult[str]:
139
+ key = self._credential()
140
+ if not key:
141
+ raise DecisionUnavailableError(
142
+ kind=DecisionFailure.NOT_CONFIGURED,
143
+ provider=_PROVIDER,
144
+ detail=f"{_CREDENTIAL_VARIABLE} is not set",
145
+ )
146
+ config = self._config
147
+ model = request.model or config.model
148
+ body = json.dumps(_wire.build_request(request, model=config.model)).encode()
149
+ headers = {
150
+ "Authorization": f"Bearer {key}",
151
+ "Content-Type": "application/json",
152
+ "User-Agent": self._user_agent,
153
+ }
154
+
155
+ failure: DecisionUnavailableError | None = None
156
+ try:
157
+ started = time.monotonic()
158
+ response = _without(
159
+ key,
160
+ self._transport.post(
161
+ config.endpoint, body, headers, config.timeout_seconds
162
+ ),
163
+ )
164
+ elapsed = time.monotonic() - started
165
+
166
+ if response.status != 200:
167
+ raise _wire.failure_for(
168
+ response.status, response.body, response.headers
169
+ )
170
+ try:
171
+ payload = json.loads(response.body)
172
+ except ValueError as error:
173
+ raise DecisionUnavailableError(
174
+ kind=DecisionFailure.MALFORMED,
175
+ provider=_PROVIDER,
176
+ status=200,
177
+ detail=f"body is not JSON: {error}",
178
+ ) from error
179
+ return _wire.parse_choice(
180
+ payload, request, requested_model=model, latency_seconds=elapsed
181
+ )
182
+ except DecisionUnavailableError as error:
183
+ # Every failure's text is recorded as a gate rung's detail, so the
184
+ # key must not survive into it by any route — an echoing body, a
185
+ # transport message, a parsed value. Rebuilt outside this block so
186
+ # the original is not kept as the new error's context.
187
+ failure = _scrubbed(error, key) if key in error.detail else error
188
+ raise failure
189
+
190
+
191
+ _REDACTED = "[redacted]"
192
+
193
+
194
+ def _without(key: str, response: WireResponse) -> WireResponse:
195
+ """The response with the credential removed from its body and headers.
196
+
197
+ A service may echo what it was sent — a refusal quoting the rejected
198
+ ``Authorization`` header is the common case — and everything built from a
199
+ response can end up in recorded run state.
200
+ """
201
+ return WireResponse(
202
+ status=response.status,
203
+ body=response.body.replace(key, _REDACTED),
204
+ headers={
205
+ name: value.replace(key, _REDACTED)
206
+ for name, value in response.headers.items()
207
+ },
208
+ )
209
+
210
+
211
+ def _scrubbed(error: DecisionUnavailableError, key: str) -> DecisionUnavailableError:
212
+ """``error`` rebuilt with the credential removed from its detail."""
213
+ return DecisionUnavailableError(
214
+ kind=error.kind,
215
+ provider=error.provider,
216
+ detail=error.detail.replace(key, _REDACTED),
217
+ status=error.status,
218
+ retry_after=error.retry_after,
219
+ )
220
+
221
+
222
+ def _from_environment() -> str | None:
223
+ return os.environ.get(_CREDENTIAL_VARIABLE)
224
+
225
+
226
+ def _response(
227
+ status: int, raw: bytes, headers: Message | Mapping[str, str] | None
228
+ ) -> WireResponse:
229
+ return WireResponse(
230
+ status=status,
231
+ body=raw.decode("utf-8", errors="replace"),
232
+ headers={name.lower(): value for name, value in (headers or {}).items()},
233
+ )
@@ -0,0 +1,216 @@
1
+ """The Jev wire mapping — pure functions between the port's values and JSON.
2
+
3
+ Three functions and no I/O: ``build_request`` turns a ``ChoiceRequest`` into the
4
+ request body, ``parse_choice`` turns a ``200`` body into a ``DecisionResult``,
5
+ and ``failure_for`` turns any other status into a ``DecisionUnavailableError``.
6
+ Sending the body, the headers and the credential are the provider's business,
7
+ not this module's, so every rule here can be tested against measured bodies
8
+ without a network.
9
+
10
+ The service's response shape is a discriminated union keyed by ``type`` (a
11
+ ``choice`` answer carries ``choice``, ``confidence`` and ``probabilities``; a
12
+ ``noul`` answer carries none of them), so a ``200`` is never trusted to be the
13
+ shape that was asked for: every field is checked, and any mismatch is
14
+ ``MALFORMED`` rather than a ``KeyError`` or a ``TypeError`` escaping into the
15
+ gate that asked.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import math
21
+ from collections.abc import Mapping
22
+ from typing import Any
23
+
24
+ from functualize.plugin import (
25
+ ChoiceRequest,
26
+ DecisionFailure,
27
+ DecisionProvenance,
28
+ DecisionResult,
29
+ DecisionUnavailableError,
30
+ )
31
+
32
+ __all__ = ["QUESTION_ID", "build_request", "failure_for", "parse_choice"]
33
+
34
+ #: The one question id this adapter sends. ``answers`` is keyed by it.
35
+ QUESTION_ID = "decision"
36
+
37
+ _PROVIDER = "jev"
38
+ #: The longest body a failure quotes. The error clips again; this keeps the
39
+ #: rule visible where the body is read.
40
+ _DETAIL_LIMIT = 300
41
+
42
+
43
+ class _MalformedError(Exception):
44
+ """A shape rule a ``200`` body broke; carries the rule, for ``detail``."""
45
+
46
+
47
+ def build_request(request: ChoiceRequest, *, model: str) -> dict[str, Any]:
48
+ """The request body for one ``choice`` question.
49
+
50
+ ``model`` is the provider's configured default; a model named on the
51
+ request wins over it.
52
+ """
53
+ return {
54
+ "model": request.model if request.model is not None else model,
55
+ "state": request.state,
56
+ "questions": {
57
+ QUESTION_ID: {
58
+ "type": "choice",
59
+ "instructions": request.instructions,
60
+ "criteria": dict(request.options),
61
+ }
62
+ },
63
+ }
64
+
65
+
66
+ def parse_choice(
67
+ payload: Mapping[str, Any],
68
+ request: ChoiceRequest,
69
+ *,
70
+ requested_model: str,
71
+ latency_seconds: float,
72
+ ) -> DecisionResult[str]:
73
+ """A ``200`` body as a ``DecisionResult``, or ``MALFORMED``.
74
+
75
+ Raises nothing but ``DecisionUnavailableError``: a body that breaks any
76
+ shape rule, or carries a probability outside ``[0, 1]``, is reported with
77
+ ``status=200`` and the rule it broke as ``detail``.
78
+ """
79
+ try:
80
+ return _parse_choice(
81
+ payload,
82
+ request,
83
+ requested_model=requested_model,
84
+ latency_seconds=latency_seconds,
85
+ )
86
+ except _MalformedError as broken:
87
+ detail = str(broken)
88
+ except ValueError as out_of_range:
89
+ detail = f"answer is out of range: {out_of_range}"
90
+ raise DecisionUnavailableError(
91
+ kind=DecisionFailure.MALFORMED,
92
+ provider=_PROVIDER,
93
+ status=200,
94
+ detail=detail,
95
+ )
96
+
97
+
98
+ def failure_for(
99
+ status: int, body: str, headers: Mapping[str, str]
100
+ ) -> DecisionUnavailableError:
101
+ """The failure a non-``200`` response stands for.
102
+
103
+ ``429`` is ``RATE_LIMITED`` and carries the service's ``Retry-After`` as
104
+ sent; every other status is ``REFUSED``. The body is quoted, clipped, as
105
+ the detail — refusal bodies come in several JSON shapes and one plain-text
106
+ one, and a person reading a blocked gate needs whichever it was.
107
+ """
108
+ detail = body[:_DETAIL_LIMIT]
109
+ if status == 429:
110
+ return DecisionUnavailableError(
111
+ kind=DecisionFailure.RATE_LIMITED,
112
+ provider=_PROVIDER,
113
+ status=status,
114
+ retry_after=_retry_after(headers),
115
+ detail=detail,
116
+ )
117
+ return DecisionUnavailableError(
118
+ kind=DecisionFailure.REFUSED,
119
+ provider=_PROVIDER,
120
+ status=status,
121
+ detail=detail,
122
+ )
123
+
124
+
125
+ def _parse_choice(
126
+ payload: Mapping[str, Any],
127
+ request: ChoiceRequest,
128
+ *,
129
+ requested_model: str,
130
+ latency_seconds: float,
131
+ ) -> DecisionResult[str]:
132
+ if not isinstance(payload, Mapping):
133
+ raise _MalformedError(f"body is {type(payload).__name__}, expected an object")
134
+ answered_by = payload.get("model")
135
+ if not isinstance(answered_by, str):
136
+ raise _MalformedError("body has no string 'model'")
137
+ answers = _mapping(payload.get("answers"), "'answers'")
138
+ answer = _mapping(answers.get(QUESTION_ID), f"'answers.{QUESTION_ID}'")
139
+
140
+ kind = answer.get("type")
141
+ if kind != "choice":
142
+ raise _MalformedError(f"answer type is {kind!r}, expected 'choice'")
143
+ value = answer.get("choice")
144
+ if not isinstance(value, str) or value not in request.options:
145
+ raise _MalformedError(
146
+ f"answer choice {value!r} is not one of the options "
147
+ f"{sorted(request.options)}"
148
+ )
149
+
150
+ raw_distribution = _mapping(answer.get("probabilities"), "'probabilities'")
151
+ distribution: dict[str, float] = {}
152
+ for option, probability in raw_distribution.items():
153
+ if not isinstance(option, str):
154
+ raise _MalformedError(f"'probabilities' key {option!r} is not a string")
155
+ distribution[option] = _number(probability, f"'probabilities.{option}'")
156
+ raw_confidence = answer.get("confidence")
157
+ confidence = (
158
+ None if raw_confidence is None else _number(raw_confidence, "'confidence'")
159
+ )
160
+
161
+ usage = payload.get("usage")
162
+ if usage is None:
163
+ usage = {}
164
+ usage = _mapping(usage, "'usage'")
165
+
166
+ return DecisionResult(
167
+ value=value,
168
+ provider=_PROVIDER,
169
+ model=answered_by,
170
+ provenance=DecisionProvenance(
171
+ requested_model=requested_model,
172
+ latency_seconds=latency_seconds,
173
+ input_tokens=_tokens(usage, "input_tokens"),
174
+ output_tokens=_tokens(usage, "output_tokens"),
175
+ ),
176
+ distribution=distribution,
177
+ confidence=confidence,
178
+ )
179
+
180
+
181
+ def _mapping(value: object, name: str) -> Mapping[str, Any]:
182
+ if not isinstance(value, Mapping):
183
+ raise _MalformedError(f"{name} is missing or not an object")
184
+ return value
185
+
186
+
187
+ def _number(value: object, name: str) -> float:
188
+ # `bool` is an `int` subclass, and `true` is not a probability.
189
+ if isinstance(value, bool) or not isinstance(value, int | float):
190
+ raise _MalformedError(f"{name} is {value!r}, expected a number")
191
+ return float(value)
192
+
193
+
194
+ def _tokens(usage: Mapping[str, Any], key: str) -> int | None:
195
+ count = usage.get(key)
196
+ if count is None:
197
+ return None
198
+ if isinstance(count, bool) or not isinstance(count, int):
199
+ raise _MalformedError(f"'usage.{key}' is {count!r}, expected an integer")
200
+ return count
201
+
202
+
203
+ def _retry_after(headers: Mapping[str, str]) -> float | None:
204
+ # Header names are case-insensitive on the wire; the transport may or may
205
+ # not have normalised them, so this does not rely on it.
206
+ raw = next(
207
+ (value for name, value in headers.items() if name.lower() == "retry-after"),
208
+ None,
209
+ )
210
+ if raw is None:
211
+ return None
212
+ try:
213
+ seconds = float(raw)
214
+ except ValueError:
215
+ return None
216
+ return seconds if math.isfinite(seconds) else None