jevkit-runtime 0.2.0__tar.gz → 0.3.1__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.
Files changed (31) hide show
  1. jevkit_runtime-0.3.1/CHANGELOG.md +42 -0
  2. jevkit_runtime-0.2.0/README.md → jevkit_runtime-0.3.1/PKG-INFO +37 -2
  3. jevkit_runtime-0.2.0/PKG-INFO → jevkit_runtime-0.3.1/README.md +20 -17
  4. jevkit_runtime-0.3.1/SECURITY.md +18 -0
  5. jevkit_runtime-0.3.1/docs/diffusiongemma.md +98 -0
  6. jevkit_runtime-0.3.1/docs/laya.md +93 -0
  7. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/pyproject.toml +3 -1
  8. jevkit_runtime-0.3.1/scripts/laya_server.py +116 -0
  9. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/__init__.py +3 -1
  10. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/client.py +10 -1
  11. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/protocol.py +8 -0
  12. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/providers.py +40 -1
  13. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/settings.py +14 -7
  14. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_client.py +24 -0
  15. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_protocol.py +13 -0
  16. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_settings_and_providers.py +37 -4
  17. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/uv.lock +1 -1
  18. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/.github/workflows/downstream.yml +0 -0
  19. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/.github/workflows/publish.yml +0 -0
  20. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/.github/workflows/test.yml +0 -0
  21. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/.gitignore +0 -0
  22. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/LICENSE +0 -0
  23. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/scripts/dev.py +0 -0
  24. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/scripts/offline/sitecustomize.py +0 -0
  25. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/errors.py +0 -0
  26. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/meter.py +0 -0
  27. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/store.py +0 -0
  28. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/transport.py +0 -0
  29. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_dev_runner.py +0 -0
  30. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_store.py +0 -0
  31. {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_transport.py +0 -0
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ `jevkit-runtime` is 0.x: a minor version may break the API. The JevKit tools pin `<0.(n+1)` so a
4
+ new minor release never changes an installed tool.
5
+
6
+ ## 0.3.1
7
+
8
+ - Documentation and package metadata only; the code is the same as 0.3.0.
9
+ - The DiffusionGemma and Laya guides report what to expect from the four tools' benchmarks.
10
+ - Add this changelog and a security policy, and list the repository and changelog on PyPI.
11
+ - README links point to GitHub, so they also work on PyPI.
12
+
13
+ ## 0.3.0
14
+
15
+ - Add the local decision servers `diffusiongemma` (OpenJev) and `laya` (laya-mlx) to the catalog.
16
+ They need no key, are chosen only by name and never in place of a configured hosted provider, and
17
+ are priced at zero API fees.
18
+ - A provider may carry its own price; `JEV_PRICE_PER_MTOK` overrides it and `Settings.list_price`
19
+ is the fallback.
20
+ - Providers with joint reads key every answer on the whole ordered batch and are re-sent whole when
21
+ any slot is missing.
22
+ - Setup guides for both servers in `docs/`, and the Laya server adapter in `scripts/`.
23
+
24
+ ## 0.2.0
25
+
26
+ Breaking redesign: one request pipeline, one answer store, one provider catalog.
27
+
28
+ - `Client.ask` owns the whole pipeline: request sharing, hedging, cache-only runs, charge callbacks
29
+ and provenance are per-call keyword arguments. It returns `Answers`, a dict with an `origins`
30
+ attribute.
31
+ - The import is now `jevkit_runtime`, matching the distribution.
32
+ - The answer store keeps answer and provenance in one row under a versioned schema; caches from
33
+ 0.1 are reset and re-asked.
34
+ - HTTP/2 is used whenever the `http2` extra is installed.
35
+ - Removed: the legacy key recipes, the two-table cache, the `DecisionClient` seam, and the unused
36
+ local providers.
37
+
38
+ ## 0.1.0
39
+
40
+ - First release: shared transport, settings, answer cache and metering for jgrep, jsort, jlink,
41
+ jselect and jcol, published as `jevkit-runtime` (the PyPI name `jevkit-core` belongs to another
42
+ project).
@@ -1,3 +1,20 @@
1
+ Metadata-Version: 2.5
2
+ Name: jevkit-runtime
3
+ Version: 0.3.1
4
+ Summary: Shared transport, configuration, caching, and accounting for JevKit tools
5
+ Project-URL: Homepage, https://github.com/keltokhy/jevkit-core
6
+ Project-URL: Repository, https://github.com/keltokhy/jevkit-core
7
+ Project-URL: Changelog, https://github.com/keltokhy/jevkit-core/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/keltokhy/jevkit-core/issues
9
+ Author: Khaled Eltokhy
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx>=0.27
14
+ Provides-Extra: http2
15
+ Requires-Dist: httpx[http2]>=0.27; extra == 'http2'
16
+ Description-Content-Type: text/markdown
17
+
1
18
  # JevKit core
2
19
 
3
20
  Distribution **`jevkit-runtime`**, import **`jevkit_runtime`**. The PyPI name `jevkit-core` belongs
@@ -48,14 +65,32 @@ HTTP/2 is used whenever the `http2` extra is installed.
48
65
  - **Credentials** come from the provider's variable, then `$XDG_CONFIG_HOME/jev/<provider>.key`.
49
66
  Gateways take their URL from `JEV_GATEWAY_URL` or `<provider>.url`. `JEV_URL` overrides any endpoint.
50
67
  - **Metering** refuses malformed usage rather than under-counting; a response without a reported
51
- cost is priced from its tokens at the run's list price.
68
+ cost is priced from its tokens at the provider's price, zero for local servers, or the list price.
69
+ `JEV_PRICE_PER_MTOK` overrides both.
52
70
  - **Errors** keep their wording across tools: a fatal status reads `PROVIDER said 401: detail`, a
53
71
  bad request reads `HTTP 400: detail`, and exhaustion reads `gave up after 15s (last failure)`.
54
72
  Both status errors carry `provider`, `status` and `detail` for tools that word or redact them.
55
73
 
74
+ ## Local servers
75
+
76
+ Two catalog entries point at System One servers on your own machine: `diffusiongemma`, an
77
+ [OpenJev](https://github.com/razorback16/openjev) server on port 8080, and `laya`, a
78
+ [laya-mlx](https://github.com/mizorewww/laya-mlx) server on port 8081. Every JevKit tool names
79
+ them in its catalog, so `--api laya` or `JEV_API=laya` works everywhere. They are never chosen
80
+ in place of a configured hosted provider, need no key, and are metered at zero API fees unless
81
+ `JEV_PRICE_PER_MTOK` says otherwise. `JEV_LAYA_URL` and `JEV_DIFFUSIONGEMMA_URL`, or the matching
82
+ `.url` files, point at a server elsewhere.
83
+
84
+ DiffusionGemma reads every question in a batch together, so the runtime keys each of its answers
85
+ on the whole ordered batch and re-sends a batch whole when any slot is missing.
86
+
87
+ No package ships the models. [docs/diffusiongemma.md](https://github.com/keltokhy/jevkit-core/blob/main/docs/diffusiongemma.md) and
88
+ [docs/laya.md](https://github.com/keltokhy/jevkit-core/blob/main/docs/laya.md) explain how to run the servers, and `scripts/laya_server.py` is the
89
+ adapter the Laya guide starts.
90
+
56
91
  ## Development
57
92
 
58
- Keep the six checkouts as siblings. Each consumer depends on `jevkit-runtime>=0.2.0,<0.3.0` and
93
+ Keep the six checkouts as siblings. Each consumer depends on `jevkit-runtime>=0.3.0,<0.4.0` and
59
94
  overrides it for development with `jevkit-runtime = { path = "../jevkit-core", editable = true }`
60
95
  under `[tool.uv.sources]`.
61
96
 
@@ -1,18 +1,3 @@
1
- Metadata-Version: 2.5
2
- Name: jevkit-runtime
3
- Version: 0.2.0
4
- Summary: Shared transport, configuration, caching, and accounting for JevKit tools
5
- Project-URL: Homepage, https://github.com/keltokhy/jevkit-core
6
- Project-URL: Issues, https://github.com/keltokhy/jevkit-core/issues
7
- Author: Khaled Eltokhy
8
- License-Expression: MIT
9
- License-File: LICENSE
10
- Requires-Python: >=3.10
11
- Requires-Dist: httpx>=0.27
12
- Provides-Extra: http2
13
- Requires-Dist: httpx[http2]>=0.27; extra == 'http2'
14
- Description-Content-Type: text/markdown
15
-
16
1
  # JevKit core
17
2
 
18
3
  Distribution **`jevkit-runtime`**, import **`jevkit_runtime`**. The PyPI name `jevkit-core` belongs
@@ -63,14 +48,32 @@ HTTP/2 is used whenever the `http2` extra is installed.
63
48
  - **Credentials** come from the provider's variable, then `$XDG_CONFIG_HOME/jev/<provider>.key`.
64
49
  Gateways take their URL from `JEV_GATEWAY_URL` or `<provider>.url`. `JEV_URL` overrides any endpoint.
65
50
  - **Metering** refuses malformed usage rather than under-counting; a response without a reported
66
- cost is priced from its tokens at the run's list price.
51
+ cost is priced from its tokens at the provider's price, zero for local servers, or the list price.
52
+ `JEV_PRICE_PER_MTOK` overrides both.
67
53
  - **Errors** keep their wording across tools: a fatal status reads `PROVIDER said 401: detail`, a
68
54
  bad request reads `HTTP 400: detail`, and exhaustion reads `gave up after 15s (last failure)`.
69
55
  Both status errors carry `provider`, `status` and `detail` for tools that word or redact them.
70
56
 
57
+ ## Local servers
58
+
59
+ Two catalog entries point at System One servers on your own machine: `diffusiongemma`, an
60
+ [OpenJev](https://github.com/razorback16/openjev) server on port 8080, and `laya`, a
61
+ [laya-mlx](https://github.com/mizorewww/laya-mlx) server on port 8081. Every JevKit tool names
62
+ them in its catalog, so `--api laya` or `JEV_API=laya` works everywhere. They are never chosen
63
+ in place of a configured hosted provider, need no key, and are metered at zero API fees unless
64
+ `JEV_PRICE_PER_MTOK` says otherwise. `JEV_LAYA_URL` and `JEV_DIFFUSIONGEMMA_URL`, or the matching
65
+ `.url` files, point at a server elsewhere.
66
+
67
+ DiffusionGemma reads every question in a batch together, so the runtime keys each of its answers
68
+ on the whole ordered batch and re-sends a batch whole when any slot is missing.
69
+
70
+ No package ships the models. [docs/diffusiongemma.md](https://github.com/keltokhy/jevkit-core/blob/main/docs/diffusiongemma.md) and
71
+ [docs/laya.md](https://github.com/keltokhy/jevkit-core/blob/main/docs/laya.md) explain how to run the servers, and `scripts/laya_server.py` is the
72
+ adapter the Laya guide starts.
73
+
71
74
  ## Development
72
75
 
73
- Keep the six checkouts as siblings. Each consumer depends on `jevkit-runtime>=0.2.0,<0.3.0` and
76
+ Keep the six checkouts as siblings. Each consumer depends on `jevkit-runtime>=0.3.0,<0.4.0` and
74
77
  overrides it for development with `jevkit-runtime = { path = "../jevkit-core", editable = true }`
75
78
  under `[tool.uv.sources]`.
76
79
 
@@ -0,0 +1,18 @@
1
+ # Security
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please report security problems privately through GitHub:
6
+ [Report a vulnerability](https://github.com/keltokhy/jevkit-core/security/advisories/new).
7
+ Do not open a public issue. This is maintained by one person in spare time; expect a first reply
8
+ within a week.
9
+
10
+ Only the latest release is supported. Fixes ship as a new release, not as patches to older ones.
11
+
12
+ ## What to know when using it
13
+
14
+ - Text you pass in is sent to the model provider you configure (TypeSafe, OpenRouter, a gateway,
15
+ or a server on your own machine). Do not send data you are not allowed to share with that provider.
16
+ - API keys are read from environment variables or `~/.config/jev/<provider>.key` and are sent only
17
+ to the provider they belong to. They are never written to the answer cache or to logs.
18
+ - Answers are cached on disk in `~/.cache/jev`. Delete it to remove stored answers.
@@ -0,0 +1,98 @@
1
+ # DiffusionGemma (local, experimental)
2
+
3
+ Any JevKit tool can send its decisions to a DiffusionGemma decision server on your own machine
4
+ instead of a hosted provider. The server is chosen only by name, never automatically, and its
5
+ calls are metered at zero API fees:
6
+
7
+ ```bash
8
+ jgrep --api diffusiongemma -j 1 "a complaint about noise" complaints.txt
9
+ jcol --api diffusiongemma run table.csv codebook.json
10
+ ```
11
+
12
+ No JevKit package ships the model; it runs in its own environment and process. Model quality
13
+ and useful probability thresholds need evaluation on your own inputs.
14
+
15
+ ## Apple silicon
16
+
17
+ [OpenJev](https://github.com/razorback16/openjev) supplies an MLX implementation of the
18
+ structured-read approach from [vLLM PR #57250](https://github.com/vllm-project/vllm/pull/57250).
19
+ Its 4-bit checkpoint is roughly 16.6 GB to download and needs about 16 GB of model memory plus
20
+ working memory. Install the server in its own directory and environment:
21
+
22
+ ```bash
23
+ git clone https://github.com/razorback16/openjev.git
24
+ cd openjev
25
+ git checkout e04794ab36e4f7e6040c2547baecdb2737ce2e79
26
+ uv sync --python 3.12 --extra mlx
27
+ OPENJEV_BACKEND=mlx uv run --extra mlx python -m openjev
28
+ ```
29
+
30
+ The server downloads `mlx-community/diffusiongemma-26B-A4B-it-4bit` on first start. Wait for
31
+ `Application startup complete` before querying it. `OPENJEV_MLX_MODEL` can point at a downloaded
32
+ snapshot to pin the weights; the revision used in the original experiment was
33
+ `a7a81407613811e8ba63af92ac0d852b809e191f`. Then, in another terminal:
34
+
35
+ ```bash
36
+ printf '%s\n' 'The music next door keeps me awake.' 'The elevator is broken.' |
37
+ jgrep --api diffusiongemma --model openjev-0.1 -j 1 --timeout 120 --stats -o 'a complaint about noise'
38
+ ```
39
+
40
+ Start with one request at a time: the MLX backend executes model work serially, and a deep
41
+ queue can exceed a tool's per-request deadline. The longer timeout covers the first inference.
42
+ Raise concurrency from measured throughput once the model is warm.
43
+
44
+ ## NVIDIA / vLLM
45
+
46
+ Run OpenJev's documented vLLM deployment, or the prototype `structured_server.py` from
47
+ [PR #57250](https://github.com/vllm-project/vllm/pull/57250). That PR was unmerged at the time of
48
+ writing, so a released vLLM is not enough on its own; follow the server's pinned build
49
+ instructions. Its `/v1/systemone` adapter sits in front of vLLM. For the PR example server's
50
+ default port:
51
+
52
+ ```bash
53
+ JEV_DIFFUSIONGEMMA_URL=http://127.0.0.1:8011/v1/systemone \
54
+ jgrep --api diffusiongemma --model jev-latest 'a stack trace' build.log
55
+ ```
56
+
57
+ ## Configuration
58
+
59
+ | Setting | Meaning |
60
+ |---|---|
61
+ | `--api diffusiongemma` or `JEV_API=diffusiongemma` | Select it; it is never picked by discovery |
62
+ | `JEV_DIFFUSIONGEMMA_URL` or `~/.config/jev/diffusiongemma.url` | Full endpoint, default `http://127.0.0.1:8080/v1/systemone` |
63
+ | `JEV_DIFFUSIONGEMMA_API_KEY` or `~/.config/jev/diffusiongemma.key` | Optional bearer token; no `Authorization` header when absent |
64
+ | `--model` or `JEV_MODEL` | Default `openjev-latest`; `openjev-0.1` pins the server's decision model |
65
+ | `JEV_PRICE_PER_MTOK` | Overrides the zero price for `--stats`, `--estimate`, and dollar budgets |
66
+
67
+ `JEV_URL` overrides every endpoint. A cost the server reports always wins over the price.
68
+ Zero means no API fee, not zero compute. Configure a price before using a dollar budget against
69
+ a metered remote server; an offline estimate is a byte-based approximation and cannot predict
70
+ the server's adaptive re-reads.
71
+
72
+ ## Joint reads and the cache
73
+
74
+ A diffusion read answers every question in a batch in the light of the others, so the runtime
75
+ keys each of this provider's answers on the provider, endpoint, model, state, and the entire
76
+ ordered batch of questions, IDs included. A batch is reused only whole: if any slot is missing
77
+ from the cache, the whole batch is sent again. Hosted providers and Laya keep per-question
78
+ caching. Pin both server and weights; after changing weights or inference settings behind the
79
+ same URL and model, use `--no-cache` or a separate `XDG_CACHE_HOME`.
80
+
81
+ ## What to expect
82
+
83
+ On September 22, 2026, DiffusionGemma (`openjev-0.1`) ran each tool's benchmarks on an Apple M3
84
+ Ultra with 96 GiB of unified memory, beside Jev 1.13's recorded runs. It is a reasonable
85
+ substitute for Jev when the text must stay on your machine and each decision is about one record,
86
+ or about long ones. In
87
+ [jgrep](https://github.com/keltokhy/jgrep/blob/main/docs/benchmarks/local-models-2026-09-22.md)
88
+ its spam F1 was 0.87 against Jev's 0.91, with the same news accuracy; in
89
+ [jlink](https://github.com/keltokhy/jlink/blob/main/docs/benchmarks/local-models-2026-09-22.md)
90
+ it came within 0.03 of Jev's F1 on four of five benchmarks; in
91
+ [jcol](https://github.com/keltokhy/jcol/blob/main/benchmarks/README.md#local-models-2026-09-22)
92
+ it agreed with the product label on 78 of 100 complaints against Jev's 75; and in
93
+ [jsort](https://github.com/keltokhy/jsort/blob/main/docs/benchmarks/local-models-2026-09-22.md)
94
+ its scores for whole FOMC statements correlated 0.89 with Jev's. It is not a substitute for
95
+ sorting single short lines, where its comparisons had a reliability of 0.62 and 0.39 against
96
+ Jev's 0.97 and 0.96, or for judging isolated diff lines. The server runs one call at a time on
97
+ the GPU: about a third of a second per short record, and 14 to 18 minutes per 3,000 record pairs.
98
+ Keep it experimental and evaluate its cutoff on your own data.
@@ -0,0 +1,93 @@
1
+ # Laya (local, experimental)
2
+
3
+ Any JevKit tool can send its decisions to a Laya server on your own machine instead of a hosted
4
+ provider. The server is chosen only by name, never automatically, and its calls are metered at
5
+ zero API fees:
6
+
7
+ ```bash
8
+ jgrep --api laya "a complaint about noise" complaints.txt
9
+ jsort --api laya "more urgent" tickets.txt
10
+ JEV_API=laya jlink link left.csv right.csv --on name
11
+ ```
12
+
13
+ This guide runs the general English 421M-parameter [Laya](https://github.com/NandhaKishorM/laya)
14
+ checkpoint through the independent [laya-mlx port](https://github.com/mizorewww/laya-mlx) on
15
+ Apple silicon. No JevKit package ships the model; it runs in its own environment and process.
16
+ The multilingual and newer typed-decision checkpoints have not been tried.
17
+
18
+ ## Setup (Apple silicon)
19
+
20
+ Keep the model dependencies in a separate environment. From a directory outside this repository:
21
+
22
+ ```bash
23
+ git clone https://github.com/mizorewww/laya-mlx.git
24
+ cd laya-mlx
25
+ git checkout fc1df62828a3fedf4d8229fdac1cbd85f1cdf337
26
+ uv sync --python 3.12
27
+ uv pip install --python .venv/bin/python fastapi==0.141.1 uvicorn==0.53.0
28
+ .venv/bin/python -c 'from huggingface_hub import snapshot_download; print(snapshot_download("aac6fef/laya-mlx", revision="047678560251f28113ee8f5df4be82102c7bf336"))'
29
+ ```
30
+
31
+ Use the printed snapshot path as `--checkpoint`, then start the adapter from this repository
32
+ with that environment's Python:
33
+
34
+ ```bash
35
+ /path/to/laya-mlx/.venv/bin/python scripts/laya_server.py \
36
+ --checkpoint /path/to/downloaded/snapshot \
37
+ --audit /path/to/laya-audit.jsonl
38
+ ```
39
+
40
+ It binds to loopback port 8081, loads FP16 weights, warms the model, and serves `/v1/systemone`.
41
+ `/health` reports the checkpoint, context budget, and truncation policy. Then, in another terminal:
42
+
43
+ ```bash
44
+ printf '%s\n' 'The music next door keeps me awake.' 'The elevator is broken.' |
45
+ jgrep --api laya -j 4 --timeout 120 --stats -o 'a complaint about noise'
46
+ ```
47
+
48
+ ## Context limit
49
+
50
+ The model's 512-token window includes the question's instructions and options, leaving less
51
+ room for the record. The adapter checks the remaining budget separately for every question and
52
+ answers HTTP 422 if any state would be cropped; the tool reports an error rather than a negative
53
+ answer. `--allow-truncation` enables the native runtime's prefix retention instead, and every
54
+ request then records the available and dropped state tokens per question in the audit log.
55
+ Ordinary use should keep rejection on. Long question instructions can also be shortened by the
56
+ runtime's own question budget; the adapter audits state truncation, not question truncation.
57
+
58
+ ## Configuration
59
+
60
+ | Setting | Meaning |
61
+ |---|---|
62
+ | `--api laya` or `JEV_API=laya` | Select it; it is never picked by discovery |
63
+ | `JEV_LAYA_URL` or `~/.config/jev/laya.url` | Full endpoint, default `http://127.0.0.1:8081/v1/systemone` |
64
+ | `JEV_LAYA_API_KEY` or `~/.config/jev/laya.key` | Optional bearer token for a server that authenticates; the supplied adapter does not |
65
+ | `--model` or `JEV_MODEL` | `laya-421m`; the supplied adapter rejects other IDs |
66
+ | `JEV_PRICE_PER_MTOK` | Overrides the zero price for `--stats`, `--estimate`, and dollar budgets |
67
+
68
+ `JEV_URL` overrides every endpoint. Zero API fees exclude hardware and electricity.
69
+
70
+ Laya reads each question on its own, so its answers share the ordinary per-question cache with
71
+ the hosted providers, keyed by provider, endpoint, model, state, and question. Pin the server
72
+ and the weights; after changing the model behind the same URL and model ID, use `--no-cache`
73
+ or a separate `XDG_CACHE_HOME`.
74
+
75
+ ## What to expect
76
+
77
+ On September 22, 2026, Laya (`laya-421m`) ran each tool's benchmarks on an Apple M3 Ultra with
78
+ 96 GiB of unified memory, beside Jev 1.13's recorded runs. It is a fast classifier of short texts
79
+ into broad topics, and not a substitute for Jev elsewhere. In
80
+ [jgrep](https://github.com/keltokhy/jgrep/blob/main/docs/benchmarks/local-models-2026-09-22.md)
81
+ it had the best news accuracy of the three models, 92% against Jev's 87%, and finished 2,000
82
+ messages in 46 s; at the default cutoff it also flagged 235 messages that were not spam, against
83
+ Jev's 41. Its 512-token window, question included, decides what it can read: the adapter refuses
84
+ longer requests with HTTP 422, which the tools report as failed decisions, so in
85
+ [jcol](https://github.com/keltokhy/jcol/blob/main/benchmarks/README.md#local-models-2026-09-22)
86
+ it refused 27 of 100 complaint narratives and in
87
+ [jsort](https://github.com/keltokhy/jsort/blob/main/docs/benchmarks/local-models-2026-09-22.md)
88
+ every whole FOMC statement. On what it could read, its pairwise comparisons in jsort did not
89
+ follow Jev's order, and in
90
+ [jlink](https://github.com/keltokhy/jlink/blob/main/docs/benchmarks/local-models-2026-09-22.md)
91
+ it scored most candidate pairs as matches, for an F1 of 0.16 to 0.22 on firms, products and
92
+ software where Jev's was 0.67 to 0.94. Evaluate thresholds on your own labeled inputs before
93
+ relying on the scores.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "jevkit-runtime"
3
- version = "0.2.0"
3
+ version = "0.3.1"
4
4
  description = "Shared transport, configuration, caching, and accounting for JevKit tools"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -10,6 +10,8 @@ dependencies = ["httpx>=0.27"]
10
10
 
11
11
  [project.urls]
12
12
  Homepage = "https://github.com/keltokhy/jevkit-core"
13
+ Repository = "https://github.com/keltokhy/jevkit-core"
14
+ Changelog = "https://github.com/keltokhy/jevkit-core/blob/main/CHANGELOG.md"
13
15
  Issues = "https://github.com/keltokhy/jevkit-core/issues"
14
16
 
15
17
  [project.optional-dependencies]
@@ -0,0 +1,116 @@
1
+ """Local System One adapter for a separately installed, pinned laya-mlx runtime.
2
+
3
+ python scripts/laya_server.py --checkpoint /path/to/snapshot --audit /path/to/audit.jsonl
4
+ State truncation fails explicitly unless --allow-truncation is supplied.
5
+ Dependencies belong in the server environment: laya-mlx, fastapi, uvicorn.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import time
14
+ from pathlib import Path
15
+
16
+
17
+ def make_app(agent, *, audit_path, served_model="laya-421m", allow_truncation=False):
18
+ from fastapi import FastAPI, HTTPException
19
+ from laya_mlx.common import build_prefix, serialize_state
20
+
21
+ app = FastAPI()
22
+
23
+ @app.get("/health")
24
+ async def health():
25
+ return {
26
+ "model": served_model,
27
+ "max_tokens": agent.cfg["max_len"],
28
+ "allow_truncation": allow_truncation,
29
+ "checkpoint": str(agent.model_dir),
30
+ }
31
+
32
+ @app.post("/v1/systemone")
33
+ async def decide(body: dict):
34
+ if body.get("model") != served_model:
35
+ raise HTTPException(400, f"This server only serves {served_model!r}")
36
+ if "state" not in body or not isinstance(body.get("questions"), dict) or not body["questions"]:
37
+ raise HTTPException(400, "state and a nonempty questions object are required")
38
+ started = time.perf_counter()
39
+ state, questions = body["state"], body["questions"]
40
+ entry = {
41
+ "state_sha256": hashlib.sha256(
42
+ json.dumps(state, ensure_ascii=False, sort_keys=True).encode()
43
+ ).hexdigest(),
44
+ "questions_sha256": hashlib.sha256(
45
+ json.dumps(questions, ensure_ascii=False, sort_keys=True).encode()
46
+ ).hexdigest(),
47
+ "status": "started",
48
+ "questions": {},
49
+ }
50
+ try:
51
+ tokens = agent.tok(
52
+ serialize_state(state).replace(agent.tok.mask_token, " "), add_special_tokens=False
53
+ )["input_ids"]
54
+ for qid, definition in questions.items():
55
+ q = agent._to_internal(definition)
56
+ prefix, _ = build_prefix(agent.tok, q, agent.cfg.get("head_max_len", 192))
57
+ room = max(0, agent.cfg["max_len"] - len(prefix) - 1)
58
+ entry["questions"][qid] = {
59
+ "state_tokens": len(tokens),
60
+ "available_state_tokens": room,
61
+ "dropped_state_tokens": max(0, len(tokens) - room),
62
+ }
63
+ if not allow_truncation and any(q["dropped_state_tokens"] for q in entry["questions"].values()):
64
+ entry["status"] = "context_rejected"
65
+ raise HTTPException(
66
+ 422,
67
+ "State exceeds the Laya context budget; use shorter records "
68
+ "or explicitly enable audited truncation",
69
+ )
70
+ # An async route intentionally runs MLX on one event-loop thread, serially.
71
+ result = agent.predict(state, questions)
72
+ result["model"] = served_model
73
+ result["usage"]["cost"] = 0.0
74
+ result["context_audit"] = entry["questions"]
75
+ entry["status"] = "ok"
76
+ entry["usage"] = result["usage"]
77
+ return result
78
+ except (ValueError, TypeError, KeyError) as error:
79
+ entry["status"] = "invalid_request"
80
+ raise HTTPException(400, str(error)) from error
81
+ finally:
82
+ entry["seconds"] = time.perf_counter() - started
83
+ with audit_path.open("a") as stream:
84
+ stream.write(json.dumps(entry, allow_nan=False) + "\n")
85
+
86
+ return app
87
+
88
+
89
+ def main():
90
+ ap = argparse.ArgumentParser(description=__doc__)
91
+ ap.add_argument("--checkpoint", type=Path, required=True)
92
+ ap.add_argument("--audit", type=Path, required=True)
93
+ ap.add_argument("--port", type=int, default=8081)
94
+ ap.add_argument("--served-model", default="laya-421m")
95
+ ap.add_argument("--allow-truncation", action="store_true")
96
+ options = ap.parse_args()
97
+ import laya_mlx
98
+ import uvicorn
99
+
100
+ agent = laya_mlx.load(str(options.checkpoint), dtype="float16", device="gpu")
101
+ warmup = {"warmup": {"type": "noul", "instructions": "The text is a greeting."}}
102
+ agent.predict("Hello.", warmup)
103
+ options.audit.parent.mkdir(parents=True, exist_ok=True)
104
+ # Do not accidentally mix distinct server sessions in one audit file.
105
+ options.audit.touch(exist_ok=False)
106
+ app = make_app(
107
+ agent,
108
+ audit_path=options.audit,
109
+ served_model=options.served_model,
110
+ allow_truncation=options.allow_truncation,
111
+ )
112
+ uvicorn.run(app, host="127.0.0.1", port=options.port, log_level="warning", access_log=False)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -15,6 +15,7 @@ from .protocol import (
15
15
  QUESTION_TYPES,
16
16
  Usage,
17
17
  answer_key,
18
+ answer_keys,
18
19
  digest,
19
20
  error_detail,
20
21
  parse_answers,
@@ -27,7 +28,7 @@ from .settings import DEFAULT_PRICE_PER_MTOK, Settings
27
28
  from .store import AnswerStore, Entry
28
29
  from .transport import FATAL, RETRYABLE, post
29
30
 
30
- __version__ = "0.2.0"
31
+ __version__ = "0.3.1"
31
32
  __all__ = [
32
33
  "AnswerStore",
33
34
  "Answers",
@@ -51,6 +52,7 @@ __all__ = [
51
52
  "Settings",
52
53
  "Usage",
53
54
  "answer_key",
55
+ "answer_keys",
54
56
  "catalog",
55
57
  "digest",
56
58
  "error_detail",
@@ -14,6 +14,7 @@ from .errors import JevBudgetExceeded
14
14
  from .meter import Meter
15
15
  from .protocol import (
16
16
  answer_key,
17
+ answer_keys,
17
18
  answer_origin,
18
19
  parse_answers,
19
20
  parse_usage,
@@ -106,6 +107,9 @@ class Client:
106
107
  def key(self, state, question: dict) -> str:
107
108
  return answer_key(self.backend, state, question)
108
109
 
110
+ def keys(self, state, questions: dict[str, dict]) -> dict[str, str]:
111
+ return answer_keys(self.backend, state, questions)
112
+
109
113
  async def ask(
110
114
  self,
111
115
  state,
@@ -123,7 +127,7 @@ class Client:
123
127
  `on_cost` is charged only by the caller whose request actually went out. `hedge_after`
124
128
  sends a slow call a second time and keeps the first answer.
125
129
  """
126
- keys = dict(keys) if keys is not None else {qid: self.key(state, q) for qid, q in questions.items()}
130
+ keys = dict(keys) if keys is not None else self.keys(state, questions)
127
131
  answers: dict[str, dict] = {}
128
132
  origins: dict[str, dict] = {}
129
133
  if self.store is not None:
@@ -133,6 +137,11 @@ class Client:
133
137
  answers[qid] = entry.answer
134
138
  origins[qid] = dict(entry.metadata or {}) | {"source": "cache"}
135
139
  misses = {qid: q for qid, q in questions.items() if qid not in answers}
140
+ if misses and self.backend.joint_reads and len(misses) != len(questions):
141
+ # Each slot was answered in the light of the others; asking for some alone would change that.
142
+ answers.clear()
143
+ origins.clear()
144
+ misses = dict(questions)
136
145
  if not misses:
137
146
  self.meter.cached += 1
138
147
  else:
@@ -23,6 +23,14 @@ def answer_key(backend: Backend, state, question: dict) -> str:
23
23
  return digest([ANSWER_KEY_VERSION, backend.name, backend.url, backend.model, state, question])
24
24
 
25
25
 
26
+ def answer_keys(backend: Backend, state, questions: dict[str, dict]) -> dict[str, str]:
27
+ """Each question's identity. Under joint reads it is the whole batch, which every answer depends on."""
28
+ if not backend.joint_reads:
29
+ return {qid: answer_key(backend, state, question) for qid, question in questions.items()}
30
+ batch = list(questions.items())
31
+ return {qid: answer_key(backend, state, {"slot": qid, "batch": batch}) for qid in questions}
32
+
33
+
26
34
  def request_body(model: str, state, questions: dict[str, dict]) -> dict:
27
35
  return {"model": model, "state": state, "questions": questions}
28
36
 
@@ -22,6 +22,8 @@ class Provider:
22
22
  url_env: str | None = None
23
23
  requires_key: bool = True
24
24
  auto_select: bool = True
25
+ price_per_mtok: float | None = None # when the server reports no cost; None means the list price
26
+ joint_reads: bool = False # every answer depends on the whole batch of questions, not on its own
25
27
 
26
28
  def key_file(self, settings: Settings) -> Path:
27
29
  return settings.config_dir / f"{self.name}.key"
@@ -55,6 +57,7 @@ class Backend:
55
57
  key: str = ""
56
58
  key_source: str = "none"
57
59
  price_per_mtok: float = DEFAULT_PRICE_PER_MTOK
60
+ joint_reads: bool = False
58
61
 
59
62
 
60
63
  PROVIDERS = {
@@ -68,6 +71,29 @@ PROVIDERS = {
68
71
  "OPENROUTER_API_KEY",
69
72
  ),
70
73
  "gateway": Provider("gateway", "", "jev-latest", "JEV_GATEWAY_API_KEY", url_env="JEV_GATEWAY_URL"),
74
+ # Local servers: chosen only by name, never in place of a configured hosted provider, and free
75
+ # of API fees. The models run in their own processes; no JevKit package ships them.
76
+ "diffusiongemma": Provider(
77
+ "diffusiongemma",
78
+ "http://127.0.0.1:8080/v1/systemone",
79
+ "openjev-latest",
80
+ "JEV_DIFFUSIONGEMMA_API_KEY",
81
+ url_env="JEV_DIFFUSIONGEMMA_URL",
82
+ requires_key=False,
83
+ auto_select=False,
84
+ price_per_mtok=0.0,
85
+ joint_reads=True, # a diffusion read answers every slot in the light of the others
86
+ ),
87
+ "laya": Provider(
88
+ "laya",
89
+ "http://127.0.0.1:8081/v1/systemone",
90
+ "laya-421m",
91
+ "JEV_LAYA_API_KEY",
92
+ url_env="JEV_LAYA_URL",
93
+ requires_key=False,
94
+ auto_select=False,
95
+ price_per_mtok=0.0,
96
+ ),
71
97
  }
72
98
 
73
99
 
@@ -139,6 +165,19 @@ def _backend(provider: Provider, key: str, source: str, model: str | None, setti
139
165
  parsed = None
140
166
  if parsed is None or parsed.scheme not in ("http", "https") or not parsed.host:
141
167
  raise JevFatal(f"{provider.name} endpoint must be a complete HTTP or HTTPS URL, not {url!r}")
168
+ price = (
169
+ settings.price_per_mtok
170
+ if settings.price_per_mtok is not None
171
+ else provider.price_per_mtok
172
+ if provider.price_per_mtok is not None
173
+ else DEFAULT_PRICE_PER_MTOK
174
+ )
142
175
  return Backend(
143
- provider.name, url, model or settings.model or provider.model, key, source, settings.price_per_mtok
176
+ provider.name,
177
+ url,
178
+ model or settings.model or provider.model,
179
+ key,
180
+ source,
181
+ price,
182
+ provider.joint_reads,
144
183
  )
@@ -22,7 +22,7 @@ class Settings:
22
22
  api: str | None = None
23
23
  url: str | None = None
24
24
  model: str | None = None
25
- price_per_mtok: float = DEFAULT_PRICE_PER_MTOK
25
+ price_per_mtok: float | None = None # JEV_PRICE_PER_MTOK when set; see list_price
26
26
  environ: Mapping[str, str] = field(default_factory=lambda: os.environ, repr=False, compare=False)
27
27
 
28
28
  @classmethod
@@ -30,12 +30,14 @@ class Settings:
30
30
  env = os.environ if environ is None else environ
31
31
  home = Path.home()
32
32
  raw_price = env.get("JEV_PRICE_PER_MTOK", "").strip()
33
- try:
34
- price = float(raw_price) if raw_price else DEFAULT_PRICE_PER_MTOK
35
- except ValueError:
36
- raise JevFatal(f"JEV_PRICE_PER_MTOK must be a number, not {raw_price!r}") from None
37
- if not math.isfinite(price) or price < 0:
38
- raise JevFatal("JEV_PRICE_PER_MTOK must be finite and nonnegative")
33
+ price = None
34
+ if raw_price:
35
+ try:
36
+ price = float(raw_price)
37
+ except ValueError:
38
+ raise JevFatal(f"JEV_PRICE_PER_MTOK must be a number, not {raw_price!r}") from None
39
+ if not math.isfinite(price) or price < 0:
40
+ raise JevFatal("JEV_PRICE_PER_MTOK must be finite and nonnegative")
39
41
  return cls(
40
42
  config_dir=Path(env.get("XDG_CONFIG_HOME") or home / ".config") / "jev",
41
43
  cache_dir=Path(env.get("XDG_CACHE_HOME") or home / ".cache") / "jev",
@@ -46,6 +48,11 @@ class Settings:
46
48
  environ=env,
47
49
  )
48
50
 
51
+ @property
52
+ def list_price(self) -> float:
53
+ """Dollars per million input tokens when neither the environment nor a provider says otherwise."""
54
+ return DEFAULT_PRICE_PER_MTOK if self.price_per_mtok is None else self.price_per_mtok
55
+
49
56
  def credential(self, name: str, variable: str) -> tuple[str, str]:
50
57
  """A provider key and where it came from: `env`, `file`, or `none`."""
51
58
  key = self.environ.get(variable, "").strip()
@@ -198,6 +198,30 @@ def test_a_slow_call_is_hedged_and_the_first_answer_wins():
198
198
  run(exercise())
199
199
 
200
200
 
201
+ def test_a_joint_read_is_reused_whole_or_repeated_whole(tmp_path):
202
+ fake = Fake()
203
+ store = AnswerStore(tmp_path / "answers.sqlite")
204
+ joint = Backend(
205
+ "diffusiongemma", "http://127.0.0.1:8080/v1/systemone", "openjev-latest", joint_reads=True
206
+ )
207
+
208
+ async def exercise():
209
+ async with Client(joint, store=store, transport=httpx.MockTransport(fake)) as client:
210
+ first = await client.ask("s", QUESTIONS)
211
+ assert await client.ask("s", QUESTIONS) == first and len(fake.bodies) == 1
212
+ await client.ask("s", dict(reversed(QUESTIONS.items())))
213
+ assert len(fake.bodies) == 2 # the same questions in another order are another read
214
+ await client.ask("s", {"q": QUESTIONS["q"]})
215
+ assert len(fake.bodies) == 3 # and so is one of them alone
216
+ store.db.execute("DELETE FROM answers WHERE key = ?", (client.keys("s", QUESTIONS)["r"],))
217
+ await client.ask("s", QUESTIONS)
218
+ assert len(fake.bodies) == 4 and list(fake.bodies[-1]["questions"]) == ["q", "r"]
219
+ assert client.meter.cached == 1
220
+
221
+ run(exercise())
222
+ store.close()
223
+
224
+
201
225
  def test_callers_may_supply_their_own_answer_identity(tmp_path):
202
226
  fake = Fake()
203
227
  store = AnswerStore(tmp_path / "answers.sqlite")
@@ -5,6 +5,7 @@ from jevkit_runtime import (
5
5
  JevError,
6
6
  JevFatal,
7
7
  answer_key,
8
+ answer_keys,
8
9
  parse_answers,
9
10
  parse_usage,
10
11
  validate_answer,
@@ -23,6 +24,18 @@ def test_answer_key_depends_on_who_answers_and_what_was_asked():
23
24
  assert key != answer_key(BACKEND, "évidence ", question)
24
25
 
25
26
 
27
+ def test_joint_reads_key_every_slot_on_the_whole_batch():
28
+ q = {"type": "noul", "instructions": "rule"}
29
+ plain = answer_keys(BACKEND, "s", {"a": q, "b": q})
30
+ assert plain == {"a": answer_key(BACKEND, "s", q), "b": answer_key(BACKEND, "s", q)}
31
+ joint = Backend("typesafe", "https://one.invalid", "m", joint_reads=True)
32
+ keys = answer_keys(joint, "s", {"a": q, "b": q})
33
+ assert len(set(keys.values())) == 2 and not set(keys.values()) & set(plain.values())
34
+ assert answer_keys(joint, "s", {"a": q, "b": q}) == keys
35
+ assert answer_keys(joint, "s", {"b": q, "a": q})["a"] != keys["a"] # order is part of the read
36
+ assert answer_keys(joint, "s", {"a": q})["a"] != keys["a"] # so is company
37
+
38
+
26
39
  @pytest.mark.parametrize(
27
40
  "question,answer",
28
41
  [
@@ -24,6 +24,9 @@ def test_settings_read_every_convention_once(monkeypatch, tmp_path):
24
24
  settings = Settings.from_env()
25
25
  assert settings.config_dir == tmp_path / "jev"
26
26
  assert (settings.api, settings.model, settings.price_per_mtok) == ("gateway", "pinned", 0.5)
27
+ assert settings.list_price == 0.5
28
+ monkeypatch.delenv("JEV_PRICE_PER_MTOK")
29
+ assert Settings.from_env().price_per_mtok is None and Settings.from_env().list_price == 0.042
27
30
  monkeypatch.setenv("JEV_PRICE_PER_MTOK", "lots")
28
31
  with pytest.raises(JevFatal, match="JEV_PRICE_PER_MTOK"):
29
32
  Settings.from_env()
@@ -93,14 +96,44 @@ def test_gateway_needs_a_complete_endpoint(monkeypatch, tmp_path):
93
96
  assert resolve(providers, "gateway").url == "https://override.invalid/v1"
94
97
 
95
98
 
96
- def test_a_tool_can_add_its_own_provider(monkeypatch):
99
+ def test_local_servers_are_free_keyless_and_chosen_only_by_name(monkeypatch):
100
+ providers = catalog("typesafe", "diffusiongemma", "laya")
101
+ monkeypatch.setenv("JEV_DIFFUSIONGEMMA_API_KEY", "optional")
102
+ with pytest.raises(JevFatal, match="Set TYPESAFE_API_KEY, or put"):
103
+ resolve(providers)
104
+ gemma = resolve(providers, "diffusiongemma")
105
+ assert (gemma.url, gemma.model, gemma.key, gemma.price_per_mtok, gemma.joint_reads) == (
106
+ "http://127.0.0.1:8080/v1/systemone",
107
+ "openjev-latest",
108
+ "optional",
109
+ 0.0,
110
+ True,
111
+ )
112
+ laya = resolve(providers, "laya")
113
+ assert (laya.key, laya.price_per_mtok, laya.joint_reads) == ("", 0.0, False)
114
+ monkeypatch.setenv("JEV_LAYA_URL", "http://gpu-box:8081/v1/systemone")
115
+ assert resolve(providers, "laya").url == "http://gpu-box:8081/v1/systemone"
116
+
117
+
118
+ def test_price_comes_from_the_environment_then_the_provider_then_the_list(monkeypatch):
119
+ monkeypatch.setenv("TYPESAFE_API_KEY", "key")
120
+ monkeypatch.setenv("M_KEY", "key")
121
+ metered = Provider("metered", "https://m.invalid/v1", "m", "M_KEY", price_per_mtok=0.25)
122
+ providers = catalog("typesafe", "laya", metered)
123
+ assert resolve(providers, "typesafe").price_per_mtok == 0.042
124
+ assert resolve(providers, "laya").price_per_mtok == 0.0
125
+ assert resolve(providers, "metered").price_per_mtok == 0.25
126
+ monkeypatch.setenv("JEV_PRICE_PER_MTOK", "0.5")
127
+ assert {resolve(providers, name).price_per_mtok for name in providers} == {0.5}
128
+
129
+
130
+ def test_a_tool_can_add_its_own_provider():
97
131
  local = Provider(
98
- "local", "http://127.0.0.1:8080/v1", "local-v1", "LOCAL_KEY", requires_key=False, auto_select=False
132
+ "local", "http://127.0.0.1:9000/v1", "local-v1", "LOCAL_KEY", requires_key=False, auto_select=False
99
133
  )
100
134
  providers = catalog("typesafe", local)
101
135
  assert list(providers) == ["typesafe", "local"]
102
136
  with pytest.raises(JevFatal, match="no API key"):
103
137
  resolve(providers)
104
- monkeypatch.setenv("JEV_PRICE_PER_MTOK", "0")
105
138
  backend = resolve(providers, "local")
106
- assert (backend.key, backend.price_per_mtok, backend.url) == ("", 0.0, "http://127.0.0.1:8080/v1")
139
+ assert (backend.key, backend.price_per_mtok, backend.url) == ("", 0.042, "http://127.0.0.1:9000/v1")
@@ -139,7 +139,7 @@ wheels = [
139
139
 
140
140
  [[package]]
141
141
  name = "jevkit-runtime"
142
- version = "0.2.0"
142
+ version = "0.3.1"
143
143
  source = { editable = "." }
144
144
  dependencies = [
145
145
  { name = "httpx" },
File without changes