jevkit-runtime 0.1.0__tar.gz → 0.2.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.1.0 → jevkit_runtime-0.2.0}/.github/workflows/downstream.yml +0 -2
- jevkit_runtime-0.2.0/PKG-INFO +94 -0
- jevkit_runtime-0.2.0/README.md +79 -0
- {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/pyproject.toml +2 -2
- {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/scripts/dev.py +48 -50
- jevkit_runtime-0.2.0/src/jevkit_runtime/__init__.py +63 -0
- jevkit_runtime-0.2.0/src/jevkit_runtime/client.py +213 -0
- jevkit_runtime-0.2.0/src/jevkit_runtime/errors.py +43 -0
- jevkit_runtime-0.2.0/src/jevkit_runtime/meter.py +67 -0
- jevkit_runtime-0.2.0/src/jevkit_runtime/protocol.py +152 -0
- jevkit_runtime-0.2.0/src/jevkit_runtime/providers.py +144 -0
- jevkit_runtime-0.2.0/src/jevkit_runtime/settings.py +57 -0
- jevkit_runtime-0.2.0/src/jevkit_runtime/store.py +90 -0
- jevkit_runtime-0.2.0/src/jevkit_runtime/transport.py +90 -0
- jevkit_runtime-0.2.0/tests/test_client.py +243 -0
- jevkit_runtime-0.2.0/tests/test_dev_runner.py +60 -0
- jevkit_runtime-0.2.0/tests/test_protocol.py +104 -0
- jevkit_runtime-0.2.0/tests/test_settings_and_providers.py +106 -0
- jevkit_runtime-0.2.0/tests/test_store.py +77 -0
- jevkit_runtime-0.2.0/tests/test_transport.py +159 -0
- {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/uv.lock +1 -1
- jevkit_runtime-0.1.0/PKG-INFO +0 -179
- jevkit_runtime-0.1.0/README.md +0 -164
- jevkit_runtime-0.1.0/TESTING.md +0 -95
- jevkit_runtime-0.1.0/consumer-baselines.json +0 -7
- jevkit_runtime-0.1.0/scripts/probe_consumer.py +0 -232
- jevkit_runtime-0.1.0/src/jevkit_core/__init__.py +0 -48
- jevkit_runtime-0.1.0/src/jevkit_core/backends.py +0 -142
- jevkit_runtime-0.1.0/src/jevkit_core/cache.py +0 -92
- jevkit_runtime-0.1.0/src/jevkit_core/client.py +0 -109
- jevkit_runtime-0.1.0/src/jevkit_core/errors.py +0 -22
- jevkit_runtime-0.1.0/src/jevkit_core/provenance.py +0 -18
- jevkit_runtime-0.1.0/src/jevkit_core/transport.py +0 -119
- jevkit_runtime-0.1.0/src/jevkit_core/usage.py +0 -88
- jevkit_runtime-0.1.0/tests/test_accounting.py +0 -62
- jevkit_runtime-0.1.0/tests/test_backends.py +0 -82
- jevkit_runtime-0.1.0/tests/test_cache_and_usage.py +0 -91
- jevkit_runtime-0.1.0/tests/test_shared_requests.py +0 -122
- jevkit_runtime-0.1.0/tests/test_transport.py +0 -136
- {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/.github/workflows/publish.yml +0 -0
- {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/.github/workflows/test.yml +0 -0
- {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/.gitignore +0 -0
- {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/LICENSE +0 -0
- {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/scripts/offline/sitecustomize.py +0 -0
|
@@ -14,8 +14,6 @@ permissions:
|
|
|
14
14
|
contents: read
|
|
15
15
|
jobs:
|
|
16
16
|
consumers:
|
|
17
|
-
# Enabled after all five migration PRs land; manual runs remain available.
|
|
18
|
-
if: vars.JEVKIT_CONSUMERS_READY == 'true' || github.event_name == 'workflow_dispatch'
|
|
19
17
|
runs-on: ubuntu-latest
|
|
20
18
|
strategy:
|
|
21
19
|
fail-fast: false
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
# JevKit core
|
|
17
|
+
|
|
18
|
+
Distribution **`jevkit-runtime`**, import **`jevkit_runtime`**. The PyPI name `jevkit-core` belongs
|
|
19
|
+
to a different project.
|
|
20
|
+
|
|
21
|
+
One request pipeline, one answer store, one provider catalog for jgrep, jsort, jlink, jselect,
|
|
22
|
+
and jcol. Each tool remains its own package and repository; the core imports none of them, and a
|
|
23
|
+
tool's adapter is a few lines naming which providers it offers.
|
|
24
|
+
|
|
25
|
+
## What a tool gets
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from jevkit_runtime import AnswerStore, Client, catalog, resolve
|
|
29
|
+
|
|
30
|
+
PROVIDERS = catalog("typesafe", "openrouter", "gateway")
|
|
31
|
+
backend = resolve(PROVIDERS, name=None, model=None) # or JEV_API / JEV_MODEL, else the first configured
|
|
32
|
+
async with Client(backend, store=AnswerStore()) as client:
|
|
33
|
+
answers = await client.ask(state, {"q": {"type": "noul", "instructions": "..."}})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`Client.ask` does the whole thing: computes each question's identity, serves what the store already
|
|
37
|
+
knows, joins an identical request already in flight, sends only the misses, validates the entire
|
|
38
|
+
response before storing any of it, and meters the call before validation so a billed but malformed
|
|
39
|
+
answer still counts. It returns `Answers`, a dict by question id whose `origins` say who answered
|
|
40
|
+
each one and whether it came from the API, the store, or a shared call. Per-call policy is keyword
|
|
41
|
+
arguments: `allow_paid=False` for cache-only runs, `on_cost` for the caller who should be charged,
|
|
42
|
+
`hedge_after` to resend a slow call, and `keys` for callers whose reuse unit is not the request.
|
|
43
|
+
HTTP/2 is used whenever the `http2` extra is installed.
|
|
44
|
+
|
|
45
|
+
| Module | Owns |
|
|
46
|
+
|---|---|
|
|
47
|
+
| `settings.py` | Every environment and filesystem convention, read in one place: `XDG_*`, `JEV_API`, `JEV_URL`, `JEV_MODEL`, `JEV_PRICE_PER_MTOK`, provider keys and URL files |
|
|
48
|
+
| `providers.py` | The catalog (`Provider`), a tool's selection of it or its own entries, and `resolve()` to one `Backend`: endpoint, model, key |
|
|
49
|
+
| `protocol.py` | Request bodies, typed answer validation (`noul`, `choice`, `score`), usage parsing, answer identity, provenance |
|
|
50
|
+
| `transport.py` | One HTTP call with a total deadline, retries with backoff and `Retry-After`, structured status errors |
|
|
51
|
+
| `store.py` | SQLite answers with their provenance in one row, one versioned schema |
|
|
52
|
+
| `client.py` | The pipeline above, request sharing, hedging |
|
|
53
|
+
| `meter.py` | Calls, cache hits, retries, hedges, tokens, cost, and which models actually answered |
|
|
54
|
+
| `errors.py` | `JevError`, `JevFatal`, `JevBudgetExceeded`, `RequestExhausted`, `ProviderError`, `ProviderFatal` |
|
|
55
|
+
|
|
56
|
+
## Conventions every tool shares
|
|
57
|
+
|
|
58
|
+
- **Answer identity** is `answer_key(backend, state, question)`: provider, endpoint, model, state and
|
|
59
|
+
question. An answer from one provider or model is never served for another.
|
|
60
|
+
- **The store** lives at `$XDG_CACHE_HOME/jev/answers.sqlite` (default `~/.cache/jev`), is created
|
|
61
|
+
private to the user, and resets itself when it finds an older schema. Version 0.2 cannot read
|
|
62
|
+
caches written by 0.1 tools; the first run after upgrading re-asks.
|
|
63
|
+
- **Credentials** come from the provider's variable, then `$XDG_CONFIG_HOME/jev/<provider>.key`.
|
|
64
|
+
Gateways take their URL from `JEV_GATEWAY_URL` or `<provider>.url`. `JEV_URL` overrides any endpoint.
|
|
65
|
+
- **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.
|
|
67
|
+
- **Errors** keep their wording across tools: a fatal status reads `PROVIDER said 401: detail`, a
|
|
68
|
+
bad request reads `HTTP 400: detail`, and exhaustion reads `gave up after 15s (last failure)`.
|
|
69
|
+
Both status errors carry `provider`, `status` and `detail` for tools that word or redact them.
|
|
70
|
+
|
|
71
|
+
## Development
|
|
72
|
+
|
|
73
|
+
Keep the six checkouts as siblings. Each consumer depends on `jevkit-runtime>=0.2.0,<0.3.0` and
|
|
74
|
+
overrides it for development with `jevkit-runtime = { path = "../jevkit-core", editable = true }`
|
|
75
|
+
under `[tool.uv.sources]`.
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python3 scripts/dev.py setup # uv sync every checkout, fetch jselect's tokenizer data
|
|
79
|
+
python3 scripts/dev.py check # core and consumer suites, credentials stripped, sockets blocked
|
|
80
|
+
python3 scripts/dev.py wheel-check # build and exercise real wheel installs in temporary environments
|
|
81
|
+
python3 scripts/dev.py run jgrep -- --help
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`check` also proves each consumer imports this exact source tree; `wheel-check` proves the installed
|
|
85
|
+
wheel, not the checkout. `--tool NAME` limits either to one consumer and `--suffix` selects
|
|
86
|
+
alternatively named checkouts. The GitHub workflows run the core suite on Python 3.10 and 3.13 and
|
|
87
|
+
the downstream matrix against each consumer's main branch.
|
|
88
|
+
|
|
89
|
+
## Releasing
|
|
90
|
+
|
|
91
|
+
Tag the verified core `vX.Y.Z` and dispatch the publish workflow with that tag; the workflow checks
|
|
92
|
+
the tag matches the package version and publishes through PyPI Trusted Publishing. Then release each
|
|
93
|
+
consumer through its own process, bumping its supported core range, lockfile, and CI core reference
|
|
94
|
+
together.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# JevKit core
|
|
2
|
+
|
|
3
|
+
Distribution **`jevkit-runtime`**, import **`jevkit_runtime`**. The PyPI name `jevkit-core` belongs
|
|
4
|
+
to a different project.
|
|
5
|
+
|
|
6
|
+
One request pipeline, one answer store, one provider catalog for jgrep, jsort, jlink, jselect,
|
|
7
|
+
and jcol. Each tool remains its own package and repository; the core imports none of them, and a
|
|
8
|
+
tool's adapter is a few lines naming which providers it offers.
|
|
9
|
+
|
|
10
|
+
## What a tool gets
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
from jevkit_runtime import AnswerStore, Client, catalog, resolve
|
|
14
|
+
|
|
15
|
+
PROVIDERS = catalog("typesafe", "openrouter", "gateway")
|
|
16
|
+
backend = resolve(PROVIDERS, name=None, model=None) # or JEV_API / JEV_MODEL, else the first configured
|
|
17
|
+
async with Client(backend, store=AnswerStore()) as client:
|
|
18
|
+
answers = await client.ask(state, {"q": {"type": "noul", "instructions": "..."}})
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
`Client.ask` does the whole thing: computes each question's identity, serves what the store already
|
|
22
|
+
knows, joins an identical request already in flight, sends only the misses, validates the entire
|
|
23
|
+
response before storing any of it, and meters the call before validation so a billed but malformed
|
|
24
|
+
answer still counts. It returns `Answers`, a dict by question id whose `origins` say who answered
|
|
25
|
+
each one and whether it came from the API, the store, or a shared call. Per-call policy is keyword
|
|
26
|
+
arguments: `allow_paid=False` for cache-only runs, `on_cost` for the caller who should be charged,
|
|
27
|
+
`hedge_after` to resend a slow call, and `keys` for callers whose reuse unit is not the request.
|
|
28
|
+
HTTP/2 is used whenever the `http2` extra is installed.
|
|
29
|
+
|
|
30
|
+
| Module | Owns |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `settings.py` | Every environment and filesystem convention, read in one place: `XDG_*`, `JEV_API`, `JEV_URL`, `JEV_MODEL`, `JEV_PRICE_PER_MTOK`, provider keys and URL files |
|
|
33
|
+
| `providers.py` | The catalog (`Provider`), a tool's selection of it or its own entries, and `resolve()` to one `Backend`: endpoint, model, key |
|
|
34
|
+
| `protocol.py` | Request bodies, typed answer validation (`noul`, `choice`, `score`), usage parsing, answer identity, provenance |
|
|
35
|
+
| `transport.py` | One HTTP call with a total deadline, retries with backoff and `Retry-After`, structured status errors |
|
|
36
|
+
| `store.py` | SQLite answers with their provenance in one row, one versioned schema |
|
|
37
|
+
| `client.py` | The pipeline above, request sharing, hedging |
|
|
38
|
+
| `meter.py` | Calls, cache hits, retries, hedges, tokens, cost, and which models actually answered |
|
|
39
|
+
| `errors.py` | `JevError`, `JevFatal`, `JevBudgetExceeded`, `RequestExhausted`, `ProviderError`, `ProviderFatal` |
|
|
40
|
+
|
|
41
|
+
## Conventions every tool shares
|
|
42
|
+
|
|
43
|
+
- **Answer identity** is `answer_key(backend, state, question)`: provider, endpoint, model, state and
|
|
44
|
+
question. An answer from one provider or model is never served for another.
|
|
45
|
+
- **The store** lives at `$XDG_CACHE_HOME/jev/answers.sqlite` (default `~/.cache/jev`), is created
|
|
46
|
+
private to the user, and resets itself when it finds an older schema. Version 0.2 cannot read
|
|
47
|
+
caches written by 0.1 tools; the first run after upgrading re-asks.
|
|
48
|
+
- **Credentials** come from the provider's variable, then `$XDG_CONFIG_HOME/jev/<provider>.key`.
|
|
49
|
+
Gateways take their URL from `JEV_GATEWAY_URL` or `<provider>.url`. `JEV_URL` overrides any endpoint.
|
|
50
|
+
- **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.
|
|
52
|
+
- **Errors** keep their wording across tools: a fatal status reads `PROVIDER said 401: detail`, a
|
|
53
|
+
bad request reads `HTTP 400: detail`, and exhaustion reads `gave up after 15s (last failure)`.
|
|
54
|
+
Both status errors carry `provider`, `status` and `detail` for tools that word or redact them.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
Keep the six checkouts as siblings. Each consumer depends on `jevkit-runtime>=0.2.0,<0.3.0` and
|
|
59
|
+
overrides it for development with `jevkit-runtime = { path = "../jevkit-core", editable = true }`
|
|
60
|
+
under `[tool.uv.sources]`.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python3 scripts/dev.py setup # uv sync every checkout, fetch jselect's tokenizer data
|
|
64
|
+
python3 scripts/dev.py check # core and consumer suites, credentials stripped, sockets blocked
|
|
65
|
+
python3 scripts/dev.py wheel-check # build and exercise real wheel installs in temporary environments
|
|
66
|
+
python3 scripts/dev.py run jgrep -- --help
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`check` also proves each consumer imports this exact source tree; `wheel-check` proves the installed
|
|
70
|
+
wheel, not the checkout. `--tool NAME` limits either to one consumer and `--suffix` selects
|
|
71
|
+
alternatively named checkouts. The GitHub workflows run the core suite on Python 3.10 and 3.13 and
|
|
72
|
+
the downstream matrix against each consumer's main branch.
|
|
73
|
+
|
|
74
|
+
## Releasing
|
|
75
|
+
|
|
76
|
+
Tag the verified core `vX.Y.Z` and dispatch the publish workflow with that tag; the workflow checks
|
|
77
|
+
the tag matches the package version and publishes through PyPI Trusted Publishing. Then release each
|
|
78
|
+
consumer through its own process, bumping its supported core range, lockfile, and CI core reference
|
|
79
|
+
together.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "jevkit-runtime"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Shared transport, configuration, caching, and accounting for JevKit tools"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -23,7 +23,7 @@ requires = ["hatchling"]
|
|
|
23
23
|
build-backend = "hatchling.build"
|
|
24
24
|
|
|
25
25
|
[tool.hatch.build.targets.wheel]
|
|
26
|
-
packages = ["src/
|
|
26
|
+
packages = ["src/jevkit_runtime"]
|
|
27
27
|
|
|
28
28
|
[tool.pytest.ini_options]
|
|
29
29
|
testpaths = ["tests"]
|
|
@@ -12,6 +12,10 @@ from pathlib import Path
|
|
|
12
12
|
|
|
13
13
|
CORE = Path(__file__).resolve().parents[1]
|
|
14
14
|
TOOLS = ("jgrep", "jsort", "jlink", "jselect", "jcol")
|
|
15
|
+
PACKAGED_ASSETS = {
|
|
16
|
+
"jcol": ["static/index.html"],
|
|
17
|
+
"jlink": ["assets/review.html", "assets/review.js", "assets/review.css"],
|
|
18
|
+
}
|
|
15
19
|
|
|
16
20
|
|
|
17
21
|
def command(argv, *, cwd=CORE, env=None):
|
|
@@ -39,10 +43,34 @@ def python(repo):
|
|
|
39
43
|
return repo / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
40
44
|
|
|
41
45
|
|
|
46
|
+
def runtime_environment(executable, env=None):
|
|
47
|
+
"""Use the selected environment for child CLI processes as well as Python."""
|
|
48
|
+
result = dict(os.environ if env is None else env)
|
|
49
|
+
result["PATH"] = os.pathsep.join((str(executable.parent), result.get("PATH", os.defpath)))
|
|
50
|
+
result["VIRTUAL_ENV"] = str(executable.parent.parent)
|
|
51
|
+
return result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def expect_core(executable, location, *, cwd, env):
|
|
55
|
+
"""The consumer's interpreter must import the core from `location`, not a stray install."""
|
|
56
|
+
command(
|
|
57
|
+
[
|
|
58
|
+
executable,
|
|
59
|
+
"-c",
|
|
60
|
+
"import sys, pathlib, jevkit_runtime; "
|
|
61
|
+
"here = pathlib.Path(jevkit_runtime.__file__).resolve().parent; "
|
|
62
|
+
"assert here == pathlib.Path(sys.argv[1]).resolve(), here",
|
|
63
|
+
location,
|
|
64
|
+
],
|
|
65
|
+
cwd=cwd,
|
|
66
|
+
env=env,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
42
70
|
def main():
|
|
43
71
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
44
72
|
parser.add_argument("--repos-root", type=Path, default=CORE.parent)
|
|
45
|
-
parser.add_argument("--suffix", default="", help="e.g. --suffix=-
|
|
73
|
+
parser.add_argument("--suffix", default="", help="checkout name suffix, e.g. --suffix=-wip")
|
|
46
74
|
parser.add_argument("--tool", choices=TOOLS, help="operate on only one consumer")
|
|
47
75
|
parser.add_argument(
|
|
48
76
|
"--tokenizer-cache", type=Path, default=Path(tempfile.gettempdir()) / "data-gym-cache"
|
|
@@ -63,7 +91,11 @@ def main():
|
|
|
63
91
|
if args.action == "run":
|
|
64
92
|
repo = root / (args.name + args.suffix)
|
|
65
93
|
arguments = args.arguments[1:] if args.arguments[:1] == ["--"] else args.arguments
|
|
66
|
-
command(
|
|
94
|
+
command(
|
|
95
|
+
[python(repo), "-m", args.name, *arguments],
|
|
96
|
+
cwd=Path.cwd(),
|
|
97
|
+
env=runtime_environment(python(repo)),
|
|
98
|
+
)
|
|
67
99
|
return
|
|
68
100
|
for repo in repos.values():
|
|
69
101
|
if not (repo / "pyproject.toml").is_file():
|
|
@@ -89,25 +121,11 @@ def main():
|
|
|
89
121
|
temp = Path(temporary)
|
|
90
122
|
env = environment(temp, args.tokenizer_cache)
|
|
91
123
|
if args.action == "check":
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
python(repo),
|
|
98
|
-
CORE / "scripts/probe_consumer.py",
|
|
99
|
-
name,
|
|
100
|
-
"--expect-core",
|
|
101
|
-
CORE / "src/jevkit_core",
|
|
102
|
-
"--baseline-repo",
|
|
103
|
-
repo,
|
|
104
|
-
"--baseline-ref",
|
|
105
|
-
baselines[name],
|
|
106
|
-
],
|
|
107
|
-
cwd=temp,
|
|
108
|
-
env=env,
|
|
109
|
-
)
|
|
110
|
-
command([python(repo), "-m", "pytest", "-q"], cwd=repo, env=env)
|
|
124
|
+
command([python(CORE), "-m", "pytest", "-q"], env=runtime_environment(python(CORE), env))
|
|
125
|
+
for repo in repos.values():
|
|
126
|
+
consumer_env = runtime_environment(python(repo), env)
|
|
127
|
+
expect_core(python(repo), CORE / "src/jevkit_runtime", cwd=temp, env=consumer_env)
|
|
128
|
+
command([python(repo), "-m", "pytest", "-q"], cwd=repo, env=consumer_env)
|
|
111
129
|
return
|
|
112
130
|
wheels = temp / "wheels"
|
|
113
131
|
command(["uv", "build", "--no-sources", "--out-dir", wheels])
|
|
@@ -119,47 +137,27 @@ def main():
|
|
|
119
137
|
venv = temp / name
|
|
120
138
|
command(["uv", "venv", "--python", python(repo), venv])
|
|
121
139
|
executable = venv / ("Scripts/python.exe" if os.name == "nt" else "bin/python")
|
|
140
|
+
wheel_env = runtime_environment(executable, env)
|
|
122
141
|
command(["uv", "pip", "install", "--python", executable, core_wheel, package])
|
|
123
142
|
site = subprocess.check_output(
|
|
124
143
|
[str(executable), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"], text=True
|
|
125
144
|
).strip()
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
CORE / "scripts/probe_consumer.py",
|
|
130
|
-
name,
|
|
131
|
-
"--expect-core",
|
|
132
|
-
Path(site) / "jevkit_core",
|
|
133
|
-
],
|
|
134
|
-
cwd=temp,
|
|
135
|
-
env=env,
|
|
136
|
-
)
|
|
137
|
-
command([executable, "-m", name, "--version"], cwd=temp, env=env)
|
|
138
|
-
if name == "jcol":
|
|
139
|
-
cli = venv / ("Scripts/jcol.exe" if os.name == "nt" else "bin/jcol")
|
|
140
|
-
command([executable, repo / "tests/test_process.py", cli], cwd=temp, env=env)
|
|
145
|
+
expect_core(executable, Path(site) / "jevkit_runtime", cwd=temp, env=wheel_env)
|
|
146
|
+
command([executable, "-m", name, "--version"], cwd=temp, env=wheel_env)
|
|
147
|
+
for asset in PACKAGED_ASSETS.get(name, []):
|
|
141
148
|
command(
|
|
142
149
|
[
|
|
143
150
|
executable,
|
|
144
151
|
"-c",
|
|
145
152
|
"from importlib.resources import files; "
|
|
146
|
-
"assert files(
|
|
153
|
+
f"assert files({name!r}).joinpath({asset!r}).is_file()",
|
|
147
154
|
],
|
|
148
155
|
cwd=temp,
|
|
149
|
-
env=
|
|
150
|
-
)
|
|
151
|
-
if name == "jlink":
|
|
152
|
-
command(
|
|
153
|
-
[
|
|
154
|
-
executable,
|
|
155
|
-
"-c",
|
|
156
|
-
"from importlib.resources import files; "
|
|
157
|
-
"assert all(files('jlink').joinpath('assets', f).is_file() "
|
|
158
|
-
"for f in ('review.html', 'review.js', 'review.css'))",
|
|
159
|
-
],
|
|
160
|
-
cwd=temp,
|
|
161
|
-
env=env,
|
|
156
|
+
env=wheel_env,
|
|
162
157
|
)
|
|
158
|
+
if name == "jcol":
|
|
159
|
+
cli = venv / ("Scripts/jcol.exe" if os.name == "nt" else "bin/jcol")
|
|
160
|
+
command([executable, repo / "tests/test_process.py", cli], cwd=temp, env=wheel_env)
|
|
163
161
|
print(json.dumps({"wheel_checks": names, "status": "passed"}))
|
|
164
162
|
|
|
165
163
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Shared runtime for the JevKit tools: one pipeline, one store, one provider catalog."""
|
|
2
|
+
|
|
3
|
+
from .client import Answers, Client
|
|
4
|
+
from .errors import (
|
|
5
|
+
JevBudgetExceeded,
|
|
6
|
+
JevError,
|
|
7
|
+
JevFatal,
|
|
8
|
+
ProviderError,
|
|
9
|
+
ProviderFatal,
|
|
10
|
+
ProviderStatus,
|
|
11
|
+
RequestExhausted,
|
|
12
|
+
)
|
|
13
|
+
from .meter import Meter
|
|
14
|
+
from .protocol import (
|
|
15
|
+
QUESTION_TYPES,
|
|
16
|
+
Usage,
|
|
17
|
+
answer_key,
|
|
18
|
+
digest,
|
|
19
|
+
error_detail,
|
|
20
|
+
parse_answers,
|
|
21
|
+
parse_usage,
|
|
22
|
+
request_body,
|
|
23
|
+
validate_answer,
|
|
24
|
+
)
|
|
25
|
+
from .providers import PROVIDERS, Backend, Provider, catalog, resolve
|
|
26
|
+
from .settings import DEFAULT_PRICE_PER_MTOK, Settings
|
|
27
|
+
from .store import AnswerStore, Entry
|
|
28
|
+
from .transport import FATAL, RETRYABLE, post
|
|
29
|
+
|
|
30
|
+
__version__ = "0.2.0"
|
|
31
|
+
__all__ = [
|
|
32
|
+
"AnswerStore",
|
|
33
|
+
"Answers",
|
|
34
|
+
"Backend",
|
|
35
|
+
"Client",
|
|
36
|
+
"DEFAULT_PRICE_PER_MTOK",
|
|
37
|
+
"Entry",
|
|
38
|
+
"FATAL",
|
|
39
|
+
"JevBudgetExceeded",
|
|
40
|
+
"JevError",
|
|
41
|
+
"JevFatal",
|
|
42
|
+
"Meter",
|
|
43
|
+
"PROVIDERS",
|
|
44
|
+
"ProviderError",
|
|
45
|
+
"ProviderFatal",
|
|
46
|
+
"ProviderStatus",
|
|
47
|
+
"Provider",
|
|
48
|
+
"QUESTION_TYPES",
|
|
49
|
+
"RETRYABLE",
|
|
50
|
+
"RequestExhausted",
|
|
51
|
+
"Settings",
|
|
52
|
+
"Usage",
|
|
53
|
+
"answer_key",
|
|
54
|
+
"catalog",
|
|
55
|
+
"digest",
|
|
56
|
+
"error_detail",
|
|
57
|
+
"parse_answers",
|
|
58
|
+
"parse_usage",
|
|
59
|
+
"post",
|
|
60
|
+
"request_body",
|
|
61
|
+
"resolve",
|
|
62
|
+
"validate_answer",
|
|
63
|
+
]
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""The request pipeline: identity, store, sharing, transport, validation, storage, metering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib.util
|
|
7
|
+
from collections.abc import Callable, Coroutine, Iterable, Mapping
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from . import transport
|
|
13
|
+
from .errors import JevBudgetExceeded
|
|
14
|
+
from .meter import Meter
|
|
15
|
+
from .protocol import (
|
|
16
|
+
answer_key,
|
|
17
|
+
answer_origin,
|
|
18
|
+
parse_answers,
|
|
19
|
+
parse_usage,
|
|
20
|
+
request_body,
|
|
21
|
+
resolved_model,
|
|
22
|
+
validate_answer,
|
|
23
|
+
)
|
|
24
|
+
from .providers import Backend
|
|
25
|
+
from .store import AnswerStore
|
|
26
|
+
|
|
27
|
+
Flight = tuple[dict[str, dict], dict] # answers by key, and the origin they share
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Answers(dict):
|
|
31
|
+
"""Answers by question id, plus where each came from.
|
|
32
|
+
|
|
33
|
+
`origins[qid]` carries `provider`, `requested_model`, `resolved_model`, `answered_at`, and a
|
|
34
|
+
`source` of `cache`, `api`, or `shared`. An answer stored without provenance has only `source`.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, answers: Mapping[str, dict], origins: Mapping[str, dict]):
|
|
38
|
+
super().__init__(answers)
|
|
39
|
+
self.origins: dict[str, dict] = dict(origins)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def http2_available() -> bool:
|
|
43
|
+
"""HTTP/2 whenever the optional `h2` package is installed; the `http2` extra pulls it in."""
|
|
44
|
+
return importlib.util.find_spec("h2") is not None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Client:
|
|
48
|
+
"""Ask System One questions about a state. Every tool gets the same pipeline; policy is per call."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
backend: Backend,
|
|
53
|
+
*,
|
|
54
|
+
timeout: float = 15.0,
|
|
55
|
+
attempts: int = 4,
|
|
56
|
+
concurrency: int = 32,
|
|
57
|
+
store: AnswerStore | None = None,
|
|
58
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
59
|
+
):
|
|
60
|
+
self.backend = backend
|
|
61
|
+
self.timeout, self.attempts, self.store = timeout, attempts, store
|
|
62
|
+
self.concurrency, self.transport = concurrency, transport
|
|
63
|
+
self.http2 = transport is None and http2_available()
|
|
64
|
+
self.meter = Meter(provider=backend.name, requested_model=backend.model)
|
|
65
|
+
self._flights: dict[str, asyncio.Task[Flight]] = {}
|
|
66
|
+
self._http: httpx.AsyncClient | None = None
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def model(self) -> str:
|
|
70
|
+
return self.backend.model
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def url(self) -> str:
|
|
74
|
+
return self.backend.url
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def http(self) -> httpx.AsyncClient:
|
|
78
|
+
"""Opened on first use, so cache-only clients never hold a connection pool."""
|
|
79
|
+
if self._http is None:
|
|
80
|
+
headers = {"X-Title": "jev tools"}
|
|
81
|
+
if self.backend.key:
|
|
82
|
+
headers["Authorization"] = f"Bearer {self.backend.key}"
|
|
83
|
+
limits = (
|
|
84
|
+
httpx.Limits(max_connections=16, max_keepalive_connections=16, keepalive_expiry=120)
|
|
85
|
+
if self.http2
|
|
86
|
+
else httpx.Limits(
|
|
87
|
+
max_connections=self.concurrency + 4, max_keepalive_connections=self.concurrency + 4
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
self._http = httpx.AsyncClient(
|
|
91
|
+
headers=headers, limits=limits, transport=self.transport, http2=self.http2
|
|
92
|
+
)
|
|
93
|
+
return self._http
|
|
94
|
+
|
|
95
|
+
async def close(self) -> None:
|
|
96
|
+
if self._http is not None:
|
|
97
|
+
await self._http.aclose()
|
|
98
|
+
self._http = None
|
|
99
|
+
|
|
100
|
+
async def __aenter__(self) -> Client:
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
async def __aexit__(self, *exc) -> None:
|
|
104
|
+
await self.close()
|
|
105
|
+
|
|
106
|
+
def key(self, state, question: dict) -> str:
|
|
107
|
+
return answer_key(self.backend, state, question)
|
|
108
|
+
|
|
109
|
+
async def ask(
|
|
110
|
+
self,
|
|
111
|
+
state,
|
|
112
|
+
questions: dict[str, dict],
|
|
113
|
+
*,
|
|
114
|
+
keys: Mapping[str, str] | None = None,
|
|
115
|
+
allow_paid: bool = True,
|
|
116
|
+
on_cost: Callable[[float], None] | None = None,
|
|
117
|
+
hedge_after: float | None = None,
|
|
118
|
+
) -> Answers:
|
|
119
|
+
"""Answer every question, sending only those the store cannot answer.
|
|
120
|
+
|
|
121
|
+
`keys` overrides the identity of each question for callers whose reuse unit is not the
|
|
122
|
+
request. `allow_paid=False` still serves store hits and joins an in-flight request.
|
|
123
|
+
`on_cost` is charged only by the caller whose request actually went out. `hedge_after`
|
|
124
|
+
sends a slow call a second time and keeps the first answer.
|
|
125
|
+
"""
|
|
126
|
+
keys = dict(keys) if keys is not None else {qid: self.key(state, q) for qid, q in questions.items()}
|
|
127
|
+
answers: dict[str, dict] = {}
|
|
128
|
+
origins: dict[str, dict] = {}
|
|
129
|
+
if self.store is not None:
|
|
130
|
+
for qid, question in questions.items():
|
|
131
|
+
if (entry := self.store.entry(keys[qid])) is not None:
|
|
132
|
+
validate_answer(qid, question, entry.answer)
|
|
133
|
+
answers[qid] = entry.answer
|
|
134
|
+
origins[qid] = dict(entry.metadata or {}) | {"source": "cache"}
|
|
135
|
+
misses = {qid: q for qid, q in questions.items() if qid not in answers}
|
|
136
|
+
if not misses:
|
|
137
|
+
self.meter.cached += 1
|
|
138
|
+
else:
|
|
139
|
+
miss_keys = {qid: keys[qid] for qid in misses}
|
|
140
|
+
|
|
141
|
+
def start() -> Coroutine[Any, Any, Flight]:
|
|
142
|
+
if not allow_paid:
|
|
143
|
+
raise JevBudgetExceeded("a new paid request is not allowed by the budget")
|
|
144
|
+
return self._request(state, misses, miss_keys, on_cost)
|
|
145
|
+
|
|
146
|
+
task, owner = self._share(miss_keys.values(), start)
|
|
147
|
+
if hedge_after is None:
|
|
148
|
+
by_key, origin = await task
|
|
149
|
+
else:
|
|
150
|
+
by_key, origin = await self._hedged(task, hedge_after, state, misses, miss_keys, on_cost)
|
|
151
|
+
for qid, key in miss_keys.items():
|
|
152
|
+
answers[qid] = by_key[key]
|
|
153
|
+
origins[qid] = dict(origin) | {"source": "api" if owner else "shared"}
|
|
154
|
+
for origin in origins.values():
|
|
155
|
+
self.meter.note_answer(origin)
|
|
156
|
+
return Answers({qid: answers[qid] for qid in questions}, {qid: origins[qid] for qid in questions})
|
|
157
|
+
|
|
158
|
+
def _share(self, keys: Iterable[str], start: Callable[[], Coroutine[Any, Any, Flight]]):
|
|
159
|
+
"""Join an identical in-flight request, or start one. Only the starter pays."""
|
|
160
|
+
flight = "|".join(sorted(keys))
|
|
161
|
+
if (task := self._flights.get(flight)) is not None:
|
|
162
|
+
self.meter.cached += 1
|
|
163
|
+
return task, False
|
|
164
|
+
task = asyncio.ensure_future(start())
|
|
165
|
+
self._flights[flight] = task
|
|
166
|
+
|
|
167
|
+
def discard(done):
|
|
168
|
+
if self._flights.get(flight) is done:
|
|
169
|
+
del self._flights[flight]
|
|
170
|
+
|
|
171
|
+
task.add_done_callback(discard)
|
|
172
|
+
return task, True
|
|
173
|
+
|
|
174
|
+
async def _hedged(self, first, after, state, questions, keys, on_cost) -> Flight:
|
|
175
|
+
done, _ = await asyncio.wait({first}, timeout=after)
|
|
176
|
+
if done:
|
|
177
|
+
return first.result()
|
|
178
|
+
second = asyncio.ensure_future(self._request(state, questions, keys, on_cost))
|
|
179
|
+
self.meter.hedges += 1
|
|
180
|
+
pending = {first, second}
|
|
181
|
+
error: BaseException | None = None
|
|
182
|
+
while pending:
|
|
183
|
+
done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
|
|
184
|
+
for task in done:
|
|
185
|
+
if task.exception() is None:
|
|
186
|
+
if second in pending:
|
|
187
|
+
second.cancel() # never cancel `first`: other callers may be sharing it
|
|
188
|
+
return task.result()
|
|
189
|
+
error = task.exception()
|
|
190
|
+
assert error is not None
|
|
191
|
+
raise error
|
|
192
|
+
|
|
193
|
+
async def _request(self, state, questions: dict[str, dict], keys: dict[str, str], on_cost) -> Flight:
|
|
194
|
+
def retry():
|
|
195
|
+
self.meter.retries += 1
|
|
196
|
+
|
|
197
|
+
data = await transport.post(
|
|
198
|
+
self.http,
|
|
199
|
+
self.backend.url,
|
|
200
|
+
request_body(self.backend.model, state, questions),
|
|
201
|
+
provider=self.backend.name,
|
|
202
|
+
timeout=self.timeout,
|
|
203
|
+
attempts=self.attempts,
|
|
204
|
+
on_retry=retry,
|
|
205
|
+
)
|
|
206
|
+
usage = parse_usage(data.get("usage"), price_per_mtok=self.backend.price_per_mtok)
|
|
207
|
+
self.meter.record_call(usage, on_cost)
|
|
208
|
+
answers = parse_answers(data, questions, provider=self.backend.name)
|
|
209
|
+
origin = answer_origin(self.backend, resolved_model(data))
|
|
210
|
+
if self.store is not None:
|
|
211
|
+
for qid, answer in answers.items():
|
|
212
|
+
self.store.put(keys[qid], answer, origin)
|
|
213
|
+
return {keys[qid]: answer for qid, answer in answers.items()}, origin
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Failure types shared by every JevKit tool."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class JevError(Exception):
|
|
5
|
+
"""One request failed; the rest of the run may continue."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JevFatal(Exception):
|
|
9
|
+
"""Stop the run until configuration, credentials, or metering is corrected."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JevBudgetExceeded(Exception):
|
|
13
|
+
"""No cached or in-flight answer exists and a new paid request is forbidden."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ProviderStatus(Exception):
|
|
17
|
+
"""An HTTP status the provider answered with, kept structured so tools can word or redact it."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, provider: str, status: int, detail: str):
|
|
20
|
+
self.provider, self.status, self.detail = provider, status, detail
|
|
21
|
+
super().__init__(self.message())
|
|
22
|
+
|
|
23
|
+
def message(self) -> str:
|
|
24
|
+
return f"HTTP {self.status}: {self.detail}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ProviderError(ProviderStatus, JevError):
|
|
28
|
+
"""A status that is neither retryable nor a reason to stop the run, such as a bad request."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProviderFatal(ProviderStatus, JevFatal):
|
|
32
|
+
"""Authentication, payment, or permission failed; no further request can succeed."""
|
|
33
|
+
|
|
34
|
+
def message(self) -> str:
|
|
35
|
+
return f"{self.provider} said {self.status}: {self.detail}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RequestExhausted(JevError):
|
|
39
|
+
"""Every attempt within the deadline failed; `last` names the final failure."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, timeout: float, last: str, *, timed_out: bool = False):
|
|
42
|
+
self.timeout, self.last, self.timed_out = timeout, last, timed_out
|
|
43
|
+
super().__init__(f"gave up after {timeout:g}s ({last})")
|