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.
- functualize_decision_jev-0.1.0/.gitignore +150 -0
- functualize_decision_jev-0.1.0/PKG-INFO +28 -0
- functualize_decision_jev-0.1.0/README.md +9 -0
- functualize_decision_jev-0.1.0/pyproject.toml +40 -0
- functualize_decision_jev-0.1.0/src/functualize_decision_jev/__init__.py +10 -0
- functualize_decision_jev-0.1.0/src/functualize_decision_jev/_plugin.py +91 -0
- functualize_decision_jev-0.1.0/src/functualize_decision_jev/_provider.py +233 -0
- functualize_decision_jev-0.1.0/src/functualize_decision_jev/_wire.py +216 -0
- functualize_decision_jev-0.1.0/src/functualize_decision_jev/py.typed +0 -0
|
@@ -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
|
|
File without changes
|