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.
Files changed (65) hide show
  1. jev_mcp/__init__.py +1 -0
  2. jev_mcp/__main__.py +3 -0
  3. jev_mcp/domain/__init__.py +32 -0
  4. jev_mcp/domain/answers.py +25 -0
  5. jev_mcp/domain/json.py +49 -0
  6. jev_mcp/domain/questions.py +75 -0
  7. jev_mcp/domain/usage.py +16 -0
  8. jev_mcp/errors.py +59 -0
  9. jev_mcp/extract/__init__.py +1 -0
  10. jev_mcp/extract/candidates.py +75 -0
  11. jev_mcp/extract/dialect.py +400 -0
  12. jev_mcp/extract/executor.py +118 -0
  13. jev_mcp/extract/worker.py +198 -0
  14. jev_mcp/ids.py +49 -0
  15. jev_mcp/limits.py +218 -0
  16. jev_mcp/policy/__init__.py +98 -0
  17. jev_mcp/policy/actions.py +41 -0
  18. jev_mcp/policy/claims.py +103 -0
  19. jev_mcp/policy/extract.py +73 -0
  20. jev_mcp/policy/ranking.py +41 -0
  21. jev_mcp/policy/review.py +73 -0
  22. jev_mcp/policy/screen.py +48 -0
  23. jev_mcp/policy/thresholds.py +74 -0
  24. jev_mcp/providers/__init__.py +26 -0
  25. jev_mcp/providers/base.py +236 -0
  26. jev_mcp/providers/cloudflare.py +59 -0
  27. jev_mcp/providers/compatible.py +43 -0
  28. jev_mcp/providers/openrouter.py +47 -0
  29. jev_mcp/providers/resolver.py +106 -0
  30. jev_mcp/providers/typesafe.py +127 -0
  31. jev_mcp/py.typed +0 -0
  32. jev_mcp/serialize.py +199 -0
  33. jev_mcp/server.py +176 -0
  34. jev_mcp/settings.py +73 -0
  35. jev_mcp/stdio.py +99 -0
  36. jev_mcp/telemetry.py +223 -0
  37. jev_mcp/text.py +42 -0
  38. jev_mcp/tools/__init__.py +20 -0
  39. jev_mcp/tools/arguments.py +447 -0
  40. jev_mcp/tools/base.py +153 -0
  41. jev_mcp/tools/classify.py +187 -0
  42. jev_mcp/tools/common.py +96 -0
  43. jev_mcp/tools/compare.py +143 -0
  44. jev_mcp/tools/decide.py +206 -0
  45. jev_mcp/tools/extract.py +262 -0
  46. jev_mcp/tools/find.py +113 -0
  47. jev_mcp/tools/gate.py +236 -0
  48. jev_mcp/tools/observed.py +69 -0
  49. jev_mcp/tools/rerank.py +139 -0
  50. jev_mcp/tools/review.py +236 -0
  51. jev_mcp/tools/screen.py +126 -0
  52. jev_mcp/tools/toolset.py +92 -0
  53. jev_mcp/tools/verify.py +141 -0
  54. jev_mcp/validation/__init__.py +25 -0
  55. jev_mcp/validation/caps.py +93 -0
  56. jev_mcp/validation/choice.py +65 -0
  57. jev_mcp/validation/extract.py +48 -0
  58. jev_mcp/validation/noul.py +15 -0
  59. jev_mcp/validation/numbers.py +21 -0
  60. jev_mcp/validation/score.py +20 -0
  61. jev_mcp_python-0.1.0.dist-info/METADATA +18 -0
  62. jev_mcp_python-0.1.0.dist-info/RECORD +65 -0
  63. jev_mcp_python-0.1.0.dist-info/WHEEL +4 -0
  64. jev_mcp_python-0.1.0.dist-info/entry_points.txt +2 -0
  65. 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 "")