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.
- jevkit_runtime-0.3.1/CHANGELOG.md +42 -0
- jevkit_runtime-0.2.0/README.md → jevkit_runtime-0.3.1/PKG-INFO +37 -2
- jevkit_runtime-0.2.0/PKG-INFO → jevkit_runtime-0.3.1/README.md +20 -17
- jevkit_runtime-0.3.1/SECURITY.md +18 -0
- jevkit_runtime-0.3.1/docs/diffusiongemma.md +98 -0
- jevkit_runtime-0.3.1/docs/laya.md +93 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/pyproject.toml +3 -1
- jevkit_runtime-0.3.1/scripts/laya_server.py +116 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/__init__.py +3 -1
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/client.py +10 -1
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/protocol.py +8 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/providers.py +40 -1
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/settings.py +14 -7
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_client.py +24 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_protocol.py +13 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_settings_and_providers.py +37 -4
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/uv.lock +1 -1
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/.github/workflows/downstream.yml +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/.github/workflows/publish.yml +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/.github/workflows/test.yml +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/.gitignore +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/LICENSE +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/scripts/dev.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/scripts/offline/sitecustomize.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/errors.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/meter.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/store.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/src/jevkit_runtime/transport.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_dev_runner.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.1}/tests/test_store.py +0 -0
- {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
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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:
|
|
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.
|
|
139
|
+
assert (backend.key, backend.price_per_mtok, backend.url) == ("", 0.042, "http://127.0.0.1:9000/v1")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|