jevkit-runtime 0.1.0__py3-none-any.whl
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_core/__init__.py +48 -0
- jevkit_core/backends.py +142 -0
- jevkit_core/cache.py +92 -0
- jevkit_core/client.py +109 -0
- jevkit_core/errors.py +22 -0
- jevkit_core/provenance.py +18 -0
- jevkit_core/transport.py +119 -0
- jevkit_core/usage.py +88 -0
- jevkit_runtime-0.1.0.dist-info/METADATA +179 -0
- jevkit_runtime-0.1.0.dist-info/RECORD +12 -0
- jevkit_runtime-0.1.0.dist-info/WHEEL +4 -0
- jevkit_runtime-0.1.0.dist-info/licenses/LICENSE +21 -0
jevkit_core/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Shared implementation for the independent JevKit tools."""
|
|
2
|
+
|
|
3
|
+
from .backends import (
|
|
4
|
+
PRICE_PER_MTOK,
|
|
5
|
+
PROVIDERS,
|
|
6
|
+
Backend,
|
|
7
|
+
backend_catalog,
|
|
8
|
+
config_dir,
|
|
9
|
+
credential,
|
|
10
|
+
resolve_backend,
|
|
11
|
+
)
|
|
12
|
+
from .cache import AnswerCache, answer_key, cache_path, digest
|
|
13
|
+
from .client import DecisionClient, validate_answer
|
|
14
|
+
from .errors import JevBudgetExceeded, JevError, JevFatal
|
|
15
|
+
from .provenance import answer_provenance
|
|
16
|
+
from .transport import FATAL, RETRYABLE, RetryPolicy, error_detail, json_object, request_json
|
|
17
|
+
from .usage import Meter, Usage, parse_usage, record_usage
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
__all__ = [
|
|
21
|
+
"AnswerCache",
|
|
22
|
+
"Backend",
|
|
23
|
+
"DecisionClient",
|
|
24
|
+
"FATAL",
|
|
25
|
+
"JevBudgetExceeded",
|
|
26
|
+
"JevError",
|
|
27
|
+
"JevFatal",
|
|
28
|
+
"Meter",
|
|
29
|
+
"PRICE_PER_MTOK",
|
|
30
|
+
"PROVIDERS",
|
|
31
|
+
"RETRYABLE",
|
|
32
|
+
"RetryPolicy",
|
|
33
|
+
"Usage",
|
|
34
|
+
"answer_key",
|
|
35
|
+
"answer_provenance",
|
|
36
|
+
"backend_catalog",
|
|
37
|
+
"cache_path",
|
|
38
|
+
"config_dir",
|
|
39
|
+
"credential",
|
|
40
|
+
"digest",
|
|
41
|
+
"error_detail",
|
|
42
|
+
"json_object",
|
|
43
|
+
"parse_usage",
|
|
44
|
+
"record_usage",
|
|
45
|
+
"request_json",
|
|
46
|
+
"resolve_backend",
|
|
47
|
+
"validate_answer",
|
|
48
|
+
]
|
jevkit_core/backends.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Backend capabilities and credentials; product adapters select their supported backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import asdict, dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .errors import JevFatal
|
|
10
|
+
|
|
11
|
+
PRICE_PER_MTOK = float(os.environ.get("JEV_PRICE_PER_MTOK", 0.042))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def config_dir() -> Path:
|
|
15
|
+
return Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "jev"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def credential(name: str, variable: str) -> tuple[str, str]:
|
|
19
|
+
"""Return a key and its source, without putting secrets in errors or logs."""
|
|
20
|
+
key = os.environ.get(variable, "").strip()
|
|
21
|
+
if key:
|
|
22
|
+
return key, "env"
|
|
23
|
+
path = config_dir() / f"{name}.key"
|
|
24
|
+
return (path.read_text().strip(), "config") if path.is_file() else ("", "config")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Backend:
|
|
29
|
+
name: str
|
|
30
|
+
url: str
|
|
31
|
+
model: str
|
|
32
|
+
key_env: str
|
|
33
|
+
url_env: str | None = None
|
|
34
|
+
requires_key: bool = True
|
|
35
|
+
auto_select: bool = True
|
|
36
|
+
price_per_mtok: float = PRICE_PER_MTOK
|
|
37
|
+
cache_by_request: bool = False
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def key_file(self) -> Path:
|
|
41
|
+
return config_dir() / f"{self.name}.key"
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def url_file(self) -> Path:
|
|
45
|
+
return config_dir() / f"{self.name}.url"
|
|
46
|
+
|
|
47
|
+
def key(self) -> str | None:
|
|
48
|
+
# Retain the historical precedence of an explicitly set environment value.
|
|
49
|
+
if os.environ.get(self.key_env):
|
|
50
|
+
return os.environ[self.key_env].strip()
|
|
51
|
+
return self.key_file.read_text().strip() if self.key_file.exists() else None
|
|
52
|
+
|
|
53
|
+
def configured_url(self) -> str | None:
|
|
54
|
+
if not self.url_env:
|
|
55
|
+
return self.url
|
|
56
|
+
if os.environ.get(self.url_env):
|
|
57
|
+
return os.environ[self.url_env].strip()
|
|
58
|
+
return (self.url_file.read_text().strip() if self.url_file.exists() else None) or self.url or None
|
|
59
|
+
|
|
60
|
+
def endpoint(self) -> str:
|
|
61
|
+
return os.environ.get("JEV_URL") or self.configured_url() or self.url
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
PROVIDERS = {
|
|
65
|
+
"typesafe": Backend("typesafe", "https://api.typesafe.ai/v1/systemone", "jev-latest", "TYPESAFE_API_KEY"),
|
|
66
|
+
"openrouter": Backend(
|
|
67
|
+
"openrouter",
|
|
68
|
+
"https://openrouter.ai/api/alpha/decisions",
|
|
69
|
+
"~typesafe/jev-latest",
|
|
70
|
+
"OPENROUTER_API_KEY",
|
|
71
|
+
),
|
|
72
|
+
"gateway": Backend("gateway", "", "jev-latest", "JEV_GATEWAY_API_KEY", url_env="JEV_GATEWAY_URL"),
|
|
73
|
+
# Local servers are explicit opt-ins and never replace a configured hosted provider.
|
|
74
|
+
"diffusiongemma": Backend(
|
|
75
|
+
"diffusiongemma",
|
|
76
|
+
"http://127.0.0.1:8080/v1/systemone",
|
|
77
|
+
"openjev-latest",
|
|
78
|
+
"JEV_DIFFUSIONGEMMA_API_KEY",
|
|
79
|
+
url_env="JEV_DIFFUSIONGEMMA_URL",
|
|
80
|
+
requires_key=False,
|
|
81
|
+
auto_select=False,
|
|
82
|
+
price_per_mtok=float(os.environ.get("JEV_PRICE_PER_MTOK", 0)),
|
|
83
|
+
cache_by_request=True,
|
|
84
|
+
),
|
|
85
|
+
"laya": Backend(
|
|
86
|
+
"laya",
|
|
87
|
+
"http://127.0.0.1:8081/v1/systemone",
|
|
88
|
+
"laya-421m",
|
|
89
|
+
"JEV_LAYA_API_KEY",
|
|
90
|
+
url_env="JEV_LAYA_URL",
|
|
91
|
+
requires_key=False,
|
|
92
|
+
auto_select=False,
|
|
93
|
+
price_per_mtok=float(os.environ.get("JEV_PRICE_PER_MTOK", 0)),
|
|
94
|
+
),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def backend_catalog(
|
|
99
|
+
*names: str, models: dict[str, str] | None = None, backend_type: type[Backend] = Backend
|
|
100
|
+
) -> dict[str, Backend]:
|
|
101
|
+
"""Select providers in priority order, retaining tool-owned model defaults and adapters.
|
|
102
|
+
|
|
103
|
+
Both the mapping and its definitions are fresh, so a consumer's overrides never
|
|
104
|
+
change another consumer's catalog. Unknown providers or unused overrides fail early.
|
|
105
|
+
"""
|
|
106
|
+
models = {} if models is None else models
|
|
107
|
+
if unknown := models.keys() - set(names):
|
|
108
|
+
raise ValueError(f"model overrides for unselected providers: {', '.join(sorted(unknown))}")
|
|
109
|
+
return {
|
|
110
|
+
name: backend_type(**(asdict(PROVIDERS[name]) | {"model": models.get(name, PROVIDERS[name].model)}))
|
|
111
|
+
for name in names
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def resolve_backend(
|
|
116
|
+
backends: dict[str, Backend], name: str | None = None, *, require_key: bool = True, help_suffix: str = ""
|
|
117
|
+
) -> tuple[Backend, str]:
|
|
118
|
+
name = name or os.environ.get("JEV_API")
|
|
119
|
+
if name:
|
|
120
|
+
if name not in backends:
|
|
121
|
+
raise JevFatal(f"unknown API {name!r}; choose from {', '.join(backends)}")
|
|
122
|
+
backend = backends[name]
|
|
123
|
+
key = backend.key() or ""
|
|
124
|
+
if require_key and backend.requires_key and not key:
|
|
125
|
+
raise JevFatal(f"no key for {name}. Set {backend.key_env} or put the key in {backend.key_file}")
|
|
126
|
+
if backend.url_env and not backend.configured_url() and not os.environ.get("JEV_URL"):
|
|
127
|
+
raise JevFatal(
|
|
128
|
+
f"no URL for {name}. Set {backend.url_env} to the full System One endpoint "
|
|
129
|
+
f"(for example https://gateway.example.com/v1/systemone) or put it in {backend.url_file}"
|
|
130
|
+
)
|
|
131
|
+
return backend, key
|
|
132
|
+
for backend in backends.values():
|
|
133
|
+
if (
|
|
134
|
+
backend.auto_select
|
|
135
|
+
and (key := backend.key())
|
|
136
|
+
and (not backend.url_env or backend.configured_url())
|
|
137
|
+
):
|
|
138
|
+
return backend, key
|
|
139
|
+
if not require_key:
|
|
140
|
+
return next(iter(backends.values())), ""
|
|
141
|
+
options = " or ".join(b.key_env for b in backends.values() if b.auto_select)
|
|
142
|
+
raise JevFatal(f"no API key. Set {options}, or put a key in {config_dir()}/<api>.key{help_suffix}")
|
jevkit_core/cache.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Thread-safe SQLite answers, compatible with the existing three-column table.
|
|
2
|
+
|
|
3
|
+
Storage is shared; key identity is explicit. Existing tools retain their exact legacy
|
|
4
|
+
key functions in adapters. New consumers may opt into the versioned answer_key.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def cache_path() -> Path:
|
|
19
|
+
return Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache") / "jev" / "answers.sqlite"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def digest(parts) -> str:
|
|
23
|
+
return hashlib.sha256(json.dumps(parts, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def answer_key(model: str, state, question: dict, *, provider: str, endpoint: str) -> str:
|
|
27
|
+
"""Opt-in v1 identity; no unsafe fallback to ambiguous legacy keys."""
|
|
28
|
+
return digest(["jevkit-answer-v1", provider, endpoint, model, state, question])
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AnswerCache:
|
|
32
|
+
metadata = True
|
|
33
|
+
|
|
34
|
+
def __init__(self, path: Path | None = None):
|
|
35
|
+
path = Path(path) if path is not None else cache_path()
|
|
36
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self.db = sqlite3.connect(path, timeout=30, isolation_level=None, check_same_thread=False)
|
|
38
|
+
self._lock = threading.RLock()
|
|
39
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
40
|
+
self.db.execute("PRAGMA synchronous=NORMAL")
|
|
41
|
+
self.db.execute(
|
|
42
|
+
"CREATE TABLE IF NOT EXISTS answers "
|
|
43
|
+
"(key TEXT PRIMARY KEY, answer TEXT NOT NULL, at REAL NOT NULL) WITHOUT ROWID"
|
|
44
|
+
)
|
|
45
|
+
if self.metadata:
|
|
46
|
+
self.db.execute(
|
|
47
|
+
"CREATE TABLE IF NOT EXISTS answer_metadata "
|
|
48
|
+
"(key TEXT PRIMARY KEY, at REAL NOT NULL, metadata TEXT NOT NULL) WITHOUT ROWID"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def get(self, key: str) -> dict | None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
row = self.db.execute("SELECT answer FROM answers WHERE key = ?", (key,)).fetchone()
|
|
54
|
+
return json.loads(row[0]) if row else None
|
|
55
|
+
|
|
56
|
+
def get_entry(self, key: str) -> tuple[dict, dict] | None:
|
|
57
|
+
if not self.metadata:
|
|
58
|
+
answer = self.get(key)
|
|
59
|
+
return (answer, {}) if answer is not None else None
|
|
60
|
+
with self._lock:
|
|
61
|
+
row = self.db.execute(
|
|
62
|
+
"SELECT a.answer, m.metadata FROM answers a LEFT JOIN answer_metadata m "
|
|
63
|
+
"ON a.key = m.key AND a.at = m.at WHERE a.key = ?",
|
|
64
|
+
(key,),
|
|
65
|
+
).fetchone()
|
|
66
|
+
return (json.loads(row[0]), json.loads(row[1]) if row[1] else {}) if row else None
|
|
67
|
+
|
|
68
|
+
def put(self, key: str, answer: dict, *, metadata: dict | None = None) -> None:
|
|
69
|
+
encoded = json.dumps(answer)
|
|
70
|
+
encoded_metadata = json.dumps(metadata) if metadata is not None else None
|
|
71
|
+
with self._lock:
|
|
72
|
+
at = time.time()
|
|
73
|
+
if not self.metadata:
|
|
74
|
+
self.db.execute("INSERT OR REPLACE INTO answers VALUES (?, ?, ?)", (key, encoded, at))
|
|
75
|
+
return
|
|
76
|
+
self.db.execute("BEGIN IMMEDIATE")
|
|
77
|
+
try:
|
|
78
|
+
self.db.execute("INSERT OR REPLACE INTO answers VALUES (?, ?, ?)", (key, encoded, at))
|
|
79
|
+
if metadata is not None:
|
|
80
|
+
self.db.execute(
|
|
81
|
+
"INSERT OR REPLACE INTO answer_metadata VALUES (?, ?, ?)", (key, at, encoded_metadata)
|
|
82
|
+
)
|
|
83
|
+
else:
|
|
84
|
+
self.db.execute("DELETE FROM answer_metadata WHERE key = ?", (key,))
|
|
85
|
+
self.db.execute("COMMIT")
|
|
86
|
+
except BaseException:
|
|
87
|
+
self.db.execute("ROLLBACK")
|
|
88
|
+
raise
|
|
89
|
+
|
|
90
|
+
def close(self) -> None:
|
|
91
|
+
with self._lock:
|
|
92
|
+
self.db.close()
|
jevkit_core/client.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Shared client lifecycle and transport; adapters own answer/request reuse semantics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Callable, Coroutine, Iterable
|
|
9
|
+
from typing import Any, TypeVar
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from . import transport
|
|
14
|
+
from .backends import Backend
|
|
15
|
+
from .errors import JevError
|
|
16
|
+
from .usage import Meter
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_answer(qid: str, question: dict, answer) -> None:
|
|
22
|
+
if not isinstance(answer, dict):
|
|
23
|
+
raise JevError(f"invalid answer returned for question {qid!r}: expected an object")
|
|
24
|
+
if question.get("type") == "noul":
|
|
25
|
+
p = answer.get("noul")
|
|
26
|
+
if isinstance(p, bool) or not isinstance(p, (int, float)) or not math.isfinite(p) or not 0 <= p <= 1:
|
|
27
|
+
raise JevError(
|
|
28
|
+
f"invalid answer returned for question {qid!r}: noul must be a probability from 0 to 1"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DecisionClient:
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
key: str,
|
|
36
|
+
backend: Backend,
|
|
37
|
+
*,
|
|
38
|
+
model: str | None = None,
|
|
39
|
+
timeout: float = 15.0,
|
|
40
|
+
attempts: int = 4,
|
|
41
|
+
concurrency: int = 32,
|
|
42
|
+
cache=None,
|
|
43
|
+
transport=None,
|
|
44
|
+
meter: Meter | None = None,
|
|
45
|
+
http2: bool = False,
|
|
46
|
+
):
|
|
47
|
+
self.backend = backend
|
|
48
|
+
self.model = model or os.environ.get("JEV_MODEL") or backend.model
|
|
49
|
+
self.url = backend.endpoint()
|
|
50
|
+
self.timeout, self.attempts, self.cache = timeout, attempts, cache
|
|
51
|
+
self.meter = meter if meter is not None else Meter()
|
|
52
|
+
self._flights: dict[str, asyncio.Task] = {}
|
|
53
|
+
headers = {"X-Title": "jev tools"}
|
|
54
|
+
if key:
|
|
55
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
56
|
+
limits = (
|
|
57
|
+
httpx.Limits(max_connections=16, max_keepalive_connections=16, keepalive_expiry=120)
|
|
58
|
+
if http2
|
|
59
|
+
else httpx.Limits(max_connections=concurrency + 4, max_keepalive_connections=concurrency + 4)
|
|
60
|
+
)
|
|
61
|
+
self.http = httpx.AsyncClient(
|
|
62
|
+
headers=headers, limits=limits, transport=transport, http2=http2 and transport is None
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
async def close(self) -> None:
|
|
66
|
+
await self.http.aclose()
|
|
67
|
+
|
|
68
|
+
def share_request(
|
|
69
|
+
self, keys: Iterable[str], start: Callable[[], Coroutine[Any, Any, T]]
|
|
70
|
+
) -> tuple[asyncio.Task[T], bool]:
|
|
71
|
+
"""Get an in-flight request and whether this caller started it.
|
|
72
|
+
|
|
73
|
+
Called on the client's event loop without yielding. The lazy factory runs
|
|
74
|
+
only for new requests, so admission checks and charge callbacks belong to
|
|
75
|
+
the owner. Callers keep their existing awaiting/cancellation or hedging policy.
|
|
76
|
+
"""
|
|
77
|
+
flight = "|".join(sorted(keys))
|
|
78
|
+
if (task := self._flights.get(flight)) is not None:
|
|
79
|
+
self.meter.cached += 1
|
|
80
|
+
return task, False
|
|
81
|
+
task = asyncio.ensure_future(start())
|
|
82
|
+
self._flights[flight] = task
|
|
83
|
+
|
|
84
|
+
def discard(completed):
|
|
85
|
+
if self._flights.get(flight) is completed:
|
|
86
|
+
del self._flights[flight]
|
|
87
|
+
|
|
88
|
+
task.add_done_callback(discard)
|
|
89
|
+
return task, True
|
|
90
|
+
|
|
91
|
+
async def _call(self, state, questions: dict[str, dict], **kwargs):
|
|
92
|
+
body = {"model": self.model, "state": state, "questions": questions}
|
|
93
|
+
|
|
94
|
+
def retry():
|
|
95
|
+
self.meter.retries += 1
|
|
96
|
+
|
|
97
|
+
data, seconds = await transport.request_json(
|
|
98
|
+
self.http,
|
|
99
|
+
self.url,
|
|
100
|
+
body,
|
|
101
|
+
provider=self.backend.name,
|
|
102
|
+
timeout=self.timeout,
|
|
103
|
+
attempts=self.attempts,
|
|
104
|
+
on_retry=retry,
|
|
105
|
+
)
|
|
106
|
+
return self._record(state, questions, data, seconds, **kwargs)
|
|
107
|
+
|
|
108
|
+
def _record(self, state, questions, data, seconds, **kwargs):
|
|
109
|
+
raise NotImplementedError("the product adapter must record and validate its answer contract")
|
jevkit_core/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Errors shared by all JevKit client adapters."""
|
|
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 RequestExhausted(JevError):
|
|
17
|
+
"""Structured exhaustion details let each CLI retain its error wording."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, timeout: float, last: str, *, timed_out: bool = False):
|
|
20
|
+
self.timeout, self.last = timeout, last
|
|
21
|
+
self.timed_out = timed_out
|
|
22
|
+
super().__init__(f"gave up after {timeout:g}s ({last})")
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Answer origins shared by caches and saved results, independent of current defaults."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def answer_provenance(*, provider: str, requested_model: str, resolved_model: object) -> dict:
|
|
9
|
+
"""Record the responder literally; an absent model must never become the requested alias."""
|
|
10
|
+
return {
|
|
11
|
+
"version": 1,
|
|
12
|
+
"provider": provider,
|
|
13
|
+
"requested_model": requested_model,
|
|
14
|
+
"resolved_model": resolved_model
|
|
15
|
+
if isinstance(resolved_model, str) and resolved_model.strip()
|
|
16
|
+
else None,
|
|
17
|
+
"answered_at": time.time(),
|
|
18
|
+
}
|
jevkit_core/transport.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""One HTTP retry and deadline implementation for all JevKit tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import random
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from .errors import JevError, JevFatal, RequestExhausted
|
|
15
|
+
|
|
16
|
+
RETRYABLE = frozenset({408, 429, 500, 502, 503, 504, 529})
|
|
17
|
+
FATAL = frozenset({401, 402, 403})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class RetryPolicy:
|
|
22
|
+
delay: float = 0.2
|
|
23
|
+
jitter: float = 0.1
|
|
24
|
+
retry_after: bool = False
|
|
25
|
+
strict_json: bool = False
|
|
26
|
+
require_answers: bool = True
|
|
27
|
+
error_details: bool = True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
DEFAULT_RETRY_POLICY = RetryPolicy()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def json_object(response: httpx.Response) -> dict:
|
|
34
|
+
try:
|
|
35
|
+
data = response.json()
|
|
36
|
+
except ValueError:
|
|
37
|
+
return {}
|
|
38
|
+
return data if isinstance(data, dict) else {}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def error_detail(data: dict) -> str:
|
|
42
|
+
found = data.get("error", data.get("detail"))
|
|
43
|
+
if isinstance(found, list):
|
|
44
|
+
found = "; ".join(error_detail({"detail": item}) for item in found)
|
|
45
|
+
elif isinstance(found, dict):
|
|
46
|
+
found = found.get("message") or found.get("msg") or json.dumps(found)
|
|
47
|
+
return " ".join(str(found or "").split())[:200]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def request_json(
|
|
51
|
+
client: httpx.AsyncClient,
|
|
52
|
+
url: str,
|
|
53
|
+
body: dict,
|
|
54
|
+
*,
|
|
55
|
+
provider: str,
|
|
56
|
+
timeout: float = 15.0,
|
|
57
|
+
attempts: int = 4,
|
|
58
|
+
on_retry=None,
|
|
59
|
+
policy: RetryPolicy = DEFAULT_RETRY_POLICY,
|
|
60
|
+
) -> tuple[dict, float]:
|
|
61
|
+
if isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0:
|
|
62
|
+
raise ValueError("timeout must be positive and finite")
|
|
63
|
+
if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 1:
|
|
64
|
+
raise ValueError("attempts must be a positive integer")
|
|
65
|
+
deadline = time.monotonic() + timeout
|
|
66
|
+
last = "no attempt made"
|
|
67
|
+
timed_out = False
|
|
68
|
+
for attempt in range(attempts):
|
|
69
|
+
remaining = deadline - time.monotonic()
|
|
70
|
+
if remaining <= 0:
|
|
71
|
+
break
|
|
72
|
+
started = time.perf_counter()
|
|
73
|
+
pause = policy.delay * 2**attempt + random.random() * policy.jitter
|
|
74
|
+
try:
|
|
75
|
+
response = await asyncio.wait_for(client.post(url, json=body, timeout=remaining), remaining)
|
|
76
|
+
except asyncio.TimeoutError:
|
|
77
|
+
last = "deadline exceeded"
|
|
78
|
+
timed_out = True
|
|
79
|
+
break
|
|
80
|
+
except httpx.TransportError as exc:
|
|
81
|
+
last = type(exc).__name__
|
|
82
|
+
else:
|
|
83
|
+
if response.status_code == 200 and policy.strict_json:
|
|
84
|
+
try:
|
|
85
|
+
data = response.json()
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise JevError("provider returned invalid JSON") from exc
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
raise JevError("provider returned a non-object response")
|
|
90
|
+
else:
|
|
91
|
+
data = json_object(response)
|
|
92
|
+
if response.status_code == 200 and (not policy.require_answers or "answers" in data):
|
|
93
|
+
return data, time.perf_counter() - started
|
|
94
|
+
if response.status_code in FATAL or (
|
|
95
|
+
response.status_code != 200 and response.status_code not in RETRYABLE
|
|
96
|
+
):
|
|
97
|
+
error = JevFatal if response.status_code in FATAL else JevError
|
|
98
|
+
if not policy.error_details:
|
|
99
|
+
raise error(f"{provider} returned HTTP {response.status_code}")
|
|
100
|
+
detail = error_detail(data) or response.text[:200]
|
|
101
|
+
message = (
|
|
102
|
+
f"{provider} said {response.status_code}: {detail}"
|
|
103
|
+
if error is JevFatal
|
|
104
|
+
else f"HTTP {response.status_code}: {detail}"
|
|
105
|
+
)
|
|
106
|
+
raise error(message)
|
|
107
|
+
last = f"HTTP {response.status_code}"
|
|
108
|
+
if policy.retry_after:
|
|
109
|
+
try:
|
|
110
|
+
retry_after = float(response.headers.get("Retry-After", 0))
|
|
111
|
+
if math.isfinite(retry_after):
|
|
112
|
+
pause = max(pause, retry_after)
|
|
113
|
+
except ValueError:
|
|
114
|
+
pass
|
|
115
|
+
if attempt + 1 < attempts:
|
|
116
|
+
if on_retry is not None:
|
|
117
|
+
on_retry()
|
|
118
|
+
await asyncio.sleep(max(0.0, min(pause, deadline - time.monotonic())))
|
|
119
|
+
raise RequestExhausted(timeout, last, timed_out=timed_out)
|
jevkit_core/usage.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Validated usage and the common meter; budget policy remains with the caller."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from collections.abc import Callable, MutableMapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
|
|
9
|
+
from .backends import PRICE_PER_MTOK
|
|
10
|
+
from .errors import JevFatal
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Usage:
|
|
15
|
+
tokens: int | float
|
|
16
|
+
cost: float
|
|
17
|
+
source: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def record_usage(totals: MutableMapping, usage: Usage) -> None:
|
|
21
|
+
"""Accumulate one validated response in existing meter or report fields."""
|
|
22
|
+
totals["calls"] += 1
|
|
23
|
+
totals["input_tokens"] += usage.tokens
|
|
24
|
+
totals["cost"] += usage.cost
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_usage(
|
|
28
|
+
usage, *, price_per_mtok: float = PRICE_PER_MTOK, missing_tokens: int = 0, fractional_tokens: bool = False
|
|
29
|
+
) -> Usage:
|
|
30
|
+
usage = {} if usage is None else usage
|
|
31
|
+
if not isinstance(usage, dict):
|
|
32
|
+
raise JevFatal("invalid API usage metadata: expected an object; stopped to avoid unmetered calls")
|
|
33
|
+
tokens = usage.get("input_tokens")
|
|
34
|
+
tokens = missing_tokens if tokens is None else tokens
|
|
35
|
+
valid_types = (int, float) if fractional_tokens else (int,)
|
|
36
|
+
if isinstance(tokens, bool) or not isinstance(tokens, valid_types) or tokens < 0:
|
|
37
|
+
raise JevFatal("invalid API usage metadata: input_tokens must be a nonnegative integer")
|
|
38
|
+
cost = usage.get("cost")
|
|
39
|
+
if cost is not None and (isinstance(cost, bool) or not isinstance(cost, (int, float))):
|
|
40
|
+
raise JevFatal("invalid API usage metadata: cost must be a finite nonnegative number")
|
|
41
|
+
try:
|
|
42
|
+
metered_cost = tokens * price_per_mtok / 1e6 if cost is None else float(cost)
|
|
43
|
+
valid = math.isfinite(tokens) and math.isfinite(metered_cost) and metered_cost >= 0
|
|
44
|
+
except OverflowError as exc:
|
|
45
|
+
raise JevFatal("invalid API usage metadata: cost exceeds numeric range") from exc
|
|
46
|
+
if not valid:
|
|
47
|
+
raise JevFatal("invalid API usage metadata: cost must be a finite nonnegative number")
|
|
48
|
+
return Usage(tokens, metered_cost, "estimated_from_tokens" if cost is None else "reported_by_api")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Meter:
|
|
53
|
+
calls: int = 0
|
|
54
|
+
cached: int = 0
|
|
55
|
+
retries: int = 0
|
|
56
|
+
input_tokens: int = 0
|
|
57
|
+
cost: float = 0.0
|
|
58
|
+
model: str = ""
|
|
59
|
+
latencies: list[float] = field(default_factory=list)
|
|
60
|
+
|
|
61
|
+
def record(
|
|
62
|
+
self,
|
|
63
|
+
usage: Usage,
|
|
64
|
+
seconds: float,
|
|
65
|
+
*,
|
|
66
|
+
model: str | None = None,
|
|
67
|
+
on_cost: Callable[[float], None] | None = None,
|
|
68
|
+
) -> None:
|
|
69
|
+
"""Count the response before answer validation, including charges for invalid answers.
|
|
70
|
+
|
|
71
|
+
The request owner supplies on_cost; consumers of a shared request never
|
|
72
|
+
call it. Model fallback and tool-specific statistics remain adapter policy.
|
|
73
|
+
"""
|
|
74
|
+
record_usage(vars(self), usage)
|
|
75
|
+
if on_cost is not None:
|
|
76
|
+
on_cost(usage.cost)
|
|
77
|
+
self.latencies.append(seconds)
|
|
78
|
+
if model is not None:
|
|
79
|
+
self.model = model
|
|
80
|
+
|
|
81
|
+
def summary(self) -> str:
|
|
82
|
+
parts = [f"{self.calls:,} calls, {self.cached:,} cached"]
|
|
83
|
+
if self.retries:
|
|
84
|
+
parts.append(f"{self.retries:,} retries")
|
|
85
|
+
if self.calls:
|
|
86
|
+
parts.append(f"{self.input_tokens:,} tokens")
|
|
87
|
+
parts.append(f"${self.cost:.4f}")
|
|
88
|
+
return "; ".join(parts)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jevkit-runtime
|
|
3
|
+
Version: 0.1.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`**. Python import: **`jevkit_core`**.
|
|
19
|
+
The PyPI name `jevkit-core` belongs to a different project.
|
|
20
|
+
|
|
21
|
+
Shared provider definitions, backend configuration, HTTP transport, retries, deadlines,
|
|
22
|
+
SQLite answer storage, in-flight requests, usage accounting, and answer provenance
|
|
23
|
+
for jgrep, jsort, jlink, jselect, and jcol.
|
|
24
|
+
|
|
25
|
+
Each product remains a separate repository and package. This core imports none of them.
|
|
26
|
+
Product adapters retain prompts, cache identities, reuse policies, budget policies, and public APIs.
|
|
27
|
+
|
|
28
|
+
Version 0.1.0 is a **local development release**, not a published PyPI release.
|
|
29
|
+
Ordinary source edits in an editable core installation apply on the next run;
|
|
30
|
+
already-running Python processes need to restart.
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
Keep the six checkouts as siblings. Each consumer declares a normal versioned
|
|
35
|
+
dependency on `jevkit-runtime` and a uv development override:
|
|
36
|
+
|
|
37
|
+
```toml
|
|
38
|
+
[tool.uv.sources]
|
|
39
|
+
jevkit-runtime = { path = "../jevkit-core", editable = true }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Clone `keltokhy/jevkit-core` beside `keltokhy/jgrep`, `keltokhy/jsort`,
|
|
43
|
+
`keltokhy/jlink`, `keltokhy/jselect`, and `keltokhy/jcol`. All six repositories
|
|
44
|
+
remain independently versioned. For isolated development checkouts named
|
|
45
|
+
`jgrep-jevkit` and so on, use the `--suffix=-jevkit` option shown below.
|
|
46
|
+
|
|
47
|
+
From this directory:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
python3 scripts/dev.py --suffix=-jevkit setup
|
|
51
|
+
python3 scripts/dev.py --suffix=-jevkit check
|
|
52
|
+
python3 scripts/dev.py --suffix=-jevkit wheel-check
|
|
53
|
+
python3 scripts/dev.py --suffix=-jevkit run jgrep -- --help
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`run` uses the selected worktree's virtual environment and preserves the caller's
|
|
57
|
+
working directory. To use it with data, replace `--help` with the normal tool arguments.
|
|
58
|
+
For ordinary unsuffixed clones, omit `--suffix`. `--repos-root` selects their parent;
|
|
59
|
+
`--tool jgrep` limits setup and checks to one consumer.
|
|
60
|
+
|
|
61
|
+
`setup` installs dependencies and prepares the public tiktoken encoding files needed
|
|
62
|
+
by jselect. It performs no model inference. `check` runs each suite in its own
|
|
63
|
+
environment, strips model credentials, isolates configuration/cache directories,
|
|
64
|
+
and blocks outbound sockets while permitting local fake HTTP servers. It also proves
|
|
65
|
+
that every consumer calls the shared transport and imports this exact source tree,
|
|
66
|
+
and compares its fixture behavior to the commits in `consumer-baselines.json`.
|
|
67
|
+
Those commits must be present locally; use a full clone or fetch that history.
|
|
68
|
+
|
|
69
|
+
`wheel-check` builds the core and consumer wheels, installs each consumer alongside
|
|
70
|
+
the core wheel in a separate temporary environment, and exercises model fixtures and
|
|
71
|
+
CLI entry points outside the source trees. It also checks packaged browser/review assets
|
|
72
|
+
and jcol's installed process workflow. Dependency installation may use the package index;
|
|
73
|
+
inference checks stay offline.
|
|
74
|
+
|
|
75
|
+
## Shared boundary
|
|
76
|
+
|
|
77
|
+
| Module | Responsibility |
|
|
78
|
+
|---|---|
|
|
79
|
+
| `backends.py` | Provider catalog, credential/configuration lookup, capabilities, selection |
|
|
80
|
+
| `transport.py` | HTTP requests, total deadlines, retries, JSON/error handling |
|
|
81
|
+
| `client.py` | Client lifecycle, shared in-flight requests, transport delegation, common answer validation |
|
|
82
|
+
| `cache.py` | SQLite answer storage and atomic provenance writes, key serialization |
|
|
83
|
+
| `usage.py` | Usage validation, reported/estimated costs, meter and report accumulation |
|
|
84
|
+
| `provenance.py` | Versioned answer origins with literal or unknown responder identity |
|
|
85
|
+
| `errors.py` | Shared failure types and structured request exhaustion |
|
|
86
|
+
|
|
87
|
+
`DecisionClient` is an adapter base, not a complete standalone scoring SDK. Product
|
|
88
|
+
adapters supply their own `ask` and `_record` contracts. jselect calls the shared
|
|
89
|
+
transport directly while retaining its relevance batching, score cache, and statistics.
|
|
90
|
+
Tool-specific meter fields remain in small subclasses.
|
|
91
|
+
|
|
92
|
+
`backend_catalog` selects provider definitions in each tool's priority order and
|
|
93
|
+
accepts model overrides. The four decision clients retain moving model aliases;
|
|
94
|
+
jselect keeps its pinned defaults. The catalog also defines two opt-in local providers; tools choose whether to expose them. Adding a catalog entry does not automatically enable it in every tool.
|
|
95
|
+
|
|
96
|
+
`DecisionClient.share_request` returns a task and an ownership flag. It invokes a
|
|
97
|
+
lazy request factory only for the owner, allowing jlink's cache-only callers to
|
|
98
|
+
join existing requests and jsort to charge only the initiating caller. It removes
|
|
99
|
+
finished, failed, and cancelled tasks from the registry. Awaiting, cancellation,
|
|
100
|
+
cache identity, and jcol's hedging policy remain with the adapters; sharing does
|
|
101
|
+
not introduce cancellation shielding or change how a hedge is charged.
|
|
102
|
+
|
|
103
|
+
`Meter.record` and `record_usage` accumulate the same call/token/cost fields for
|
|
104
|
+
the four clients and jselect's dictionary reports. Meter callbacks run after
|
|
105
|
+
totals change and before latency/model updates. Cost-source labels, jsort's
|
|
106
|
+
maximum call cost, jlink's provenance summaries, and model fallback remain local.
|
|
107
|
+
`answer_provenance` constructs the shared version-1 metadata for jsort and jlink;
|
|
108
|
+
missing responder identity stays unknown rather than inheriting a requested alias.
|
|
109
|
+
|
|
110
|
+
No product imports another product, and the core imports none of them. NumPy,
|
|
111
|
+
pandas, SciPy, Polars, tokenizers, and browser dependencies stay in their owning
|
|
112
|
+
tools. HTTP/2 is an optional core extra used by jcol.
|
|
113
|
+
|
|
114
|
+
## Compatibility
|
|
115
|
+
|
|
116
|
+
- Existing prompts, CLI flags, supported backend choices, cache-key bytes, and
|
|
117
|
+
saved project/scale/index formats are retained by the adapters.
|
|
118
|
+
- Existing `~/.config/jev` and cache locations continue to work. No user cache or
|
|
119
|
+
credential files were opened or migrated during development.
|
|
120
|
+
- Legacy answer keys remain explicitly tool-owned. The shared `answer_key` function
|
|
121
|
+
offers an opt-in, versioned provider/endpoint identity for future consumers; it
|
|
122
|
+
never falls back to an ambiguous legacy entry. In particular, jlink's existing
|
|
123
|
+
cross-backend cache behavior has not silently changed.
|
|
124
|
+
- jcol checkpoints and jselect's score cache remain separate from shared answer storage.
|
|
125
|
+
- Budget meanings remain local: zero is unlimited for jgrep/jsort, cache-only for
|
|
126
|
+
jlink, and rejected by jselect's semantic scorer. jcol retains its scheduler policy.
|
|
127
|
+
- The separate experimental jgrep branch retains joint-read caching and keyless
|
|
128
|
+
local backends. The main-branch migration exposes its original three providers;
|
|
129
|
+
unrelated experiments and benchmark graphics are not part of that migration.
|
|
130
|
+
- All clients now use a true total HTTP deadline. jlink and jcol previously passed
|
|
131
|
+
the remaining time only to httpx's individual network waits.
|
|
132
|
+
- All five use validated usage parsing. Malformed, negative, nonfinite, or boolean
|
|
133
|
+
usage values fail rather than corrupting accounting; jselect retains its
|
|
134
|
+
fractional-token and missing-token estimate policy.
|
|
135
|
+
|
|
136
|
+
These last two points are deliberate hardening accompanying the extraction.
|
|
137
|
+
The adapter tests remain the source of truth for each tool's external behavior.
|
|
138
|
+
|
|
139
|
+
## Cross-repository validation
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python3 scripts/dev.py --suffix=-jevkit check
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
For a before/after request, result, and metering comparison, use a consumer's
|
|
146
|
+
environment with `scripts/probe_consumer.py TOOL --baseline-repo PATH --baseline-ref COMMIT`.
|
|
147
|
+
The probe covers cold/warm caches, repeated requests, in-flight sharing, typed
|
|
148
|
+
jcol answers, and provider definitions/defaults. It verifies shared accounting
|
|
149
|
+
for all five, request sharing for the four decision clients, and provenance
|
|
150
|
+
construction for jsort/jlink. It is a focused compatibility fixture, not an
|
|
151
|
+
exhaustive equivalence proof.
|
|
152
|
+
|
|
153
|
+
The core CI tests Python 3.10 and 3.13. The downstream workflow tests the exact
|
|
154
|
+
core revision against all five consumer main branches in separate jobs, including
|
|
155
|
+
wheel installs. Pull-request and main-push runs are enabled with the repository
|
|
156
|
+
variable `JEVKIT_CONSUMERS_READY=true` after bootstrap. Manual dispatch can select
|
|
157
|
+
a common consumer branch/tag before then. All five consumer repositories are public.
|
|
158
|
+
|
|
159
|
+
## Release sequence
|
|
160
|
+
|
|
161
|
+
The five packages retain independent releases. Their wheels contain a dependency
|
|
162
|
+
on `jevkit-runtime>=0.1.0,<0.2.0`, never a local source path; jcol requests the `http2` extra.
|
|
163
|
+
|
|
164
|
+
Before publishing any migrated consumer to PyPI:
|
|
165
|
+
|
|
166
|
+
1. Merge and tag the verified core as `v0.1.0`. Consumer CI checks out this tag
|
|
167
|
+
beside its own source, so tests do not depend on PyPI publication timing.
|
|
168
|
+
2. Configure a PyPI Trusted Publisher for `jevkit-runtime`, repository
|
|
169
|
+
`keltokhy/jevkit-core`, workflow `publish.yml`, environment `pypi`. Dispatch the
|
|
170
|
+
publish workflow with the verified release tag. Publishing is explicit; creating
|
|
171
|
+
a Git tag alone does not upload a package.
|
|
172
|
+
3. Release each consumer through its existing versioning/publishing process. Update
|
|
173
|
+
its supported core range, lockfile, and CI core reference together on upgrades.
|
|
174
|
+
|
|
175
|
+
Until the runtime distribution is available on PyPI, source development uses the
|
|
176
|
+
sibling checkout. The cross-repository wheel checks install a freshly built core
|
|
177
|
+
wheel explicitly alongside each consumer. Existing published tool versions remain
|
|
178
|
+
independent of this migration. Once the runtime is published, standalone source
|
|
179
|
+
clones can use `uv sync --no-sources`; published wheels use ordinary dependencies.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
jevkit_core/__init__.py,sha256=3JmP7EnIt2rNdIEAHSWomld17d2sN9k6te0WeOgvP54,1121
|
|
2
|
+
jevkit_core/backends.py,sha256=FaB_4Q9BcchJyYlq5nwKsN7RN0orOogXuY74Vu3BFUs,5295
|
|
3
|
+
jevkit_core/cache.py,sha256=NtCQULEM9JN3pyGGoCJEgWsj1LjrRhDnWb9PMG0kCeU,3665
|
|
4
|
+
jevkit_core/client.py,sha256=sEmoSP34FL8EVIMopWxh0RAuwLA2yGu6_D63iNhNuBI,3820
|
|
5
|
+
jevkit_core/errors.py,sha256=BzER95nTOIZIWCxouLuow3_3zhtrE_vTSZEfh9mFlLI,721
|
|
6
|
+
jevkit_core/provenance.py,sha256=CudRdmRUy49ti14lpvTSbf9T0a1POm_U4nzBOK5ic4A,614
|
|
7
|
+
jevkit_core/transport.py,sha256=X_hpAjyiEPOwcZ_bmHQhFH9KNJcOuKr4PV77tw86JSI,4277
|
|
8
|
+
jevkit_core/usage.py,sha256=osTO8nn6d3c5DHm0r2EW9Zlk1G-jfCoDpdq2QXqB6n8,3264
|
|
9
|
+
jevkit_runtime-0.1.0.dist-info/METADATA,sha256=4ffDl434VIdLCwMOXT6uvyrfp8HmtywhrzXAHjthMuo,9758
|
|
10
|
+
jevkit_runtime-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
11
|
+
jevkit_runtime-0.1.0.dist-info/licenses/LICENSE,sha256=unAu2Ii_6qZZfNfkD44Vj0MNv9C7H6CBRje23cMNx4g,1071
|
|
12
|
+
jevkit_runtime-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Khaled Eltokhy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|