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.
Files changed (40) hide show
  1. avrik_server/__init__.py +4 -0
  2. avrik_server/api/__init__.py +1 -0
  3. avrik_server/api/__main__.py +77 -0
  4. avrik_server/api/app.py +143 -0
  5. avrik_server/api/discovery.py +129 -0
  6. avrik_server/api/errors.py +57 -0
  7. avrik_server/api/limits.py +294 -0
  8. avrik_server/api/ratelimit.py +158 -0
  9. avrik_server/api/schemas.py +164 -0
  10. avrik_server/backends/__init__.py +6 -0
  11. avrik_server/backends/base.py +102 -0
  12. avrik_server/backends/llamacpp.py +105 -0
  13. avrik_server/backends/threaded.py +56 -0
  14. avrik_server/backends/transformers_ref.py +125 -0
  15. avrik_server/backends/vllm_http.py +385 -0
  16. avrik_server/bench/__init__.py +6 -0
  17. avrik_server/bench/__main__.py +183 -0
  18. avrik_server/bench/core.py +263 -0
  19. avrik_server/bench/images.py +98 -0
  20. avrik_server/calibration/__init__.py +1 -0
  21. avrik_server/calibration/__main__.py +290 -0
  22. avrik_server/calibration/file.py +159 -0
  23. avrik_server/calibration/fit.py +247 -0
  24. avrik_server/config.py +212 -0
  25. avrik_server/engine/__init__.py +1 -0
  26. avrik_server/engine/corrections.py +102 -0
  27. avrik_server/engine/decide.py +733 -0
  28. avrik_server/engine/policy.py +104 -0
  29. avrik_server/engine/reask.py +38 -0
  30. avrik_server/py.typed +0 -0
  31. avrik_server/readout/__init__.py +8 -0
  32. avrik_server/readout/distribution.py +101 -0
  33. avrik_server/readout/enumerate.py +200 -0
  34. avrik_server/readout/prompt.py +211 -0
  35. avrik_server/readout/smoke.py +352 -0
  36. avrik_server-1.0.0.dist-info/METADATA +92 -0
  37. avrik_server-1.0.0.dist-info/RECORD +40 -0
  38. avrik_server-1.0.0.dist-info/WHEEL +4 -0
  39. avrik_server-1.0.0.dist-info/licenses/LICENSE +202 -0
  40. avrik_server-1.0.0.dist-info/licenses/NOTICE +10 -0
@@ -0,0 +1,4 @@
1
+ """Avrik server: readout, calibration, /v1/systemone API and inference backends.
2
+
3
+ Skeleton only; see server/ARCHITECTURE.md.
4
+ """
@@ -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())
@@ -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}}