jev-mcp-python 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.
- jev_mcp/__init__.py +1 -0
- jev_mcp/__main__.py +3 -0
- jev_mcp/domain/__init__.py +32 -0
- jev_mcp/domain/answers.py +25 -0
- jev_mcp/domain/json.py +49 -0
- jev_mcp/domain/questions.py +75 -0
- jev_mcp/domain/usage.py +16 -0
- jev_mcp/errors.py +59 -0
- jev_mcp/extract/__init__.py +1 -0
- jev_mcp/extract/candidates.py +75 -0
- jev_mcp/extract/dialect.py +400 -0
- jev_mcp/extract/executor.py +118 -0
- jev_mcp/extract/worker.py +198 -0
- jev_mcp/ids.py +49 -0
- jev_mcp/limits.py +218 -0
- jev_mcp/policy/__init__.py +98 -0
- jev_mcp/policy/actions.py +41 -0
- jev_mcp/policy/claims.py +103 -0
- jev_mcp/policy/extract.py +73 -0
- jev_mcp/policy/ranking.py +41 -0
- jev_mcp/policy/review.py +73 -0
- jev_mcp/policy/screen.py +48 -0
- jev_mcp/policy/thresholds.py +74 -0
- jev_mcp/providers/__init__.py +26 -0
- jev_mcp/providers/base.py +236 -0
- jev_mcp/providers/cloudflare.py +59 -0
- jev_mcp/providers/compatible.py +43 -0
- jev_mcp/providers/openrouter.py +47 -0
- jev_mcp/providers/resolver.py +106 -0
- jev_mcp/providers/typesafe.py +127 -0
- jev_mcp/py.typed +0 -0
- jev_mcp/serialize.py +199 -0
- jev_mcp/server.py +176 -0
- jev_mcp/settings.py +73 -0
- jev_mcp/stdio.py +99 -0
- jev_mcp/telemetry.py +223 -0
- jev_mcp/text.py +42 -0
- jev_mcp/tools/__init__.py +20 -0
- jev_mcp/tools/arguments.py +447 -0
- jev_mcp/tools/base.py +153 -0
- jev_mcp/tools/classify.py +187 -0
- jev_mcp/tools/common.py +96 -0
- jev_mcp/tools/compare.py +143 -0
- jev_mcp/tools/decide.py +206 -0
- jev_mcp/tools/extract.py +262 -0
- jev_mcp/tools/find.py +113 -0
- jev_mcp/tools/gate.py +236 -0
- jev_mcp/tools/observed.py +69 -0
- jev_mcp/tools/rerank.py +139 -0
- jev_mcp/tools/review.py +236 -0
- jev_mcp/tools/screen.py +126 -0
- jev_mcp/tools/toolset.py +92 -0
- jev_mcp/tools/verify.py +141 -0
- jev_mcp/validation/__init__.py +25 -0
- jev_mcp/validation/caps.py +93 -0
- jev_mcp/validation/choice.py +65 -0
- jev_mcp/validation/extract.py +48 -0
- jev_mcp/validation/noul.py +15 -0
- jev_mcp/validation/numbers.py +21 -0
- jev_mcp/validation/score.py +20 -0
- jev_mcp_python-0.1.0.dist-info/METADATA +18 -0
- jev_mcp_python-0.1.0.dist-info/RECORD +65 -0
- jev_mcp_python-0.1.0.dist-info/WHEEL +4 -0
- jev_mcp_python-0.1.0.dist-info/entry_points.txt +2 -0
- jev_mcp_python-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Cloudflare Workers AI (`provider.ts:230-260`): the Jev contract inside `{model, input}` and the v4 envelope."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from jev_mcp.domain import JsonValue, is_json_object
|
|
8
|
+
from jev_mcp.errors import Redactor
|
|
9
|
+
from jev_mcp.providers.base import Evaluation, HttpProvider, ProviderName, decode_body, parse_envelope
|
|
10
|
+
from jev_mcp.serialize import stringify_compact
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def cloudflare_slug(model: str) -> str:
|
|
14
|
+
"""A `typesafe/` model is kept verbatim; `jev-latest` is the single alias `typesafe/jev`."""
|
|
15
|
+
if model.startswith("typesafe/"):
|
|
16
|
+
return model
|
|
17
|
+
return f"typesafe/{'jev' if model == 'jev-latest' else model}"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CloudflareProvider(HttpProvider):
|
|
21
|
+
name: ClassVar[ProviderName] = "cloudflare"
|
|
22
|
+
label: ClassVar[str] = "Cloudflare AI run"
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, redact: Redactor, *, api_token: str, account_id: str, client: httpx.AsyncClient | None = None
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(redact, client)
|
|
28
|
+
self._api_token = api_token
|
|
29
|
+
self._url = f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run"
|
|
30
|
+
|
|
31
|
+
async def _send(
|
|
32
|
+
self, state: JsonValue, questions: dict[str, JsonValue], model: str, timeout: float | None
|
|
33
|
+
) -> Evaluation:
|
|
34
|
+
slug = cloudflare_slug(model)
|
|
35
|
+
response = await self._post(
|
|
36
|
+
self._url,
|
|
37
|
+
{"Authorization": f"Bearer {self._api_token}"},
|
|
38
|
+
{"model": slug, "input": {"state": state, "questions": questions}},
|
|
39
|
+
)
|
|
40
|
+
# `.json().catch(() => ({}))`: an unparseable body is an empty object here, not null.
|
|
41
|
+
parsed = decode_body(response.content)
|
|
42
|
+
body: object = {} if parsed is None else parsed
|
|
43
|
+
record: dict[str, object] = body if is_json_object(body) else {}
|
|
44
|
+
errors = record.get("errors")
|
|
45
|
+
if not response.is_success or record.get("success") is False:
|
|
46
|
+
raise self._status_error(response.status_code, stringify_compact(body if errors is None else errors))
|
|
47
|
+
# The v4 envelope double-nests: `result.result` holds the model output.
|
|
48
|
+
outer = record.get("result")
|
|
49
|
+
inner: object = None
|
|
50
|
+
if is_json_object(outer):
|
|
51
|
+
run_state = outer.get("state")
|
|
52
|
+
# A missing state is not an error; only a string other than Completed is.
|
|
53
|
+
if isinstance(run_state, str) and run_state != "Completed":
|
|
54
|
+
raise self._status_error(f"state {run_state}", stringify_compact([] if errors is None else errors))
|
|
55
|
+
inner = outer.get("result")
|
|
56
|
+
payload = inner if inner is not None else outer if outer is not None else body
|
|
57
|
+
# ADR-0003: the unwrapped payload gets the uniform envelope rules; the reference used `answers ?? {}`.
|
|
58
|
+
envelope = parse_envelope(payload, self.label)
|
|
59
|
+
return Evaluation(envelope.answers, envelope.usage, self.name, envelope.model_or(slug))
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""A caller-configured Jev-compatible System One endpoint (`provider.ts:158-201`)."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from jev_mcp.domain import JsonValue
|
|
8
|
+
from jev_mcp.errors import Redactor
|
|
9
|
+
from jev_mcp.providers.base import (
|
|
10
|
+
Evaluation,
|
|
11
|
+
HttpProvider,
|
|
12
|
+
ProviderName,
|
|
13
|
+
decode_body,
|
|
14
|
+
decode_text,
|
|
15
|
+
parse_envelope,
|
|
16
|
+
refuse_credentials_in_url,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CompatibleProvider(HttpProvider):
|
|
21
|
+
name: ClassVar[ProviderName] = "compatible"
|
|
22
|
+
label: ClassVar[str] = "Jev-compatible endpoint"
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self, redact: Redactor, *, api_key: str, base_url: str, client: httpx.AsyncClient | None = None
|
|
26
|
+
) -> None:
|
|
27
|
+
super().__init__(redact, client)
|
|
28
|
+
self._api_key = api_key
|
|
29
|
+
self._base_url = base_url
|
|
30
|
+
|
|
31
|
+
async def _send(
|
|
32
|
+
self, state: JsonValue, questions: dict[str, JsonValue], model: str, timeout: float | None
|
|
33
|
+
) -> Evaluation:
|
|
34
|
+
refuse_credentials_in_url(self._base_url, self.label)
|
|
35
|
+
response = await self._post(
|
|
36
|
+
self._base_url,
|
|
37
|
+
{"Authorization": f"Bearer {self._api_key}"},
|
|
38
|
+
{"model": model, "state": state, "questions": questions},
|
|
39
|
+
)
|
|
40
|
+
if not response.is_success:
|
|
41
|
+
raise self._status_error(response.status_code, decode_text(response.content))
|
|
42
|
+
envelope = parse_envelope(decode_body(response.content), self.label)
|
|
43
|
+
return Evaluation(envelope.answers, envelope.usage, self.name, envelope.model_or(model))
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""OpenRouter's Decisions API (`provider.ts:126-156`)."""
|
|
2
|
+
|
|
3
|
+
from typing import ClassVar
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from jev_mcp.domain import JsonValue
|
|
8
|
+
from jev_mcp.errors import Redactor
|
|
9
|
+
from jev_mcp.providers.base import Evaluation, HttpProvider, ProviderName, decode_body, decode_text, parse_envelope
|
|
10
|
+
|
|
11
|
+
URL = "https://openrouter.ai/api/alpha/decisions"
|
|
12
|
+
LATEST = "jev-1.13"
|
|
13
|
+
"""OpenRouter has no redirecting `jev-latest` slug; the reference maps it to this release."""
|
|
14
|
+
_TITLE = "jev-mcp"
|
|
15
|
+
_REFERER = "https://github.com/PyModel/jev-mcp"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def openrouter_slug(model: str) -> str:
|
|
19
|
+
"""`jev-latest` becomes `jev-1.13` first; then `typesafe/` is prefixed unless already there."""
|
|
20
|
+
effective = LATEST if model == "jev-latest" else model
|
|
21
|
+
return effective if effective.startswith("typesafe/") else f"typesafe/{effective}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class OpenRouterProvider(HttpProvider):
|
|
25
|
+
name: ClassVar[ProviderName] = "openrouter"
|
|
26
|
+
label: ClassVar[str] = "OpenRouter decisions API"
|
|
27
|
+
|
|
28
|
+
def __init__(self, redact: Redactor, *, api_key: str, client: httpx.AsyncClient | None = None) -> None:
|
|
29
|
+
super().__init__(redact, client)
|
|
30
|
+
self._api_key = api_key
|
|
31
|
+
|
|
32
|
+
async def _send(
|
|
33
|
+
self, state: JsonValue, questions: dict[str, JsonValue], model: str, timeout: float | None
|
|
34
|
+
) -> Evaluation:
|
|
35
|
+
slug = openrouter_slug(model)
|
|
36
|
+
headers = {
|
|
37
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
38
|
+
"HTTP-Referer": _REFERER,
|
|
39
|
+
"X-Title": _TITLE,
|
|
40
|
+
"X-OpenRouter-Title": _TITLE,
|
|
41
|
+
}
|
|
42
|
+
response = await self._post(URL, headers, {"model": slug, "state": state, "questions": questions})
|
|
43
|
+
if not response.is_success:
|
|
44
|
+
raise self._status_error(response.status_code, decode_text(response.content))
|
|
45
|
+
envelope = parse_envelope(decode_body(response.content), self.label)
|
|
46
|
+
# The reference reports the slug it sent, never a model from the body (`provider.ts:154`).
|
|
47
|
+
return Evaluation(envelope.answers, envelope.usage, self.name, slug)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Provider resolution from process configuration (`provider.ts:35-77`, ADR-0007, ADR-0008).
|
|
2
|
+
|
|
3
|
+
Explicit `JEV_PROVIDER` (lowercased) wins; an unknown name falls through to auto resolution in the
|
|
4
|
+
reference order typesafe, openrouter, cloudflare, vercel, compatible. A variable set to the empty
|
|
5
|
+
string counts as unset, as it is falsy in JS. Vercel keeps its slot but is unsupported (ADR-0007).
|
|
6
|
+
Resolution raises `ProviderConfigError` before any request, so the tools report it per call.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pydantic import SecretStr
|
|
10
|
+
|
|
11
|
+
from jev_mcp.errors import Redactor
|
|
12
|
+
from jev_mcp.providers.base import JevProvider, ProviderConfigError
|
|
13
|
+
from jev_mcp.providers.cloudflare import CloudflareProvider
|
|
14
|
+
from jev_mcp.providers.compatible import CompatibleProvider
|
|
15
|
+
from jev_mcp.providers.openrouter import OpenRouterProvider
|
|
16
|
+
from jev_mcp.providers.typesafe import TypeSafeProvider
|
|
17
|
+
from jev_mcp.settings import Settings
|
|
18
|
+
|
|
19
|
+
DEFAULT_MODEL = "jev-latest"
|
|
20
|
+
|
|
21
|
+
VERCEL_UNSUPPORTED = (
|
|
22
|
+
"vercel provider is not supported by the Python server; use typesafe, openrouter, cloudflare or compatible"
|
|
23
|
+
)
|
|
24
|
+
NO_CREDENTIALS = (
|
|
25
|
+
"No Jev provider credentials found. Set TYPESAFE_API_KEY, OPENROUTER_API_KEY (sk-or-), Cloudflare token + "
|
|
26
|
+
"CLOUDFLARE_ACCOUNT_ID, AI_GATEWAY_API_KEY, or JEV_API_KEY + JEV_API_BASE_URL; set JEV_PROVIDER to choose "
|
|
27
|
+
"explicitly."
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def resolve_model(settings: Settings) -> str:
|
|
32
|
+
"""`process.env.JEV_MCP_MODEL ?? "jev-latest"` (`index.ts:72`): an empty model stays empty."""
|
|
33
|
+
return DEFAULT_MODEL if settings.jev_mcp_model is None else settings.jev_mcp_model
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_provider(settings: Settings) -> JevProvider:
|
|
37
|
+
"""The provider `settings` select, or `ProviderConfigError` with the reference's text."""
|
|
38
|
+
redact = Redactor(settings.secret_values())
|
|
39
|
+
explicit = settings.jev_provider.lower()
|
|
40
|
+
typesafe_key = _value(settings.typesafe_api_key)
|
|
41
|
+
openrouter_key = _value(settings.openrouter_api_key)
|
|
42
|
+
has_openrouter = openrouter_key.startswith("sk-or-")
|
|
43
|
+
cloudflare_token = _value(settings.jev_cloudflare_api_token) or _value(settings.cloudflare_api_token)
|
|
44
|
+
account_id = settings.cloudflare_account_id or ""
|
|
45
|
+
has_cloudflare = bool(cloudflare_token and account_id)
|
|
46
|
+
api_key = _value(settings.jev_api_key)
|
|
47
|
+
base_url = _value(settings.jev_api_base_url)
|
|
48
|
+
|
|
49
|
+
def typesafe() -> JevProvider:
|
|
50
|
+
return TypeSafeProvider(redact, api_key=typesafe_key, base_url=_value(settings.typesafe_base_url) or None)
|
|
51
|
+
|
|
52
|
+
def openrouter() -> JevProvider:
|
|
53
|
+
return OpenRouterProvider(redact, api_key=openrouter_key)
|
|
54
|
+
|
|
55
|
+
def cloudflare() -> JevProvider:
|
|
56
|
+
return CloudflareProvider(redact, api_token=cloudflare_token, account_id=account_id)
|
|
57
|
+
|
|
58
|
+
def compatible() -> JevProvider:
|
|
59
|
+
return CompatibleProvider(redact, api_key=api_key, base_url=base_url)
|
|
60
|
+
|
|
61
|
+
match explicit:
|
|
62
|
+
case "typesafe":
|
|
63
|
+
if not typesafe_key:
|
|
64
|
+
raise ProviderConfigError("JEV_PROVIDER=typesafe but TYPESAFE_API_KEY is not set.")
|
|
65
|
+
return typesafe()
|
|
66
|
+
case "openrouter":
|
|
67
|
+
if not has_openrouter:
|
|
68
|
+
raise ProviderConfigError(
|
|
69
|
+
"JEV_PROVIDER=openrouter but OPENROUTER_API_KEY is not set or not an sk-or- key."
|
|
70
|
+
)
|
|
71
|
+
return openrouter()
|
|
72
|
+
case "vercel":
|
|
73
|
+
raise ProviderConfigError(VERCEL_UNSUPPORTED)
|
|
74
|
+
case "cloudflare":
|
|
75
|
+
if not has_cloudflare:
|
|
76
|
+
raise ProviderConfigError(
|
|
77
|
+
"JEV_PROVIDER=cloudflare but a Cloudflare API token (CLOUDFLARE_API_TOKEN or "
|
|
78
|
+
"JEV_CLOUDFLARE_API_TOKEN) and CLOUDFLARE_ACCOUNT_ID are not both set."
|
|
79
|
+
)
|
|
80
|
+
return cloudflare()
|
|
81
|
+
case "compatible":
|
|
82
|
+
missing = [name for name, value in (("JEV_API_KEY", api_key), ("JEV_API_BASE_URL", base_url)) if not value]
|
|
83
|
+
if missing:
|
|
84
|
+
verb = "are" if len(missing) > 1 else "is"
|
|
85
|
+
raise ProviderConfigError(
|
|
86
|
+
f"JEV_PROVIDER=compatible but {' and '.join(missing)} {verb} not set. "
|
|
87
|
+
"JEV_MCP_MODEL is optional and defaults to jev-latest."
|
|
88
|
+
)
|
|
89
|
+
return compatible()
|
|
90
|
+
case _:
|
|
91
|
+
pass
|
|
92
|
+
if typesafe_key:
|
|
93
|
+
return typesafe()
|
|
94
|
+
if has_openrouter:
|
|
95
|
+
return openrouter()
|
|
96
|
+
if has_cloudflare:
|
|
97
|
+
return cloudflare()
|
|
98
|
+
if _value(settings.ai_gateway_api_key):
|
|
99
|
+
raise ProviderConfigError(VERCEL_UNSUPPORTED)
|
|
100
|
+
if api_key and base_url:
|
|
101
|
+
return compatible()
|
|
102
|
+
raise ProviderConfigError(NO_CREDENTIALS)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _value(secret: SecretStr | None) -> str:
|
|
106
|
+
return "" if secret is None else secret.get_secret_value()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""TypeSafe direct through `typesafe-sdk` (`provider.ts:108-124`), the optional `typesafe` extra.
|
|
2
|
+
|
|
3
|
+
The SDK is imported on first use, so the server runs without it when another provider is configured.
|
|
4
|
+
It keeps its default retry policy, as the reference keeps `@typesafe-ai/sdk`'s. Its typed response
|
|
5
|
+
model is bypassed: the raw body goes through the uniform envelope rules (ADR-0003) and answers stay
|
|
6
|
+
raw for the tools to validate.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Self, cast, override
|
|
10
|
+
|
|
11
|
+
from pydantic import RootModel
|
|
12
|
+
|
|
13
|
+
from jev_mcp.domain import JsonValue
|
|
14
|
+
from jev_mcp.errors import Redactor
|
|
15
|
+
from jev_mcp.providers.base import (
|
|
16
|
+
Evaluation,
|
|
17
|
+
JevProvider,
|
|
18
|
+
ProviderError,
|
|
19
|
+
ProviderName,
|
|
20
|
+
decode_body,
|
|
21
|
+
parse_envelope,
|
|
22
|
+
refuse_credentials_in_url,
|
|
23
|
+
)
|
|
24
|
+
from jev_mcp.serialize import stringify_compact
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
import httpx2
|
|
28
|
+
from typesafe_sdk import AsyncTypeSafeClient, RetryPolicy
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _RawBody(RootModel[object]):
|
|
32
|
+
"""The response body as `JSON.parse` would read it: `None` when it is not JSON, NaN never a number."""
|
|
33
|
+
|
|
34
|
+
@override
|
|
35
|
+
@classmethod
|
|
36
|
+
def model_validate_json(
|
|
37
|
+
cls,
|
|
38
|
+
json_data: str | bytes | bytearray,
|
|
39
|
+
*,
|
|
40
|
+
strict: bool | None = None,
|
|
41
|
+
extra: Any = None,
|
|
42
|
+
context: Any | None = None,
|
|
43
|
+
by_alias: bool | None = None,
|
|
44
|
+
by_name: bool | None = None,
|
|
45
|
+
) -> Self:
|
|
46
|
+
content = json_data.encode() if isinstance(json_data, str) else bytes(json_data)
|
|
47
|
+
return cls.model_construct(decode_body(content))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TypeSafeProvider(JevProvider):
|
|
51
|
+
name: ClassVar[ProviderName] = "typesafe"
|
|
52
|
+
label: ClassVar[str] = "TypeSafe API"
|
|
53
|
+
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
redact: Redactor,
|
|
57
|
+
*,
|
|
58
|
+
api_key: str,
|
|
59
|
+
base_url: str | None,
|
|
60
|
+
transport: "httpx2.AsyncBaseTransport | None" = None,
|
|
61
|
+
retry: "RetryPolicy | None" = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
super().__init__(redact)
|
|
64
|
+
self._api_key = api_key
|
|
65
|
+
self._base_url = base_url
|
|
66
|
+
self._transport = transport
|
|
67
|
+
self._retry = retry
|
|
68
|
+
self._client: AsyncTypeSafeClient | None = None
|
|
69
|
+
|
|
70
|
+
def _sdk_client(self) -> "AsyncTypeSafeClient":
|
|
71
|
+
if self._client is None:
|
|
72
|
+
try:
|
|
73
|
+
from typesafe_sdk import AsyncTypeSafeClient
|
|
74
|
+
except ImportError:
|
|
75
|
+
raise ProviderError(
|
|
76
|
+
"The typesafe provider needs the typesafe-sdk package: install jev-mcp-python[typesafe]."
|
|
77
|
+
) from None
|
|
78
|
+
self._client = AsyncTypeSafeClient(
|
|
79
|
+
api_key=self._api_key, base_url=self._base_url, transport=self._transport, retry=self._retry
|
|
80
|
+
)
|
|
81
|
+
return self._client
|
|
82
|
+
|
|
83
|
+
async def _send(
|
|
84
|
+
self, state: JsonValue, questions: dict[str, JsonValue], model: str, timeout: float | None
|
|
85
|
+
) -> Evaluation:
|
|
86
|
+
if self._base_url is not None:
|
|
87
|
+
refuse_credentials_in_url(self._base_url, self.label)
|
|
88
|
+
client = self._sdk_client()
|
|
89
|
+
import httpx2
|
|
90
|
+
from typesafe_sdk import TypeSafeAPIConnectionError, TypeSafeAPIError
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
# The SDK's recursive JSON alias is partly unknown to pyright and narrower than JsonValue;
|
|
94
|
+
# the wire is the same.
|
|
95
|
+
response = await client.system_one( # pyright: ignore[reportUnknownMemberType]
|
|
96
|
+
cast(Any, state),
|
|
97
|
+
cast(Any, questions),
|
|
98
|
+
model=model,
|
|
99
|
+
# `None` must wait, as fetch does: the SDK reads a bare `None` as its 10 s default.
|
|
100
|
+
timeout=httpx2.Timeout(None) if timeout is None else timeout,
|
|
101
|
+
response_model=_RawBody,
|
|
102
|
+
)
|
|
103
|
+
except TypeSafeAPIError as error:
|
|
104
|
+
raise self._status_error(error.status, _body_text(error.body)) from None
|
|
105
|
+
except TypeSafeAPIConnectionError as error:
|
|
106
|
+
# The SDK writes `Connection error: {cause}`; a reset's cause has an empty message.
|
|
107
|
+
cause = error.__cause__
|
|
108
|
+
if isinstance(error, TimeoutError) or cause is None or str(cause):
|
|
109
|
+
raise
|
|
110
|
+
raise ProviderError(f"{self.label} request failed: {error}{type(cause).__name__}") from None
|
|
111
|
+
envelope = parse_envelope(response.root, self.label)
|
|
112
|
+
# The reference reports the requested model, never one from the body (`provider.ts:122`).
|
|
113
|
+
return Evaluation(envelope.answers, envelope.usage, self.name, model)
|
|
114
|
+
|
|
115
|
+
@override
|
|
116
|
+
async def aclose(self) -> None:
|
|
117
|
+
if self._client is not None:
|
|
118
|
+
await self._client.aclose()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _body_text(body: object) -> str:
|
|
122
|
+
"""The SDK hands over the error body parsed: text as text, JSON re-serialized, nothing as nothing."""
|
|
123
|
+
if body is None:
|
|
124
|
+
return ""
|
|
125
|
+
if isinstance(body, str):
|
|
126
|
+
return body
|
|
127
|
+
return stringify_compact(body)
|
jev_mcp/py.typed
ADDED
|
File without changes
|
jev_mcp/serialize.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""JS-compatible `JSON.stringify(payload, null, 2)` and `Number.prototype.toFixed` (ADR-0006).
|
|
2
|
+
|
|
3
|
+
Numbers follow ECMAScript Number::toString: shortest round-trip digits, integral values without a
|
|
4
|
+
fraction, exponential form only when the decimal exponent is >= 21 or <= -7. Object keys follow JS
|
|
5
|
+
own-property order: array-index keys ascending first, then the rest in insertion order.
|
|
6
|
+
`js_number_to_locale_string_en_us` (ADR-0014) renders cap-sized integers the way the reference's
|
|
7
|
+
error text does.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
from decimal import ROUND_HALF_UP, Context, Decimal
|
|
14
|
+
from typing import Final, final
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@final
|
|
18
|
+
class _Undefined:
|
|
19
|
+
"""JS `undefined`: an object key holding it is omitted, an array slot holding it prints `null`."""
|
|
20
|
+
|
|
21
|
+
def __repr__(self) -> str:
|
|
22
|
+
return "UNDEFINED"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
UNDEFINED: Final = _Undefined()
|
|
26
|
+
|
|
27
|
+
_MAX_ARRAY_INDEX = 2**32 - 2
|
|
28
|
+
_ESCAPES = {'"': '\\"', "\\": "\\\\", "\b": "\\b", "\f": "\\f", "\n": "\\n", "\r": "\\r", "\t": "\\t"}
|
|
29
|
+
_NEEDS_ESCAPE = re.compile('[\ud800-\udbff][\udc00-\udfff]|["\\\\\x00-\x1f\ud800-\udfff]')
|
|
30
|
+
_DECIMAL_CONTEXT = Context(prec=400)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stringify(value: object) -> str:
|
|
34
|
+
"""`JSON.stringify(value, null, 2)` for parsed-JSON-shaped values (dict, list, tuple, str, number, bool, None).
|
|
35
|
+
|
|
36
|
+
NaN and infinities print `null`, `-0.0` prints `0`, integers print as the float64 JS would hold.
|
|
37
|
+
"""
|
|
38
|
+
if value is UNDEFINED:
|
|
39
|
+
raise TypeError("JSON.stringify(undefined) produces no text.")
|
|
40
|
+
return _stringify(value, "")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def stringify_compact(value: object) -> str:
|
|
44
|
+
"""`JSON.stringify(value)` with no indentation, for parsed-JSON-shaped values (provider error text)."""
|
|
45
|
+
if value is UNDEFINED:
|
|
46
|
+
raise TypeError("JSON.stringify(undefined) produces no text.")
|
|
47
|
+
return _stringify(value, None)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _stringify(value: object, indent: str | None) -> str:
|
|
51
|
+
if value is None or value is UNDEFINED:
|
|
52
|
+
return "null"
|
|
53
|
+
if value is True:
|
|
54
|
+
return "true"
|
|
55
|
+
if value is False:
|
|
56
|
+
return "false"
|
|
57
|
+
if isinstance(value, int | float):
|
|
58
|
+
number = _to_double(value)
|
|
59
|
+
return number_to_string(number) if math.isfinite(number) else "null"
|
|
60
|
+
if isinstance(value, str):
|
|
61
|
+
return quote(value)
|
|
62
|
+
inner = None if indent is None else indent + " "
|
|
63
|
+
if isinstance(value, Mapping):
|
|
64
|
+
mapping: Mapping[object, object] = value # pyright: ignore[reportUnknownVariableType]
|
|
65
|
+
keys = [key for key in mapping if isinstance(key, str)]
|
|
66
|
+
if len(keys) != len(mapping):
|
|
67
|
+
raise TypeError("Object keys must be strings.")
|
|
68
|
+
members = [
|
|
69
|
+
(quote(key), _stringify(mapping[key], inner)) for key in js_key_order(keys) if mapping[key] is not UNDEFINED
|
|
70
|
+
]
|
|
71
|
+
if not members:
|
|
72
|
+
return "{}"
|
|
73
|
+
if indent is None or inner is None:
|
|
74
|
+
return "{" + ",".join(f"{key}:{item}" for key, item in members) + "}"
|
|
75
|
+
return "{\n" + ",\n".join(f"{inner}{key}: {item}" for key, item in members) + "\n" + indent + "}"
|
|
76
|
+
if isinstance(value, list | tuple):
|
|
77
|
+
items: list[object] | tuple[object, ...] = value # pyright: ignore[reportUnknownVariableType]
|
|
78
|
+
if not items:
|
|
79
|
+
return "[]"
|
|
80
|
+
if indent is None or inner is None:
|
|
81
|
+
return "[" + ",".join(_stringify(item, None) for item in items) + "]"
|
|
82
|
+
return "[\n" + ",\n".join(inner + _stringify(item, inner) for item in items) + "\n" + indent + "]"
|
|
83
|
+
raise TypeError(f"Cannot serialize {type(value).__name__} as JSON.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _to_double(value: int | float) -> float:
|
|
87
|
+
try:
|
|
88
|
+
return float(value)
|
|
89
|
+
except OverflowError:
|
|
90
|
+
return math.inf if value > 0 else -math.inf
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def quote(text: str) -> str:
|
|
94
|
+
"""JSON.stringify of a string: `"`, `\\`, and C0 controls escaped, lone surrogates as `\\udxxx`, the rest raw."""
|
|
95
|
+
return '"' + _NEEDS_ESCAPE.sub(_escape, text) + '"'
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _escape(match: re.Match[str]) -> str:
|
|
99
|
+
chars = match.group()
|
|
100
|
+
if len(chars) == 2:
|
|
101
|
+
# A surrogate pair held as two code points is one well-formed character in JS.
|
|
102
|
+
return chr(0x10000 + ((ord(chars[0]) - 0xD800) << 10) + (ord(chars[1]) - 0xDC00))
|
|
103
|
+
return _ESCAPES.get(chars) or f"\\u{ord(chars):04x}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def js_key_order(keys: Iterable[str]) -> list[str]:
|
|
107
|
+
"""Keys in JS own-property order: array indices (canonical integers up to 2**32 - 2) ascending, then the rest."""
|
|
108
|
+
indices: list[str] = []
|
|
109
|
+
others: list[str] = []
|
|
110
|
+
for key in keys:
|
|
111
|
+
(indices if _is_array_index(key) else others).append(key)
|
|
112
|
+
indices.sort(key=int)
|
|
113
|
+
return indices + others
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _is_array_index(key: str) -> bool:
|
|
117
|
+
if not key.isascii() or not key.isdigit() or (len(key) > 1 and key[0] == "0"):
|
|
118
|
+
return False
|
|
119
|
+
return int(key) <= _MAX_ARRAY_INDEX
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def number_to_string(value: float) -> str:
|
|
123
|
+
"""ECMAScript Number::toString(value) for radix 10."""
|
|
124
|
+
if math.isnan(value):
|
|
125
|
+
return "NaN"
|
|
126
|
+
if value == 0:
|
|
127
|
+
return "0"
|
|
128
|
+
if math.isinf(value):
|
|
129
|
+
return "Infinity" if value > 0 else "-Infinity"
|
|
130
|
+
sign = "-" if value < 0 else ""
|
|
131
|
+
# repr gives the shortest digits that round-trip, as Number::toString requires.
|
|
132
|
+
mantissa, _, exponent = repr(abs(value)).partition("e")
|
|
133
|
+
whole, _, fraction = mantissa.partition(".")
|
|
134
|
+
digits = whole + fraction
|
|
135
|
+
point = len(whole) + (int(exponent) if exponent else 0)
|
|
136
|
+
stripped = digits.lstrip("0")
|
|
137
|
+
point -= len(digits) - len(stripped)
|
|
138
|
+
digits = stripped.rstrip("0")
|
|
139
|
+
k, n = len(digits), point
|
|
140
|
+
if k <= n <= 21:
|
|
141
|
+
return sign + digits + "0" * (n - k)
|
|
142
|
+
if 0 < n <= 21:
|
|
143
|
+
return sign + digits[:n] + "." + digits[n:]
|
|
144
|
+
if -6 < n <= 0:
|
|
145
|
+
return sign + "0." + "0" * -n + digits
|
|
146
|
+
e = n - 1
|
|
147
|
+
head = digits[0] + ("." + digits[1:] if k > 1 else "")
|
|
148
|
+
return f"{sign}{head}e{'+' if e >= 0 else '-'}{abs(e)}"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def to_fixed(value: float, digits: int = 2) -> str:
|
|
152
|
+
"""`Number.prototype.toFixed(digits)`: round half away from zero on the exact binary value (quirk Q6).
|
|
153
|
+
|
|
154
|
+
`0.125` gives `"0.13"` where Python's `f"{0.125:.2f}"` gives `"0.12"`.
|
|
155
|
+
"""
|
|
156
|
+
if not 0 <= digits <= 100:
|
|
157
|
+
raise ValueError("toFixed digits must be between 0 and 100.")
|
|
158
|
+
if not math.isfinite(value):
|
|
159
|
+
return number_to_string(value)
|
|
160
|
+
sign = "-" if value < 0 else ""
|
|
161
|
+
magnitude = abs(value)
|
|
162
|
+
if magnitude >= 1e21:
|
|
163
|
+
return sign + number_to_string(magnitude)
|
|
164
|
+
exact = Decimal(magnitude).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP, context=_DECIMAL_CONTEXT)
|
|
165
|
+
return sign + format(exact, "f")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_LOCALE_PLACES: Final = Decimal("0.001")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def js_number_to_locale_string_en_us(value: float) -> str:
|
|
172
|
+
"""`Number.prototype.toLocaleString("en-US")` with no options — a frozen JS behavior (ADR-0014).
|
|
173
|
+
|
|
174
|
+
Not an i18n API and never process-locale-dependent: the rules are pinned against the parity
|
|
175
|
+
Node (v24.19.0, ICU 78.3), which is where the reference's `200,000` rendering comes from. The
|
|
176
|
+
integer part is grouped in 3s with ","; a non-integer keeps at most 3 fraction digits
|
|
177
|
+
(maximumFractionDigits 3, minimumFractionDigits 0) rounded half away from zero on the shortest
|
|
178
|
+
round-trip digits — `(1.0005).toLocaleString("en-US")` is `"1.001"` although the double sits
|
|
179
|
+
below the midpoint. NaN and the infinities render as ICU does (`"NaN"`, `"∞"`, `"-∞"`). Grouping
|
|
180
|
+
never becomes exponent form (`1e21` renders `"1,000,000,000,000,000,000,000"`, unlike toFixed,
|
|
181
|
+
which switches to Number::toString at 1e21). Negative zero keeps its sign (`"-0"`), and a
|
|
182
|
+
negative magnitude that rounds away keeps it too (`(-0.0004)` → `"-0"`). An int is the double
|
|
183
|
+
JS would hold, as everywhere in this module: 123456789012345678901 groups as
|
|
184
|
+
`"123,456,789,012,345,680,000"`.
|
|
185
|
+
"""
|
|
186
|
+
value = _to_double(value)
|
|
187
|
+
if math.isnan(value):
|
|
188
|
+
return "NaN"
|
|
189
|
+
if math.isinf(value):
|
|
190
|
+
return "∞" if value > 0 else "-∞"
|
|
191
|
+
sign = "-" if math.copysign(1.0, value) < 0 else ""
|
|
192
|
+
# V8/ICU round the shortest round-trip decimal (Number::toString digits), not the exact binary
|
|
193
|
+
# value; every form number_to_string emits, "1e+21" included, is a valid Decimal literal.
|
|
194
|
+
rounded = Decimal(number_to_string(abs(value))).quantize(
|
|
195
|
+
_LOCALE_PLACES, rounding=ROUND_HALF_UP, context=_DECIMAL_CONTEXT
|
|
196
|
+
)
|
|
197
|
+
whole, _, fraction = format(rounded, "f").partition(".")
|
|
198
|
+
fraction = fraction.rstrip("0")
|
|
199
|
+
return f"{sign}{int(whole):,}" + (f".{fraction}" if fraction else "")
|