jevkit-runtime 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/PKG-INFO +20 -2
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/README.md +19 -1
- jevkit_runtime-0.3.0/docs/diffusiongemma.md +86 -0
- jevkit_runtime-0.3.0/docs/laya.md +80 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/pyproject.toml +1 -1
- jevkit_runtime-0.3.0/scripts/laya_server.py +116 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/src/jevkit_runtime/__init__.py +3 -1
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/src/jevkit_runtime/client.py +10 -1
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/src/jevkit_runtime/protocol.py +8 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/src/jevkit_runtime/providers.py +40 -1
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/src/jevkit_runtime/settings.py +14 -7
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/tests/test_client.py +24 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/tests/test_protocol.py +13 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/tests/test_settings_and_providers.py +37 -4
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/uv.lock +1 -1
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/.github/workflows/downstream.yml +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/.github/workflows/publish.yml +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/.github/workflows/test.yml +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/.gitignore +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/LICENSE +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/scripts/dev.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/scripts/offline/sitecustomize.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/src/jevkit_runtime/errors.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/src/jevkit_runtime/meter.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/src/jevkit_runtime/store.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/src/jevkit_runtime/transport.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/tests/test_dev_runner.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/tests/test_store.py +0 -0
- {jevkit_runtime-0.2.0 → jevkit_runtime-0.3.0}/tests/test_transport.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: jevkit-runtime
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Shared transport, configuration, caching, and accounting for JevKit tools
|
|
5
5
|
Project-URL: Homepage, https://github.com/keltokhy/jevkit-core
|
|
6
6
|
Project-URL: Issues, https://github.com/keltokhy/jevkit-core/issues
|
|
@@ -63,11 +63,29 @@ HTTP/2 is used whenever the `http2` extra is installed.
|
|
|
63
63
|
- **Credentials** come from the provider's variable, then `$XDG_CONFIG_HOME/jev/<provider>.key`.
|
|
64
64
|
Gateways take their URL from `JEV_GATEWAY_URL` or `<provider>.url`. `JEV_URL` overrides any endpoint.
|
|
65
65
|
- **Metering** refuses malformed usage rather than under-counting; a response without a reported
|
|
66
|
-
cost is priced from its tokens at the
|
|
66
|
+
cost is priced from its tokens at the provider's price, zero for local servers, or the list price.
|
|
67
|
+
`JEV_PRICE_PER_MTOK` overrides both.
|
|
67
68
|
- **Errors** keep their wording across tools: a fatal status reads `PROVIDER said 401: detail`, a
|
|
68
69
|
bad request reads `HTTP 400: detail`, and exhaustion reads `gave up after 15s (last failure)`.
|
|
69
70
|
Both status errors carry `provider`, `status` and `detail` for tools that word or redact them.
|
|
70
71
|
|
|
72
|
+
## Local servers
|
|
73
|
+
|
|
74
|
+
Two catalog entries point at System One servers on your own machine: `diffusiongemma`, an
|
|
75
|
+
[OpenJev](https://github.com/razorback16/openjev) server on port 8080, and `laya`, a
|
|
76
|
+
[laya-mlx](https://github.com/mizorewww/laya-mlx) server on port 8081. Every JevKit tool names
|
|
77
|
+
them in its catalog, so `--api laya` or `JEV_API=laya` works everywhere. They are never chosen
|
|
78
|
+
in place of a configured hosted provider, need no key, and are metered at zero API fees unless
|
|
79
|
+
`JEV_PRICE_PER_MTOK` says otherwise. `JEV_LAYA_URL` and `JEV_DIFFUSIONGEMMA_URL`, or the matching
|
|
80
|
+
`.url` files, point at a server elsewhere.
|
|
81
|
+
|
|
82
|
+
DiffusionGemma reads every question in a batch together, so the runtime keys each of its answers
|
|
83
|
+
on the whole ordered batch and re-sends a batch whole when any slot is missing.
|
|
84
|
+
|
|
85
|
+
No package ships the models. [docs/diffusiongemma.md](docs/diffusiongemma.md) and
|
|
86
|
+
[docs/laya.md](docs/laya.md) explain how to run the servers, and `scripts/laya_server.py` is the
|
|
87
|
+
adapter the Laya guide starts.
|
|
88
|
+
|
|
71
89
|
## Development
|
|
72
90
|
|
|
73
91
|
Keep the six checkouts as siblings. Each consumer depends on `jevkit-runtime>=0.2.0,<0.3.0` and
|
|
@@ -48,11 +48,29 @@ HTTP/2 is used whenever the `http2` extra is installed.
|
|
|
48
48
|
- **Credentials** come from the provider's variable, then `$XDG_CONFIG_HOME/jev/<provider>.key`.
|
|
49
49
|
Gateways take their URL from `JEV_GATEWAY_URL` or `<provider>.url`. `JEV_URL` overrides any endpoint.
|
|
50
50
|
- **Metering** refuses malformed usage rather than under-counting; a response without a reported
|
|
51
|
-
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.
|
|
52
53
|
- **Errors** keep their wording across tools: a fatal status reads `PROVIDER said 401: detail`, a
|
|
53
54
|
bad request reads `HTTP 400: detail`, and exhaustion reads `gave up after 15s (last failure)`.
|
|
54
55
|
Both status errors carry `provider`, `status` and `detail` for tools that word or redact them.
|
|
55
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](docs/diffusiongemma.md) and
|
|
71
|
+
[docs/laya.md](docs/laya.md) explain how to run the servers, and `scripts/laya_server.py` is the
|
|
72
|
+
adapter the Laya guide starts.
|
|
73
|
+
|
|
56
74
|
## Development
|
|
57
75
|
|
|
58
76
|
Keep the six checkouts as siblings. Each consumer depends on `jevkit-runtime>=0.2.0,<0.3.0` and
|
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
A September 2026 jgrep comparison over 10,000 public-data decisions found news classification
|
|
84
|
+
close to hosted Jev but more false positives on SMS spam at a 0.9 cutoff. For code, judge whole
|
|
85
|
+
diff hunks or functions; isolated diff lines recalled poorly on a small synthetic fixture. Keep
|
|
86
|
+
it experimental and evaluate its cutoff on your own data.
|
|
@@ -0,0 +1,80 @@
|
|
|
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
|
+
A September 2026 jgrep experiment on an M3 Ultra found fast, strong short-text classification
|
|
78
|
+
(92% top-1 on a news set) but weak precision on spam at the default 0.5 cutoff (53%, rising to
|
|
79
|
+
79% at 0.9) and weak code judgments. Treat it as a fast classifier for short texts, and evaluate
|
|
80
|
+
thresholds on your own labeled inputs before relying on the scores.
|
|
@@ -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.0"
|
|
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
|