Code reference¶
This page is generated from the source docstrings at build time (mkdocstrings + griffe, static analysis — no imports are executed). They can't drift from the code, because they are the code. This is a curated tour of the modules worth knowing; the full source is the ultimate reference.
Note
If a symbol below has thin documentation, the fix is to improve its docstring in the source — not to edit this page. The page regenerates on the next build.
Domain — errors & pure types¶
The dependency-free core. Every exception a service raises is one of these; the
boundary handler in adapta/api/app.py is the only place they're serialized.
Typed error taxonomy for Adapta.
Rules enforced at the boundary (adapta/api/app.py):
- Code raises DomainError subclasses — never HTTPException, never str(e).
- internal_detail is logged server-side and never serialized to clients.
- Every error response carries a correlation ID stamped by middleware.
- make check-leaks CI gate prevents reintroducing raw str(e) to clients.
DomainError
dataclass
¶
Bases: Exception
Base class for all application errors.
Fields
message: Safe, human-readable text sent to the client. code: Stable machine-readable error code (snake_case). status: HTTP status code for the boundary handler. internal_detail: Diagnostic text logged server-side only — never sent to clients.
The boundary handler in adapta/api/app.py is the only place that
translates these into HTTP responses. All service code raises subclasses;
nothing outside app.py should catch and re-raise as HTTPException.
Source code in adapta/domain/errors.py
InvalidRequest
dataclass
¶
Bases: DomainError
The client sent a semantically invalid value (HTTP 400).
Use this for domain-level validation failures — e.g. a password that is too short, an unsupported file type, or a conflicting state transition. Structural schema failures (missing fields, wrong types) are handled by FastAPI's RequestValidationError and produce 422 automatically.
Source code in adapta/domain/errors.py
Unauthorized
dataclass
¶
Bases: DomainError
The request is missing valid credentials (HTTP 401).
Tells clients to authenticate; do not use when the caller is authenticated but lacks rights — use Forbidden instead.
Source code in adapta/domain/errors.py
Forbidden
dataclass
¶
Bases: DomainError
Authenticated caller lacks permission for this resource (HTTP 403).
Distinct from Unauthorized: the token is valid, but the role or team membership check failed. Returning 401 here would wrongly prompt clients to re-authenticate.
Source code in adapta/domain/errors.py
NotFound
dataclass
¶
Bases: DomainError
A requested resource does not exist or is not accessible (HTTP 404).
Source code in adapta/domain/errors.py
Conflict
dataclass
¶
Bases: DomainError
The request conflicts with existing state (HTTP 409).
Examples: registering a duplicate email, creating a second endpoint for a project that already has one.
Source code in adapta/domain/errors.py
ModelNotFound
dataclass
¶
Bases: NotFound
A referenced base model is not in the catalog or its GGUF is absent (HTTP 404).
Distinct from plain NotFound so callers can differentiate a missing model from a missing project or file.
Source code in adapta/domain/errors.py
ProjectNotFound
dataclass
¶
Bases: NotFound
The referenced project does not exist or the caller's team does not own it.
Source code in adapta/domain/errors.py
TrainingFailed
dataclass
¶
Bases: DomainError
A training job failed for a non-eval reason (HTTP 500).
Examples: OOM during QLoRA, broken dataset file on disk, adapter conversion subprocess crash. Distinct from EvalGateFailed (the job ran to completion but the adapter did not clear the threshold).
Source code in adapta/domain/errors.py
InferenceFailed
dataclass
¶
Bases: DomainError
The llama-cpp or vLLM inference call raised an unexpected exception (HTTP 500).
Source code in adapta/domain/errors.py
AdapterConversionFailed
dataclass
¶
Bases: DomainError
PEFT→GGUF LoRA conversion failed; the adapter cannot be served (HTTP 500).
Source code in adapta/domain/errors.py
EmbeddingFailed
dataclass
¶
Bases: DomainError
sentence-transformers embedding or cross-encoder reranking call failed (HTTP 500).
Raised from both EmbeddingService and RerankerService so they share the same HTTP surface.
Source code in adapta/domain/errors.py
EvalGateFailed
dataclass
¶
Bases: DomainError
A trained adapter did not clear the eval threshold (HTTP 422).
HTTP 422 signals a business-rule rejection, not a server error: the job ran successfully, but the resulting adapter is not good enough to serve. The caller may inspect eval_score / eval_metrics on the job, improve the dataset, and re-train.
Source code in adapta/domain/errors.py
RateLimited
dataclass
¶
Bases: DomainError
Too many requests from this client (HTTP 429).
Source code in adapta/domain/errors.py
Timeout
dataclass
¶
Bases: DomainError
An upstream operation (Chroma retrieval, inference) exceeded its deadline (HTTP 504).
504 Gateway Timeout is used rather than 408 Request Timeout because the deadline belongs to the server's call to a downstream component, not to the client's request delivery.
Source code in adapta/domain/errors.py
InternalError
dataclass
¶
Bases: DomainError
A named raising point for unexpected server-side conditions (HTTP 500).
Prefer a more specific subclass when the failure mode is known. This
class exists so services can write raise InternalError(...) to make
intent explicit, rather than instantiating the base DomainError directly.
Source code in adapta/domain/errors.py
Configuration¶
All settings (DB/Redis URLs, JWT secret, directories, thresholds, the eval-gate knobs) flow through one pydantic-settings object.
Application configuration via pydantic-settings. All env vars are prefixed ADAPTA_ (e.g. ADAPTA_DATABASE_URL).
Settings ¶
Bases: BaseSettings
Source code in adapta/config.py
11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | |
Services — business logic¶
Chat (the serving path)¶
The single real serving path: RAG retrieval + context fitting + inference + usage metering. See the request lifecycle.
Single serving path for RAG and fine-tune (LoRA) endpoints.
Composes retrieval, context fitting, and inference. Does NOT modify
adapta/core/inference.py or adapta/core/model_manager.py internals —
those are called via their public interfaces only (hard constraint #1 in CLAUDE.md).
flatten_text ¶
The text of a message whose content may be a string or a parts array. Used for RAG queries and text-path serving (image parts contribute nothing).
Source code in adapta/services/chat.py
has_image_parts ¶
True if any message carries an image_url content-part. The router uses this BEFORE building a StreamingResponse — a typed error must be raised pre-stream, not mid-stream.
Source code in adapta/services/chat.py
validate_image_parts ¶
Validate every image content-part (count cap, data-URL form, base64, size, decodable image, dimension cap) and return the total estimated image token cost. Raises typed 422s — image input must never 500 (§V4.2).
Source code in adapta/services/chat.py
chat
async
¶
chat(
*,
model_name: str,
messages: List[dict],
system_prompt: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
top_p: Optional[float] = None,
stream: bool = False,
project_id: Optional[str] = None,
top_k_rag: Optional[int] = None,
adapter_path: Optional[str] = None
) -> dict
Non-streaming chat completion. Returns an OpenAI-compatible response dict with optional citations.
Source code in adapta/services/chat.py
240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 | |
chat_stream
async
¶
chat_stream(
*,
model_name: str,
messages: List[dict],
system_prompt: Optional[str] = None,
temperature: Optional[float] = None,
max_tokens: Optional[int] = None,
project_id: Optional[str] = None,
top_k_rag: Optional[int] = None,
endpoint_id: Optional[str] = None,
adapter_path: Optional[str] = None
) -> AsyncIterator[str]
Streaming chat — yields SSE-formatted strings. RAG context is injected before streaming starts (non-streaming retrieval).
Source code in adapta/services/chat.py
448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 | |
RAG (retrieval)¶
RAG service: per-project ChromaDB collections + hybrid cited retrieval.
Retrieval pipeline (D5): 1. Vector search — fetch top_k × multiplier candidates from Chroma. 2. BM25 score — keyword signal over the same candidates. 3. Reciprocal Rank Fusion — merge the two ranked lists. 4. Optional cross-encoder rerank over the top candidates. 5. Return top_k chunks, best-first (§A4.3 pop() invariant preserved).
RAGService ¶
Per-project ChromaDB collection management and hybrid cited retrieval.
A new ChromaDB HTTP client is created on every call (_client() →
_get_chroma_client()); there is no persistent connection held by
this object.
Source code in adapta/services/rag.py
113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 | |
ensure_collection ¶
Create the Chroma collection if it doesn't exist; returns the name.
Sets hnsw:space: cosine so the index uses cosine distance. This
metadata is applied only at creation — Chroma ignores it on
get_or_create_collection calls for an existing collection, so the
distance metric is locked in after the first call.
Source code in adapta/services/rag.py
delete_collection ¶
Best-effort cleanup; silently swallows all errors.
Called on project delete — a Chroma outage or already-absent collection must not block the project row from being removed from Postgres.
Source code in adapta/services/rag.py
index_chunks ¶
Embed and upsert chunks into the project collection; returns count.
Uses upsert so re-indexing a file is idempotent — existing chunks
for the same file_id are overwritten rather than duplicated.
Source code in adapta/services/rag.py
delete_file_chunks ¶
Remove all chunks for a file from the Chroma collection.
Two-step (get then delete) because Chroma's delete requires
explicit IDs rather than accepting a where filter directly.
Source code in adapta/services/rag.py
retrieve ¶
Hybrid retrieval: vector + BM25 fused via RRF, optional cross-encoder reranker.
Pipeline (D5): 1. Fetch fetch_k = top_k × multiplier candidates via Chroma vector search. 2. BM25-score those candidates against the query. 3. Reciprocal Rank Fusion of vector order + BM25 order. 4. Optional cross-encoder reranker over the top rerank_n candidates. 5. Return top_k, best-first (§A4.3 pop() invariant preserved).
Citations reflect the final reranked scores (§A4.3 ordering).
Source code in adapta/services/rag.py
196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | |
build_context_block ¶
Format retrieved chunks into a numbered context block for the system prompt.
Produces the [N] (source: X)\ntext format that the system-prompt
template in chat.py references with "Cite sources by their [N]
number." The numbers here must stay consistent with the indices in
format_citations — both iterate in the same order.
Source code in adapta/services/rag.py
collection_name_for ¶
Return the Chroma collection name for a project.
The convention proj_{project_id} is the value stored in
Collection.chroma_collection_name. Changing this function without a
migration would orphan all existing collections.
Source code in adapta/services/rag.py
get_rag_service ¶
Return the process-level RAGService singleton.
Initialization is not protected by a lock. In asyncio this is safe
because coroutines yield at await points — the first call sets
_rag_service before any other coroutine reaches this function.
Source code in adapta/services/rag.py
Embeddings¶
sentence-transformers embedding and cross-encoder reranking services.
Both services are lazily loaded on first use so the model weights are not
downloaded at import time — only when an actual embed/rerank call is made.
Both are cached as process-level singletons via lru_cache; the first
call's model name is locked in for the lifetime of the process, so a config
change requires a restart.
EmbeddingService ¶
Bi-encoder sentence-transformers model, lazily loaded on first use.
Not thread-safe at load time: if two coroutines race the first call
before the model is loaded, both see _model is None and both load.
In practice the asyncio event loop serializes the first call, but the
double-load is harmless (the second assignment overwrites with an
identical model object).
Source code in adapta/services/embeddings.py
dimension
property
¶
Embedding vector dimension. Triggers model load on first access.
embed ¶
Embed a batch of strings. Returns one float vector per input.
Source code in adapta/services/embeddings.py
embed_one ¶
Embed a single string. Equivalent to embed([text])[0].
Not a separate model call — just a convenience wrapper around embed.
Use embed directly for batches to avoid per-call overhead.
Source code in adapta/services/embeddings.py
RerankerService ¶
Cross-encoder reranker, lazily loaded on first use (D5).
A cross-encoder takes (query, passage) pairs and scores them jointly —
unlike bi-encoders (EmbeddingService) which encode query and passage
independently. Scores are raw logits (unbounded, may be negative) and are
not comparable to cosine-similarity scores; callers that expose them should
normalize first (e.g. via sigmoid — see rag.py).
Source code in adapta/services/embeddings.py
rerank ¶
Score each passage against the query. Returns raw logits (higher = more relevant).
Scores are unbounded floats from the cross-encoder — NOT cosine
similarities. Apply sigmoid to normalize to (0, 1) before
exposing them in API responses.
Source code in adapta/services/embeddings.py
get_embedding_service
cached
¶
Return the process-level EmbeddingService singleton.
lru_cache(maxsize=1) means the first call determines the model name
for the process lifetime; subsequent calls return the cached instance
regardless of any config change. Restart the process to pick up a new
ADAPTA_EMBEDDING_MODEL.
Source code in adapta/services/embeddings.py
get_reranker_service
cached
¶
Return the process-level RerankerService singleton, or None if disabled.
Returns None when ADAPTA_RAG_RERANKER_MODEL is empty — this disables
reranking in rag.py and falls back to RRF-fused vector+BM25 order.
Same lru_cache process-lifetime lock-in as get_embedding_service.
Source code in adapta/services/embeddings.py
Training (validation & enqueue)¶
Training coordination service. Handles dataset validation and job enqueuing; actual training runs in the worker.
BundleError ¶
Bases: ValueError
A dataset .zip bundle that cannot be safely extracted (zip-slip, caps, disallowed content). The message is operator-safe.
extract_bundle ¶
Safely extract a dataset bundle (§V2.1) and return the manifest path.
A bundle is a .zip holding exactly one *.jsonl manifest at its root and
the images the manifest references (ALLOWED_IMAGE_EXTENSIONS). Hostile
archives are rejected before any byte is written: path traversal (zip-slip),
absolute paths, symlinks, over-cap file counts / uncompressed size, and
disallowed file types all raise :class:BundleError.
Source code in adapta/services/training.py
validate_dataset ¶
validate_dataset(
path: Path, bundle_dir: Optional[Path] = None
) -> tuple[bool, Optional[str], int, int]
Validate a JSONL file against the training dataset schema.
Returns (is_valid, error_message, num_samples, num_images).
Accepts both SFT rows (prompt + response) and DPO preference rows (prompt + chosen + rejected). Rows within a single dataset must all be the same type — mixing SFT and DPO rows is rejected.
Validation does not stop at the first bad row: it scans the whole file
and reports up to :data:MAX_REPORTED_ERRORS problems at once, so an
operator fixing a large bundle sees every issue in one pass instead of
discovering them one re-upload at a time.
Source code in adapta/services/training.py
update_job_record
async
¶
update_job_record(
job_id: str,
*,
status: Optional[str] = None,
progress: Optional[float] = None,
logs: Optional[str] = None,
adapter_path: Optional[str] = None,
eval_score: Optional[float] = None,
eval_passed: Optional[bool] = None,
eval_metrics: Optional[str] = None,
error: Optional[str] = None
) -> None
Persist a training job's lifecycle to Postgres (the source of truth the API and the endpoint-creation gate read).
The Redis JobQueue.update_status only updates live progress; without this the
Postgres row stayed at queued forever, so a finished job never looked terminal
to GET /jobs/{id} and a passed adapter could never bind an endpoint.
Source code in adapta/services/training.py
validate_training_config ¶
Reject out-of-range hyperparameters before a job is enqueued (A4.12).
Source code in adapta/services/training.py
check_min_training_samples ¶
Reject a too-small dataset before a job is created (A4.6).
Below settings.min_training_samples the held-out eval split collapses
(e.g. 1 row → 0 held out → the gate scores the training rows), so the eval
gate would only measure memorization. Raising here keeps that signal honest.
Source code in adapta/services/training.py
recover_orphaned_jobs
async
¶
Reconcile jobs a crashed worker left behind (A4.2). Returns (requeued, failed).
A hard crash (OOM-kill, power loss, segfault) pops a job off the Redis queue
but never drives it to a terminal state — so it sits running in Postgres
forever with no live worker. A failed enqueue (or a wiped Redis) can likewise
leave a job queued in Postgres but absent from the queue. On worker startup
we find both and either requeue them (reconstructing the payload from Postgres,
so it survives a wiped Redis) or fail them once they've burned their retries.
Single-worker assumption: at startup no other worker is live, so any running
job is orphaned. Run this before the dequeue loop.
Source code in adapta/services/training.py
399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 | |
enqueue_training_job
async
¶
enqueue_training_job(
db: AsyncSession,
project_id: str,
dataset_id: str,
base_model: str,
training_config: Optional[dict] = None,
method: str = "sft",
) -> TrainingJob
Create a TrainingJob record and push it to the Redis queue.
Source code in adapta/services/training.py
500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 | |
Adapters (registry + eval gate)¶
Adapter registry and eval gate.
An adapter must pass the eval gate before it can back a serving endpoint. The registry is a JSON file on disk (not in the DB) so the worker can write it without opening a DB connection during the training run.
AdapterRegistry ¶
Filesystem-based adapter registry (JSON file in the adapters directory).
Each entry: adapter_id, project_id, job_id, path, eval_score, base_model. Writes are not concurrent-safe — only one worker process ever writes.
Source code in adapta/services/adapters.py
register ¶
register(
adapter_id: str,
project_id: str,
job_id: str,
adapter_path: str,
eval_score: float,
base_model: str,
adapter_gguf_path: Optional[str] = None,
base_score: Optional[float] = None,
score_delta: Optional[float] = None,
) -> dict
Register an adapter after it passes the eval gate.
Raises EvalGateFailed if it passes neither the absolute nor the improvement
path (see passes_eval_gate).
adapter_path is the PEFT directory (safetensors); adapter_gguf_path
is the converted GGUF LoRA the llama-cpp serving runtime loads.
base_score/score_delta (adapter-vs-base on the held-out split) enable
the improvement pass path.
Source code in adapta/services/adapters.py
Training core — the eval gate¶
The gate's score math and the dual (absolute-or-improvement) gate live here. See the eval gate explained.
Training data models: config, eval metrics, and the eval gate.
TrainingConfig is the operator-facing hyperparameter surface (serialized
into the job payload). EvaluationMetrics / EvaluationResult carry the
held-out eval signal. score_from_loss / passes_eval_gate are the
gate's two-path decision (absolute floor OR clear improvement over base).
TrainingConfig
dataclass
¶
Operator-facing LoRA training hyperparameters.
All fields have sane defaults. Clients pass only the fields they want to
override (exclude_unset in the router); the rest fill from these defaults.
Source code in adapta/training/models.py
EvaluationMetrics
dataclass
¶
Per-adapter evaluation metrics from the held-out split.
Source code in adapta/training/models.py
EvaluationResult
dataclass
¶
Complete evaluation result for one adapter run.
Source code in adapta/training/models.py
to_dict ¶
Convert to dictionary
Source code in adapta/training/models.py
score_from_loss ¶
Map an average cross-entropy loss to an eval score in [0, 1] (higher = better).
score = 1 / perplexity = exp(-avg_loss). A perfect next-token predictor
(loss → 0) scores 1.0; the score decays as loss grows. This is the intrinsic
language-model signal the evaluator can compute without a task-specific metric,
and it is what the eval gate (eval_score_threshold, default 0.6) tests:
0.6 ⇔ perplexity ≤ ~1.67. Clamped to [0, 1] to be safe against tiny negatives.
METRIC SEMANTICS (the gate's meaning — see also specs/schemas/training_dataset.schema.json):
The loss this score is built from is the response-only cross-entropy on a
held-out split (rows the model never trained on), with prompt tokens masked
out. So score measures how well the fine-tune generalizes at producing the
target responses, not how well it memorized the training rows. The evaluator also
scores the base model on the same held-out split and reports the
adapter-minus-base delta (score_delta) so an operator can see whether the
fine-tune actually helped; the 0.6 gate itself remains an absolute floor on the
adapter's response-only score.
Source code in adapta/training/models.py
passes_eval_gate ¶
passes_eval_gate(
score: float,
base_score: Optional[float] = None,
score_delta: Optional[float] = None,
*,
threshold: Optional[float] = None,
min_improvement: Optional[float] = None,
min_floor: Optional[float] = None
) -> bool
The eval gate: may this adapter back an endpoint?
Two ways to pass (the moat verifies the fine-tune is good, not just that it hit one strict number):
- Absolute —
score >= threshold(a strong adapter), the original gate. - Improvement — the adapter clears a low sanity floor AND beats the base
model on the same held-out split by
min_improvement.scorehere isexp(-held_out_response_perplexity), which a small base model can't push to 0.6 even on an ideal task; an adapter that reliably out-scores its base by a clear margin has demonstrably learned the target behavior.
A non-improving or garbage adapter passes neither and stays blocked.
Source code in adapta/training/models.py
split_holdout ¶
Return the number of trailing rows to hold out for evaluation.
The eval gate must measure generalization, so the model is scored on rows it
never trained on. We hold out the last holdout_fraction of the dataset
(deterministic, no shuffling needed — order is the operator's) and train on the
rest. Guarantees at least min_holdout eval row and at least one training row
whenever n >= 2; for a degenerate n == 1 dataset there is nothing to hold
out, so it returns 0 (the caller falls back to evaluating on the single row and
the absolute floor still applies).
Source code in adapta/training/models.py
Core — engine wrappers¶
Inference engine¶
Wraps llama-cpp. The per-model lock, bounded executor, and streaming bridge live here. Do not rewrite the engine internals — wrap and call.
llama-cpp-python inference engine wrapper.
Wraps the Llama object for async, serialized, timeout-bounded inference.
Do NOT rewrite the llama-cpp internals here — this module calls the library;
it does not replace it (hard constraint #1 in CLAUDE.md).
Chat format: all models currently use the Qwen2.5 / ChatML template
(<|im_start|>role\n...<|im_end|>), hardcoded in _format_chat_prompt.
Adding a model with a different chat template (Llama-3, Mistral, etc.) would
require extending that function.
Message
dataclass
¶
Single chat turn.
role must be one of the OpenAI roles (user, assistant,
system) — it is embedded literally into the ChatML <|im_start|>{role}
token. An unrecognized role produces malformed output silently.
Source code in adapta/core/inference.py
InferenceRequest
dataclass
¶
Parameters for one inference call.
Messages are formatted via _format_chat_prompt using the Qwen2.5
ChatML template before being passed to llama-cpp.
adapter_path is the GGUF LoRA path produced by the fine-tune
pipeline (not the PEFT directory). None means base/RAG serving.
Source code in adapta/core/inference.py
InferenceResponse
dataclass
¶
Result of one inference call.
prompt_tokens / completion_tokens come from llama-cpp's usage
dict and are accurate for text-only calls. Vision calls add image-patch
tokens via a separate estimate in chat.py — the counts here undercount
image-heavy traffic.
Source code in adapta/core/inference.py
InferenceEngine ¶
Async wrapper around llama-cpp's Llama object.
Key invariants:
- All public methods are async; blocking C calls run on _inference_executor.
- A Llama object is NOT safe for concurrent calls — callers must pass the
per-model serialization lock from ModelManager.get_inference_lock.
- The lock is held until the underlying C call completes, even on timeout,
because a llama-cpp call cannot be cancelled mid-flight.
Source code in adapta/core/inference.py
99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 | |
generate
async
¶
generate(
model: Llama,
request: InferenceRequest,
*,
lock: Optional[Lock] = None
) -> InferenceResponse
Generate a complete response.
lock (from model_manager.get_inference_lock) serializes calls on
this Llama instance (A4.1). It is held until the underlying C call truly
returns — even on timeout — because a llama-cpp call cannot be cancelled
and must never run concurrently with the next request on the same model.
Source code in adapta/core/inference.py
generate_chat
async
¶
generate_chat(
model: Llama,
messages: List[dict],
*,
model_name: str,
temperature: float,
top_p: float,
max_tokens: int,
lock: Optional[Lock] = None
) -> InferenceResponse
Generate via the model's chat handler (create_chat_completion).
The vision path (§V4): a multimodal model is loaded with a chat handler
that routes image_url content-parts through the vision projector —
something the text path's manual prompt formatting cannot express. Same
serialization-lock and timeout semantics as generate (A4.1).
Source code in adapta/core/inference.py
generate_stream
async
¶
generate_stream(
model: Llama,
request: InferenceRequest,
*,
lock: Optional[Lock] = None
) -> AsyncIterator[str]
Generate a streaming response.
Two safety properties over the naive version (A4.1):
- The model lock is held for the entire stream — every token mutates
the same Llama context, so no other request may touch this model until
the stream is fully drained.
- Each token is pulled on the bounded pool (not iterated synchronously in
the event loop), so streaming no longer blocks the whole server, and a
wall-clock deadline is checked between tokens. The check is between
tokens (never mid-call) so stopping is race-free: there is no in-flight
C call when we close the generator and release the lock.
Source code in adapta/core/inference.py
count_tokens ¶
Count tokens in text using the model's real tokenizer.
Falls back to len(text) // 4 on tokenizer error. Callers using
this for context budgeting should be aware the fallback may under- or
over-count for non-ASCII text.
Source code in adapta/core/inference.py
count_prompt_tokens ¶
count_prompt_tokens(
model: Llama,
messages: List[Message],
system_prompt: Optional[str] = None,
) -> int
Token count of the FINAL formatted prompt, via the model's real tokenizer.
This is the exact text the model will be conditioned on, so it's the number
to budget against n_ctx (A4.3) — not a char/4 estimate.
Source code in adapta/core/inference.py
context_size ¶
Return the model's active context window (n_ctx).
Falls back to settings.max_context_length on error. If the GGUF
was loaded with a different n_ctx than the config default, this
returns the actual value; the fallback may not match, causing the
context-fit guard in chat.py to use an incorrect ceiling.
Source code in adapta/core/inference.py
Model manager (cache + locks)¶
LRU model cache and lifecycle manager.
Maintains an OrderedDict-based LRU cache of loaded Llama objects,
bounded by settings.max_loaded_models. Cache keys combine the serving
(GGUF) model name and the adapter path so a fine-tune endpoint (base+LoRA)
and base-only serving of the same base are cached independently.
ModelManager is instantiated once at module level as the process-level
singleton; import it via from adapta.core import model_manager.
ModelType ¶
Bases: str, Enum
Capability class of a catalog model. Used to group entries in the console.
Source code in adapta/core/model_manager.py
ModelConfig
dataclass
¶
Static configuration for one GGUF model entry.
loaded is mutable state: it is flipped to True after the model is
successfully loaded into the LRU cache, and back to False on eviction.
Source code in adapta/core/model_manager.py
ModelManager ¶
LRU cache of loaded Llama objects with serialized per-model inference.
Two locks with distinct scopes:
- _load_lock: guards mutations to _models / _configs (loading,
eviction, cache-key bookkeeping). Held only during load, never during
inference.
- _infer_locks[cache_key]: serializes inference calls on one model
variant (A4.1). Held for the entire duration of a generation (including
streaming) because llama-cpp's Llama object is not thread-safe.
The OrderedDict ordering is most-recently-used-last so
popitem(last=False) evicts the least-recently-used entry.
Source code in adapta/core/model_manager.py
56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 | |
get_inference_lock ¶
Return the serialization lock for a loaded model variant (A4.1).
llama-cpp's Llama object is not safe for concurrent calls on one
instance, so a caller MUST hold this lock for the entire duration of a
generation — and across a stream's full consumption (each token mutates
the same context). Keyed on the same (base, adapter) cache key as the
model, so base/RAG and a fine-tune (base+LoRA) serialize independently.
Source code in adapta/core/model_manager.py
load_model
async
¶
load_model(
model_name: str,
force_reload: bool = False,
adapter_path: Optional[str] = None,
) -> Llama
Load a model into memory, optionally applying a GGUF LoRA adapter.
When adapter_path is given (a GGUF LoRA produced by the fine-tune
pipeline, A3.1), the base GGUF is loaded WITH the adapter via llama-cpp's
lora_path. The cache is keyed on (base, adapter).
Source code in adapta/core/model_manager.py
222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 | |
unload_model
async
¶
Unload a model from memory. Accepts a catalog name or a composite base+LoRA cache key (A3.1).
Source code in adapta/core/model_manager.py
get_model ¶
list_models ¶
List all available models with updated paths and existence status
Source code in adapta/core/model_manager.py
get_model_config ¶
get_model_by_type ¶
Get the first available model of a given type
Source code in adapta/core/model_manager.py
ensure_model_loaded
async
¶
Return the loaded model, loading it first if absent.
The cache_key not in _models check is not atomic with the subsequent
load_model call. Two concurrent callers can both see the key absent
and both call load_model — the second completes inside _load_lock
and safely overwrites the first. The double-load is harmless but visible
as two consecutive log lines.
Source code in adapta/core/model_manager.py
is_loaded ¶
Check if a model (optionally a specific base+LoRA variant) is loaded
Source code in adapta/core/model_manager.py
preload_default_models
async
¶
Preload default models (chat model)