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.
Files changed (44) hide show
  1. {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/.github/workflows/downstream.yml +0 -2
  2. jevkit_runtime-0.2.0/PKG-INFO +94 -0
  3. jevkit_runtime-0.2.0/README.md +79 -0
  4. {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/pyproject.toml +2 -2
  5. {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/scripts/dev.py +48 -50
  6. jevkit_runtime-0.2.0/src/jevkit_runtime/__init__.py +63 -0
  7. jevkit_runtime-0.2.0/src/jevkit_runtime/client.py +213 -0
  8. jevkit_runtime-0.2.0/src/jevkit_runtime/errors.py +43 -0
  9. jevkit_runtime-0.2.0/src/jevkit_runtime/meter.py +67 -0
  10. jevkit_runtime-0.2.0/src/jevkit_runtime/protocol.py +152 -0
  11. jevkit_runtime-0.2.0/src/jevkit_runtime/providers.py +144 -0
  12. jevkit_runtime-0.2.0/src/jevkit_runtime/settings.py +57 -0
  13. jevkit_runtime-0.2.0/src/jevkit_runtime/store.py +90 -0
  14. jevkit_runtime-0.2.0/src/jevkit_runtime/transport.py +90 -0
  15. jevkit_runtime-0.2.0/tests/test_client.py +243 -0
  16. jevkit_runtime-0.2.0/tests/test_dev_runner.py +60 -0
  17. jevkit_runtime-0.2.0/tests/test_protocol.py +104 -0
  18. jevkit_runtime-0.2.0/tests/test_settings_and_providers.py +106 -0
  19. jevkit_runtime-0.2.0/tests/test_store.py +77 -0
  20. jevkit_runtime-0.2.0/tests/test_transport.py +159 -0
  21. {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/uv.lock +1 -1
  22. jevkit_runtime-0.1.0/PKG-INFO +0 -179
  23. jevkit_runtime-0.1.0/README.md +0 -164
  24. jevkit_runtime-0.1.0/TESTING.md +0 -95
  25. jevkit_runtime-0.1.0/consumer-baselines.json +0 -7
  26. jevkit_runtime-0.1.0/scripts/probe_consumer.py +0 -232
  27. jevkit_runtime-0.1.0/src/jevkit_core/__init__.py +0 -48
  28. jevkit_runtime-0.1.0/src/jevkit_core/backends.py +0 -142
  29. jevkit_runtime-0.1.0/src/jevkit_core/cache.py +0 -92
  30. jevkit_runtime-0.1.0/src/jevkit_core/client.py +0 -109
  31. jevkit_runtime-0.1.0/src/jevkit_core/errors.py +0 -22
  32. jevkit_runtime-0.1.0/src/jevkit_core/provenance.py +0 -18
  33. jevkit_runtime-0.1.0/src/jevkit_core/transport.py +0 -119
  34. jevkit_runtime-0.1.0/src/jevkit_core/usage.py +0 -88
  35. jevkit_runtime-0.1.0/tests/test_accounting.py +0 -62
  36. jevkit_runtime-0.1.0/tests/test_backends.py +0 -82
  37. jevkit_runtime-0.1.0/tests/test_cache_and_usage.py +0 -91
  38. jevkit_runtime-0.1.0/tests/test_shared_requests.py +0 -122
  39. jevkit_runtime-0.1.0/tests/test_transport.py +0 -136
  40. {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/.github/workflows/publish.yml +0 -0
  41. {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/.github/workflows/test.yml +0 -0
  42. {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/.gitignore +0 -0
  43. {jevkit_runtime-0.1.0 → jevkit_runtime-0.2.0}/LICENSE +0 -0
  44. {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.1.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/jevkit_core"]
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=-jevkit for isolated migration worktrees")
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([python(repo), "-m", args.name, *arguments], cwd=Path.cwd())
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
- baselines = json.loads((CORE / "consumer-baselines.json").read_text())
93
- command([python(CORE), "-m", "pytest", "-q"], env=env)
94
- for name, repo in repos.items():
95
- command(
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
- command(
127
- [
128
- executable,
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('jcol').joinpath('static/index.html').is_file()",
153
+ f"assert files({name!r}).joinpath({asset!r}).is_file()",
147
154
  ],
148
155
  cwd=temp,
149
- env=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})")