avrik-server 1.0.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.
- avrik_server/__init__.py +4 -0
- avrik_server/api/__init__.py +1 -0
- avrik_server/api/__main__.py +77 -0
- avrik_server/api/app.py +143 -0
- avrik_server/api/discovery.py +129 -0
- avrik_server/api/errors.py +57 -0
- avrik_server/api/limits.py +294 -0
- avrik_server/api/ratelimit.py +158 -0
- avrik_server/api/schemas.py +164 -0
- avrik_server/backends/__init__.py +6 -0
- avrik_server/backends/base.py +102 -0
- avrik_server/backends/llamacpp.py +105 -0
- avrik_server/backends/threaded.py +56 -0
- avrik_server/backends/transformers_ref.py +125 -0
- avrik_server/backends/vllm_http.py +385 -0
- avrik_server/bench/__init__.py +6 -0
- avrik_server/bench/__main__.py +183 -0
- avrik_server/bench/core.py +263 -0
- avrik_server/bench/images.py +98 -0
- avrik_server/calibration/__init__.py +1 -0
- avrik_server/calibration/__main__.py +290 -0
- avrik_server/calibration/file.py +159 -0
- avrik_server/calibration/fit.py +247 -0
- avrik_server/config.py +212 -0
- avrik_server/engine/__init__.py +1 -0
- avrik_server/engine/corrections.py +102 -0
- avrik_server/engine/decide.py +733 -0
- avrik_server/engine/policy.py +104 -0
- avrik_server/engine/reask.py +38 -0
- avrik_server/py.typed +0 -0
- avrik_server/readout/__init__.py +8 -0
- avrik_server/readout/distribution.py +101 -0
- avrik_server/readout/enumerate.py +200 -0
- avrik_server/readout/prompt.py +211 -0
- avrik_server/readout/smoke.py +352 -0
- avrik_server-1.0.0.dist-info/METADATA +92 -0
- avrik_server-1.0.0.dist-info/RECORD +40 -0
- avrik_server-1.0.0.dist-info/WHEEL +4 -0
- avrik_server-1.0.0.dist-info/licenses/LICENSE +202 -0
- avrik_server-1.0.0.dist-info/licenses/NOTICE +10 -0
avrik_server/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The /v1/systemone HTTP service (Step 2.5)."""
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""``python -m avrik_server.api --config <server.yaml>``: serve ``/v1/systemone``.
|
|
2
|
+
|
|
3
|
+
Loads the config, the pinned tokenizer (local files only, no network), the backend and
|
|
4
|
+
the calibration file; refuses to start on a mismatched calibration; binds to the
|
|
5
|
+
configured host (loopback unless API keys are set, research R11).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from collections.abc import Sequence
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from avrik_server.config import ServerConfig, load_config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_backend(config: ServerConfig) -> Any: # pragma: no cover - loads real backends
|
|
20
|
+
"""The configured backend, async."""
|
|
21
|
+
b = config.backend
|
|
22
|
+
if b.kind == "vllm-gpu":
|
|
23
|
+
from avrik_server.backends.vllm_http import AsyncVllmHttpBackend
|
|
24
|
+
|
|
25
|
+
return AsyncVllmHttpBackend(
|
|
26
|
+
str(b.url),
|
|
27
|
+
model=b.served_model,
|
|
28
|
+
precision=b.precision,
|
|
29
|
+
timeout=b.timeout,
|
|
30
|
+
full_vocab=b.full_vocab,
|
|
31
|
+
sequential_shards=b.sequential_shards,
|
|
32
|
+
shard_size=b.shard_size,
|
|
33
|
+
)
|
|
34
|
+
from avrik_server.backends.threaded import ThreadedBackend
|
|
35
|
+
|
|
36
|
+
if b.kind == "llamacpp-cpu":
|
|
37
|
+
from avrik_server.backends.llamacpp import LlamaCppBackend
|
|
38
|
+
|
|
39
|
+
return ThreadedBackend(LlamaCppBackend(str(b.gguf), n_ctx=b.max_model_len))
|
|
40
|
+
from avrik_server.backends.transformers_ref import TransformersBackend
|
|
41
|
+
|
|
42
|
+
dtype = "float32" if b.precision == "fp32" else "bfloat16"
|
|
43
|
+
return ThreadedBackend(TransformersBackend(config.model_dir, dtype=dtype))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main(argv: Sequence[str] | None = None) -> int: # pragma: no cover - starts a server
|
|
47
|
+
"""Entry point."""
|
|
48
|
+
parser = argparse.ArgumentParser(prog="python -m avrik_server.api")
|
|
49
|
+
parser.add_argument("--config", type=Path, required=True)
|
|
50
|
+
args = parser.parse_args(argv)
|
|
51
|
+
import uvicorn
|
|
52
|
+
|
|
53
|
+
from avrik_server.api.app import create_app
|
|
54
|
+
from avrik_server.calibration.file import CalibrationError, load
|
|
55
|
+
from avrik_server.engine.decide import Engine
|
|
56
|
+
from avrik_server.readout.smoke import _hf_tokenizer
|
|
57
|
+
|
|
58
|
+
config = load_config(args.config)
|
|
59
|
+
try:
|
|
60
|
+
engine = Engine(
|
|
61
|
+
config, _hf_tokenizer(config.model_dir), build_backend(config), load(config.calibration)
|
|
62
|
+
)
|
|
63
|
+
except CalibrationError as error:
|
|
64
|
+
print(f"refused: {error}", file=sys.stderr)
|
|
65
|
+
return 3
|
|
66
|
+
uvicorn.run(
|
|
67
|
+
create_app(engine),
|
|
68
|
+
host=config.serving.host,
|
|
69
|
+
port=config.serving.port,
|
|
70
|
+
log_level="info",
|
|
71
|
+
access_log=False,
|
|
72
|
+
)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__": # pragma: no cover
|
|
77
|
+
sys.exit(main())
|
avrik_server/api/app.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""The HTTP service (build guide Step 2.5; contracts/systemone-api.md; research R11).
|
|
2
|
+
|
|
3
|
+
``create_app(engine)`` builds the ASGI app around an ``Engine``:
|
|
4
|
+
|
|
5
|
+
* ``POST /v1/systemone``: auth (when keys are set), per-key buckets, the bounded decide
|
|
6
|
+
queue, then the raw body is size-checked (413), parsed (400) and handed to the engine,
|
|
7
|
+
whose refusals become stable JSON errors;
|
|
8
|
+
* ``GET /health``, ``/v1/models``, ``/openapi.json``, ``/llms.txt``: public discovery;
|
|
9
|
+
* every response carries the RateLimit headers; unknown paths answer a JSON 404.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Awaitable, Callable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from fastapi import FastAPI, Request, Response
|
|
21
|
+
from fastapi.responses import JSONResponse, PlainTextResponse
|
|
22
|
+
from starlette.exceptions import HTTPException as StarletteHTTPException
|
|
23
|
+
|
|
24
|
+
from avrik_server.api import discovery
|
|
25
|
+
from avrik_server.api.errors import ApiError
|
|
26
|
+
from avrik_server.api.ratelimit import Limiter, RetryAfter
|
|
27
|
+
from avrik_server.engine.decide import Engine
|
|
28
|
+
|
|
29
|
+
REQUEST_ID = re.compile(r"[A-Za-z0-9._-]{1,64}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def read_capped(request: Request, limit: int) -> bytes:
|
|
33
|
+
"""The body, refused as soon as it passes ``limit`` bytes (chunked bodies too; T056 B1).
|
|
34
|
+
|
|
35
|
+
Raises:
|
|
36
|
+
ApiError: ``body_too_large``.
|
|
37
|
+
"""
|
|
38
|
+
chunks: list[bytes] = []
|
|
39
|
+
size = 0
|
|
40
|
+
async for chunk in request.stream():
|
|
41
|
+
size += len(chunk)
|
|
42
|
+
if size > limit:
|
|
43
|
+
raise ApiError("body_too_large", f"bodies are limited to {limit} bytes")
|
|
44
|
+
chunks.append(chunk)
|
|
45
|
+
return b"".join(chunks)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_app(engine: Engine, limiter: Limiter | None = None) -> FastAPI:
|
|
49
|
+
"""The ASGI app serving ``engine``."""
|
|
50
|
+
app = FastAPI(
|
|
51
|
+
title="Avrik decision API",
|
|
52
|
+
version="1.0.0",
|
|
53
|
+
openapi_url=None,
|
|
54
|
+
docs_url=None,
|
|
55
|
+
redoc_url=None,
|
|
56
|
+
)
|
|
57
|
+
app.state.engine = engine
|
|
58
|
+
limits = limiter or Limiter(engine.config.serving)
|
|
59
|
+
max_body = engine.config.limits.max_body_bytes
|
|
60
|
+
|
|
61
|
+
def error_response(error: ApiError, who: str | None = None) -> JSONResponse:
|
|
62
|
+
response = JSONResponse(error.body(), status_code=error.status)
|
|
63
|
+
if isinstance(error, RetryAfter):
|
|
64
|
+
response.headers["Retry-After"] = str(error.retry_after)
|
|
65
|
+
response.headers.update(limits.headers(who))
|
|
66
|
+
return response
|
|
67
|
+
|
|
68
|
+
@app.exception_handler(ApiError)
|
|
69
|
+
async def api_error(_: Request, error: ApiError) -> JSONResponse:
|
|
70
|
+
return error_response(error)
|
|
71
|
+
|
|
72
|
+
@app.exception_handler(StarletteHTTPException)
|
|
73
|
+
async def http_error(_: Request, error: StarletteHTTPException) -> JSONResponse:
|
|
74
|
+
code = "not_found" if error.status_code == 404 else "invalid_request"
|
|
75
|
+
body = {"error": {"code": code, "message": str(error.detail), "field": None}}
|
|
76
|
+
response = JSONResponse(body, status_code=error.status_code)
|
|
77
|
+
response.headers.update(limits.headers(None))
|
|
78
|
+
return response
|
|
79
|
+
|
|
80
|
+
@app.middleware("http")
|
|
81
|
+
async def rate_headers(
|
|
82
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
83
|
+
) -> Response:
|
|
84
|
+
response = await call_next(request)
|
|
85
|
+
for name, value in limits.headers(getattr(request.state, "who", None)).items():
|
|
86
|
+
response.headers.setdefault(name, value)
|
|
87
|
+
given = request.headers.get("x-request-id", "")
|
|
88
|
+
safe = bool(REQUEST_ID.fullmatch(given)) # echo a client id only if it is plain
|
|
89
|
+
response.headers.setdefault("X-Request-Id", given if safe else uuid.uuid4().hex)
|
|
90
|
+
return response
|
|
91
|
+
|
|
92
|
+
@app.post("/v1/systemone")
|
|
93
|
+
async def systemone(request: Request) -> JSONResponse:
|
|
94
|
+
client = request.client.host if request.client else "unknown"
|
|
95
|
+
who = limits.identify(request.headers.get("authorization"), client)
|
|
96
|
+
request.state.who = who
|
|
97
|
+
try:
|
|
98
|
+
limits.charge(who)
|
|
99
|
+
except ApiError as error:
|
|
100
|
+
return error_response(error, who)
|
|
101
|
+
declared = request.headers.get("content-length")
|
|
102
|
+
if declared is not None and declared.isdigit() and int(declared) > max_body:
|
|
103
|
+
raise ApiError("body_too_large", f"bodies are limited to {max_body} bytes")
|
|
104
|
+
raw = await read_capped(request, max_body)
|
|
105
|
+
try:
|
|
106
|
+
body: Any = json.loads(raw)
|
|
107
|
+
except ValueError as error:
|
|
108
|
+
reason = getattr(error, "msg", "undecodable bytes")
|
|
109
|
+
raise ApiError("bad_json", f"the body is not valid JSON: {reason}") from error
|
|
110
|
+
try:
|
|
111
|
+
await limits.acquire()
|
|
112
|
+
except ApiError as error:
|
|
113
|
+
return error_response(error, who)
|
|
114
|
+
try:
|
|
115
|
+
result = await engine.decide(body, tenant=who)
|
|
116
|
+
finally:
|
|
117
|
+
limits.release()
|
|
118
|
+
response = JSONResponse(result)
|
|
119
|
+
response.headers.update(limits.headers(who))
|
|
120
|
+
return response
|
|
121
|
+
|
|
122
|
+
@app.get("/health")
|
|
123
|
+
async def health() -> JSONResponse:
|
|
124
|
+
ok = await engine.backend.health()
|
|
125
|
+
return JSONResponse(discovery.health(engine, ok), status_code=200 if ok else 503)
|
|
126
|
+
|
|
127
|
+
@app.get("/v1/models")
|
|
128
|
+
async def models() -> JSONResponse:
|
|
129
|
+
return JSONResponse(discovery.models(engine))
|
|
130
|
+
|
|
131
|
+
spec: dict[str, Any] = {}
|
|
132
|
+
|
|
133
|
+
@app.get("/openapi.json")
|
|
134
|
+
async def openapi() -> JSONResponse:
|
|
135
|
+
if not spec: # built once (T056 N1)
|
|
136
|
+
spec.update(discovery.openapi(app))
|
|
137
|
+
return JSONResponse(spec)
|
|
138
|
+
|
|
139
|
+
@app.get("/llms.txt")
|
|
140
|
+
async def llms() -> PlainTextResponse:
|
|
141
|
+
return PlainTextResponse(discovery.LLMS_TXT)
|
|
142
|
+
|
|
143
|
+
return app
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Discovery: ``/health``, ``/v1/models``, ``/openapi.json`` and ``/llms.txt`` (FR-019).
|
|
2
|
+
|
|
3
|
+
``/v1/models`` keeps Jev's ``{"models": [{name, description, release_date}]}`` shape with
|
|
4
|
+
an additive ``avrik`` object; ``/health`` and ``/v1/models`` report the same hashes the
|
|
5
|
+
audit block carries. The OpenAPI document (3.1) gets the request schema injected, since
|
|
6
|
+
the route reads the raw body to keep stable error codes.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI
|
|
14
|
+
|
|
15
|
+
from avrik_server.api.schemas import ErrorBody, SystemOneRequest, SystemOneResponse
|
|
16
|
+
from avrik_server.engine.decide import Engine
|
|
17
|
+
|
|
18
|
+
LLMS_TXT = """# Avrik decision API
|
|
19
|
+
|
|
20
|
+
> One forward pass, no generated text: state + typed questions in, calibrated
|
|
21
|
+
> probability distributions out. Shape-compatible with TypeSafe's /v1/systemone.
|
|
22
|
+
|
|
23
|
+
## Endpoint
|
|
24
|
+
|
|
25
|
+
POST /v1/systemone with {"model", "state", "questions"}.
|
|
26
|
+
- state: text, a JSON object, or an array; a batch is an array of {id, text, questions}.
|
|
27
|
+
- questions: {name: {type, instructions, criteria}}; types noul (yes/no), choice
|
|
28
|
+
(2 to max_options options), score (2-10 levels); Avrik extension multi_label
|
|
29
|
+
({labels: [...]}, one yes/no per label, default threshold 0.7).
|
|
30
|
+
- Optional Avrik fields: abstain_option per question (off by default), options
|
|
31
|
+
{near_tie_margin, near_tie_reask, correction}, images (base64, when enabled).
|
|
32
|
+
|
|
33
|
+
## Answers
|
|
34
|
+
|
|
35
|
+
Jev fields per type (choice + probabilities + confidence; noul; score + legend), plus
|
|
36
|
+
top, margin, abstain, avrik_confidence, unknown_probability (abstain offered) and an
|
|
37
|
+
eight-field audit block (model, adapter, calibration, template, allowed set, input
|
|
38
|
+
tokens, backend, precision).
|
|
39
|
+
|
|
40
|
+
Confidence measures which offered option fits best, not whether any fits: offer a
|
|
41
|
+
none-of-the-above option when that can be the answer.
|
|
42
|
+
|
|
43
|
+
## Discovery
|
|
44
|
+
|
|
45
|
+
GET /v1/models (public), GET /health, GET /openapi.json.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def models(engine: Engine) -> dict[str, Any]:
|
|
50
|
+
"""The ``/v1/models`` body."""
|
|
51
|
+
config = engine.config
|
|
52
|
+
return {
|
|
53
|
+
"models": [
|
|
54
|
+
{
|
|
55
|
+
"name": config.model_name,
|
|
56
|
+
"description": config.description or f"Frozen {config.pin['repo']}",
|
|
57
|
+
"release_date": config.release_date or None,
|
|
58
|
+
"avrik": {
|
|
59
|
+
"model_hash": engine.model_hash,
|
|
60
|
+
"adapter_hash": engine.adapter_hash,
|
|
61
|
+
"calibration_version": engine.calibration.version,
|
|
62
|
+
"prompt_template_sha256": engine.template,
|
|
63
|
+
"backend": engine.backend.name,
|
|
64
|
+
"precision": engine.backend.precision,
|
|
65
|
+
"max_options": engine.max_options,
|
|
66
|
+
"max_questions": config.limits.max_questions,
|
|
67
|
+
"max_context_tokens": config.backend.max_model_len - 1,
|
|
68
|
+
"images": config.images,
|
|
69
|
+
},
|
|
70
|
+
}
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def health(engine: Engine, ok: bool) -> dict[str, Any]:
|
|
76
|
+
"""The ``/health`` body."""
|
|
77
|
+
if not ok:
|
|
78
|
+
return {"status": "backend_unavailable"}
|
|
79
|
+
return {
|
|
80
|
+
"status": "ok",
|
|
81
|
+
"model_hash": engine.model_hash,
|
|
82
|
+
"calibration_version": engine.calibration.version,
|
|
83
|
+
"prompt_template_sha256": engine.template,
|
|
84
|
+
"backend": engine.backend.name,
|
|
85
|
+
"precision": engine.backend.precision,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def openapi(app: FastAPI) -> dict[str, Any]:
|
|
90
|
+
"""OpenAPI 3.1 with the ``/v1/systemone`` request and response schemas."""
|
|
91
|
+
from fastapi.openapi.utils import get_openapi
|
|
92
|
+
|
|
93
|
+
spec = get_openapi(title=app.title, version=app.version, routes=app.routes)
|
|
94
|
+
components = spec.setdefault("components", {}).setdefault("schemas", {})
|
|
95
|
+
for model in (SystemOneRequest, SystemOneResponse, ErrorBody):
|
|
96
|
+
schema = model.model_json_schema(ref_template="#/components/schemas/{model}")
|
|
97
|
+
components.update(schema.pop("$defs", {}))
|
|
98
|
+
components[model.__name__] = schema
|
|
99
|
+
post = spec["paths"]["/v1/systemone"]["post"]
|
|
100
|
+
post["requestBody"] = {
|
|
101
|
+
"required": True,
|
|
102
|
+
"content": {
|
|
103
|
+
"application/json": {"schema": {"$ref": "#/components/schemas/SystemOneRequest"}}
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
error = {
|
|
107
|
+
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ErrorBody"}}}
|
|
108
|
+
}
|
|
109
|
+
post["responses"] = {
|
|
110
|
+
"200": {
|
|
111
|
+
"description": "Answers",
|
|
112
|
+
"content": {
|
|
113
|
+
"application/json": {"schema": {"$ref": "#/components/schemas/SystemOneResponse"}}
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
**{
|
|
117
|
+
code: {"description": text, **error}
|
|
118
|
+
for code, text in (
|
|
119
|
+
("400", "Not JSON"),
|
|
120
|
+
("401", "Bad key"),
|
|
121
|
+
("413", "Body too large"),
|
|
122
|
+
("422", "Refused, with a stable code"),
|
|
123
|
+
("429", "Rate limited or at capacity"),
|
|
124
|
+
("502", "Backend error"),
|
|
125
|
+
("503", "Backend unavailable"),
|
|
126
|
+
)
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
return spec
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Stable error codes and the error body (contracts/systemone-api.md "Errors").
|
|
2
|
+
|
|
3
|
+
Every refusal is ``{"error": {"code", "message", "field"}}`` with a code from the
|
|
4
|
+
systemone-contract skill (plus ``invalid_request``, ``readout_mass``, ``backend_error``,
|
|
5
|
+
``body_too_large``, ``bad_json`` and ``not_found`` for cases the skill's list does not
|
|
6
|
+
name). Capacity messages carry the Decision Index's markers ("options per choice",
|
|
7
|
+
"maximum context length") so its http engine counts them as unsupported, not failed.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
STATUS: dict[str, int] = {
|
|
15
|
+
"bad_json": 400,
|
|
16
|
+
"unauthorized": 401,
|
|
17
|
+
"not_found": 404,
|
|
18
|
+
"body_too_large": 413,
|
|
19
|
+
"invalid_request": 422,
|
|
20
|
+
"too_few_options": 422,
|
|
21
|
+
"too_many_options": 422,
|
|
22
|
+
"context_too_large": 422,
|
|
23
|
+
"too_many_questions": 422,
|
|
24
|
+
"bad_question_type": 422,
|
|
25
|
+
"images_unsupported": 422,
|
|
26
|
+
"image_too_large": 422,
|
|
27
|
+
"model_unavailable": 422,
|
|
28
|
+
"readout_mass": 422,
|
|
29
|
+
"rate_limit_minute": 429,
|
|
30
|
+
"rate_limit_day": 429,
|
|
31
|
+
"request_spending_limit": 429,
|
|
32
|
+
"backend_error": 502,
|
|
33
|
+
"backend_unavailable": 503,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ApiError(Exception):
|
|
38
|
+
"""A refusal with a stable code.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
code: A key of ``STATUS``.
|
|
42
|
+
message: Human-readable reason (never request content beyond field names).
|
|
43
|
+
field: JSON path of the offending field, if any.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, code: str, message: str, field: str | None = None) -> None:
|
|
47
|
+
if code not in STATUS:
|
|
48
|
+
raise ValueError(f"unknown error code {code!r}")
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.code = code
|
|
51
|
+
self.message = message
|
|
52
|
+
self.field = field
|
|
53
|
+
self.status = STATUS[code]
|
|
54
|
+
|
|
55
|
+
def body(self) -> dict[str, Any]:
|
|
56
|
+
"""The JSON error body."""
|
|
57
|
+
return {"error": {"code": self.code, "message": self.message, "field": self.field}}
|