Skip to content

Llms

API Endpoints

src.domains.llms.endpoints

LLM model management API endpoints for browsing, downloading, and deleting models.

This module provides REST endpoints for: - Listing available LLMs (all, local only, remote only) - Fetching individual LLM details - Downloading models from HuggingFace - Deleting local models and freeing disk space - Tracking download progress via background jobs - Searching models by name

Model States
  • local=0: Remote model (available on HuggingFace, not downloaded)
  • local=1: Local model (downloaded and ready for inference)
  • local=2: Downloading (background job in progress)
Architecture

LLM Lifecycle: ┌────────────────────────────────────────────────────────────┐ │ GET /llms/remote │ │ └─> Browse available models from HuggingFace │ └────────────────────────────────────────────────────────────┘ ↓ ┌────────────────────────────────────────────────────────────┐ │ POST /llms/{llm_id}/download │ │ 1. Create local LLM record (local=2) │ │ 2. Create DownloadJob record │ │ 3. Start background download task │ └────────────────────────────────────────────────────────────┘ ↓ ┌────────────────────────────────────────────────────────────┐ │ GET /llms/download_jobs/{job_id} │ │ └─> Poll download progress (percentage, ETA) │ └────────────────────────────────────────────────────────────┘ ↓ ┌────────────────────────────────────────────────────────────┐ │ GET /llms/local │ │ └─> List downloaded models (local=1) │ └────────────────────────────────────────────────────────────┘

Download Process
  1. List the repo files and refuse the repo if it ships no artefact in the engine's format (MLX repo tag / .gguf file) -- before any byte (#408)
  2. Fetch the pre-built quant from HuggingFace (no local conversion)
  3. Save to config.LLM_DIR/{llm_id}/
  4. Update LLM record: local=1, link=local_path
  5. Mark DownloadJob as completed
Endpoints
  • GET / → List all LLMs
  • GET /local → List local (downloaded) LLMs
  • GET /remote → List remote (HuggingFace) LLMs
  • GET /{llm_id} → Get LLM details
  • GET /search?name= → Search LLMs by name
  • POST /{llm_id}/download → Start model download
  • DELETE /{llm_id} → Delete local model files
  • PUT /{llm_id} → Update LLM metadata
  • GET /download_jobs → List all download jobs
  • GET /download_jobs/{job_id} → Get download job status
  • DELETE /download_jobs/{job_id} → Cancel download job
Example

Download and use a model::

# 1. List available remote models
GET /erudi/llms/remote
→ [{"id": 42, "name": "Mistral-7B", "local": 0, ...}]

# 2. Start download
POST /erudi/llms/42/download
→ {"job_id": 123, "status": "running", "progress": 0.0}

# 3. Poll progress
GET /erudi/llms/download_jobs/123
→ {"job_id": 123, "status": "running", "progress": 45.2, "eta_seconds": 120}

# 4. Wait for completion
GET /erudi/llms/download_jobs/123
→ {"job_id": 123, "status": "completed", "progress": 100.0}

# 5. Use model in conversation
POST /erudi/conversations/
{"llm_id": 42, ...}
Note
  • Downloads run in background via FastAPI BackgroundTasks
  • Large models (7B+) take 5-30 minutes depending on connection
  • Only pre-built quants are downloaded (MLX repos / GGUF files); nothing is converted or quantized locally
  • Disk space check performed before download
Warning

DELETE /{llm_id} permanently removes model files. Cannot be undone. Ensure model is not in use (check conversations) before deleting.

cancel_download(job_id, llm_repo=Depends(get_llm_repository), job_repo=Depends(get_download_job_repository), db=Depends(get_db))

Cancel an active download job and cleanup partial files.

Source code in backend/src/domains/llms/endpoints.py
@router.post(
    "/downloads/{job_id}/cancel",
    status_code=200,
)
def cancel_download(
    job_id: int,
    llm_repo: Llm_Repository = Depends(get_llm_repository),
    job_repo: Download_Job_Repository = Depends(get_download_job_repository),
    db: Session = Depends(get_db),
):
    """Cancel an active download job and cleanup partial files."""
    try:
        return cancel_download_job(job_id, job_repo, llm_repo, db)
    except (
        DownloadJobNotFoundException,
        ModelNotFoundException,
        InvalidInputException,
        StateConflictException,
    ):
        raise
    except Exception as e:
        db.rollback()
        raise DatabaseException("Failed to cancel download", trace=str(e))

delete_llm(llm_id, orphan_dependents=False, llm_repo=Depends(get_llm_repository), db=Depends(get_db)) async

Delete local LLM (permanent deletion).

Regular local model: model files are removed from disk along with the database record. If the model has dependent KB assistants (rows sharing its COPIED link, #209), the delete is guarded: without orphan_dependents it raises 409 carrying the dependents payload so the client can offer to orphan them and proceed. With orphan_dependents=true the base is deleted, its assistants REMAIN (their link now dangles, re-bindable via /rebind), and every conversation bound to the base is nulled server-side (SET NULL, #225).

Specialized KB assistant: its link is a COPY of the base model's (set at creation) — the files belong to the base model and are left untouched; the assistant's KnowledgeBase is deleted instead, and server-side cascades sweep its documents and rag.kb_chunks. Its own conversations survive (SET NULL).

Parameters:

Name Type Description Default
llm_id int

ID of the LLM to delete.

required
orphan_dependents bool

When true, delete a base model that has dependent KB assistants anyway (they become orphans). Ignored for assistants and for base models with no dependents.

False
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)
db Session

Database session for transaction control.

Depends(get_db)

Returns:

Name Type Description
dict

Success message.

Raises:

Type Description
ModelNotFoundException

If LLM not found.

StateConflictException

400 if currently downloading; 409 (with the dependents payload) if a base model has dependents and orphan_dependents was not set.

DatabaseException

If deletion fails.

Warning

Deletes model files from disk (regular models). Cannot be undone.

Source code in backend/src/domains/llms/endpoints.py
@router.delete("/{llm_id}")
async def delete_llm(
    llm_id: int,
    orphan_dependents: bool = False,
    llm_repo: Llm_Repository = Depends(get_llm_repository),
    db: Session = Depends(get_db),
):
    """Delete local LLM (permanent deletion).

    Regular local model: model files are removed from disk along with the
    database record. If the model has dependent KB assistants (rows sharing its
    COPIED ``link``, #209), the delete is guarded: without ``orphan_dependents``
    it raises 409 carrying the dependents payload so the client can offer to
    orphan them and proceed. With ``orphan_dependents=true`` the base is deleted,
    its assistants REMAIN (their link now dangles, re-bindable via /rebind), and
    every conversation bound to the base is nulled server-side (SET NULL, #225).

    Specialized KB assistant: its ``link`` is a COPY of the base model's (set at
    creation) — the files belong to the base model and are left untouched; the
    assistant's KnowledgeBase is deleted instead, and server-side cascades sweep
    its documents and ``rag.kb_chunks``. Its own conversations survive (SET NULL).

    Args:
        llm_id: ID of the LLM to delete.
        orphan_dependents: When true, delete a base model that has dependent KB
            assistants anyway (they become orphans). Ignored for assistants and
            for base models with no dependents.
        llm_repo: Injected LLM repository.
        db: Database session for transaction control.

    Returns:
        dict: Success message.

    Raises:
        ModelNotFoundException: If LLM not found.
        StateConflictException: 400 if currently downloading; 409 (with the
            dependents payload) if a base model has dependents and
            ``orphan_dependents`` was not set.
        DatabaseException: If deletion fails.

    Warning:
        Deletes model files from disk (regular models). Cannot be undone.
    """
    try:
        llm = llm_repo.get_by_id(llm_id)
        if not llm:
            raise ModelNotFoundException(f"LLM {llm_id}")

        if llm.local == 2:
            raise StateConflictException("Cannot delete LLM while downloading")

        if llm.is_attached_to_kb:
            # KB assistant: never touch the (shared) model files. Deleting
            # the KB cascades to its documents/chunks, and the ORM cascade
            # (KnowledgeBase.llm, delete-orphan) removes the assistant row.
            # The assistant's conversations survive server-side (llm_id SET NULL).
            from src.entities.KnowledgeBase import KnowledgeBase

            await _unload_if_resident(llm.id)
            kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == llm.kb_id).first()
            if kb:
                db.delete(kb)
            else:
                # Inconsistent state (flag set, KB gone): drop the row alone.
                llm_repo.delete(llm)
            db.commit()
            logger.info(f"Deleted KB assistant {llm_id} (KB {llm.kb_id}, files kept)")
            return {"message": "LLM deleted successfully"}

        # Base model: guard against silently orphaning KB assistants that share
        # its weights. Without opt-in, surface the dependents (409) so the client
        # can confirm; with orphan_dependents=true, proceed and let them dangle.
        dependents = _dependents_payload(llm_repo, llm)
        if dependents["assistants"] and not orphan_dependents:
            raise StateConflictException(
                f"Base model '{llm.name}' has {len(dependents['assistants'])} "
                "dependent KB assistant(s). Retry with orphan_dependents=true to "
                "delete it anyway; the assistants remain and can be rebound to "
                "another base.",
                status_code=http_status.HTTP_409_CONFLICT,
                detail=dependents,
            )

        # Unload before the weights go, and only now: the 409 above means the
        # delete was refused, and a refused delete must leave the engine alone.
        await _unload_if_resident(llm.id)

        # Delete files from disk if they exist
        if llm.link and os.path.exists(llm.link):
            shutil.rmtree(llm.link, ignore_errors=True)
            logger.info(f"Deleted model files: {llm.link}")

            # Check and delete residual temp files (e.g., temp_36 for llm.link = data/models/36)
            temp_path = config.LLM_DIR / f"temp_{llm.id}"
            if os.path.exists(str(temp_path)):
                shutil.rmtree(str(temp_path), ignore_errors=True)
                logger.warning(f"Deleting residual temp files associated to llm: {temp_path}")

        # Delete database record
        llm_repo.delete(llm)
        db.commit()

        return {"message": "LLM deleted successfully"}

    except (ModelNotFoundException, StateConflictException):
        raise
    except Exception as e:
        db.rollback()
        raise DatabaseException("Failed to delete LLM", trace=str(e))

download_huggingface_route(payload, background_tasks, llm_repo=Depends(get_llm_repository), job_repo=Depends(get_download_job_repository), db=Depends(get_db)) async

Download a model chosen from HF search by its repo id (not via the catalog).

Mirrors POST /{llm_id}/download but takes the HF repo id directly, so search results never need to be persisted into the catalog. Creates the local=2 placeholder + DownloadJob and runs the download in the background.

Parameters:

Name Type Description Default
payload HFDownloadRequest

The chosen HF repo id plus display metadata from the search hit.

required

Returns:

Name Type Description
DownloadJobResponse

Job record to poll via GET /downloads/{job_id}/status.

Source code in backend/src/domains/llms/endpoints.py
@router.post(
    "/download/huggingface",
    response_model=DownloadJobResponse,
    status_code=http_status.HTTP_200_OK,
)
async def download_huggingface_route(
    payload: HFDownloadRequest,
    background_tasks: BackgroundTasks,
    llm_repo: Llm_Repository = Depends(get_llm_repository),
    job_repo: Download_Job_Repository = Depends(get_download_job_repository),
    db: Session = Depends(get_db),
):
    """Download a model chosen from HF search by its repo id (not via the catalog).

    Mirrors POST /{llm_id}/download but takes the HF repo id directly, so search
    results never need to be persisted into the catalog. Creates the local=2
    placeholder + DownloadJob and runs the download in the background.

    Args:
        payload: The chosen HF repo id plus display metadata from the search hit.

    Returns:
        DownloadJobResponse: Job record to poll via GET /downloads/{job_id}/status.
    """
    try:
        name = payload.name or humanize_model_name(payload.link)
        # Family type is metadata-only for a local row; fall back to the category.
        type_ = payload.type or payload.category or "community"
        return _start_download(
            remote_model_id=payload.link,
            remote_link=payload.link,
            name=name,
            type=type_,
            description=None,
            model_metadata=None,
            quantized=payload.quantized,
            param_size=payload.param_size,
            category=payload.category,
            llm_repo=llm_repo,
            job_repo=job_repo,
            db=db,
            background_tasks=background_tasks,
        )
    except (FileSystemException, InvalidInputException):
        raise
    except Exception as e:
        db.rollback()
        raise DatabaseException("Failed to start download", trace=str(e))

download_llm_route(llm_id, background_tasks, llm_repo=Depends(get_llm_repository), job_repo=Depends(get_download_job_repository), db=Depends(get_db)) async

Start background download of LLM from HuggingFace.

Creates a DownloadJob and runs download in background. The catalog link is already a pre-built quant for this engine (MLX repo on Apple Silicon, GGUF file elsewhere); nothing is converted locally.

Parameters:

Name Type Description Default
llm_id int

ID of the remote LLM to download.

required
background_tasks BackgroundTasks

FastAPI background tasks manager.

required
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)
job_repo Download_Job_Repository

Injected download job repository.

Depends(get_download_job_repository)
db Session

Database session for transaction control.

Depends(get_db)

Returns:

Name Type Description
DownloadJobResponse

Job record with job_id, status, progress.

Raises:

Type Description
HTTPException

404 if LLM not found, 500 if paths already exist.

Note

Download progress can be polled via GET /downloads/{job_id}/status. Large models (7B+) take 5-30 minutes.

Source code in backend/src/domains/llms/endpoints.py
@router.post(
    "/{llm_id}/download",
    response_model=DownloadJobResponse,
    status_code=http_status.HTTP_200_OK,
)
async def download_llm_route(
    llm_id: int,
    background_tasks: BackgroundTasks,
    llm_repo: Llm_Repository = Depends(get_llm_repository),
    job_repo: Download_Job_Repository = Depends(get_download_job_repository),
    db: Session = Depends(get_db),
):
    """Start background download of LLM from HuggingFace.

    Creates a DownloadJob and runs download in background. The catalog link is
    already a pre-built quant for this engine (MLX repo on Apple Silicon, GGUF
    file elsewhere); nothing is converted locally.

    Args:
        llm_id: ID of the remote LLM to download.
        background_tasks: FastAPI background tasks manager.
        llm_repo: Injected LLM repository.
        job_repo: Injected download job repository.
        db: Database session for transaction control.

    Returns:
        DownloadJobResponse: Job record with job_id, status, progress.

    Raises:
        HTTPException: 404 if LLM not found, 500 if paths already exist.

    Note:
        Download progress can be polled via GET /downloads/{job_id}/status.
        Large models (7B+) take 5-30 minutes.
    """
    try:
        # Get remote LLM metadata
        remote_llm = llm_repo.get_by_id(llm_id)
        if not remote_llm:
            raise ModelNotFoundException(f"LLM {llm_id}")

        return _start_download(
            remote_model_id=str(llm_id),
            remote_link=remote_llm.link,
            name=remote_llm.name,
            type=remote_llm.type,
            description=remote_llm.description,
            model_metadata=remote_llm.model_metadata,
            quantized=remote_llm.quantized,
            param_size=remote_llm.param_size,
            category=getattr(remote_llm, "category", "general"),
            llm_repo=llm_repo,
            job_repo=job_repo,
            db=db,
            background_tasks=background_tasks,
            generation_hints=getattr(remote_llm, "generation_hints", None),
            artifact_size_bytes=remote_llm.artifact_size_bytes,
        )

    except (ModelNotFoundException, InvalidInputException, FileSystemException):
        raise
    except Exception as e:
        db.rollback()
        raise DatabaseException("Failed to start download", trace=str(e))

get_all_llms(llm_repo=Depends(get_llm_repository)) async

List all LLMs (local and remote).

Parameters:

Name Type Description Default
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)

Returns:

Type Description

List[LLMResponse]: All LLM models with metadata.

Source code in backend/src/domains/llms/endpoints.py
@router.get("/", response_model=List[LLMResponse])
async def get_all_llms(llm_repo: Llm_Repository = Depends(get_llm_repository)):
    """List all LLMs (local and remote).

    Args:
        llm_repo: Injected LLM repository.

    Returns:
        List[LLMResponse]: All LLM models with metadata.
    """
    # Read-only operation, no commit needed
    llms = llm_repo.get_all()
    return llms

get_download_job_repository(db=Depends(get_db))

Dependency injection for Download_Job_Repository.

Parameters:

Name Type Description Default
db Session

Database session from FastAPI.

Depends(get_db)

Returns:

Name Type Description
Download_Job_Repository Download_Job_Repository

Repository instance with injected session.

Source code in backend/src/domains/llms/endpoints.py
def get_download_job_repository(db: Session = Depends(get_db)) -> Download_Job_Repository:
    """Dependency injection for Download_Job_Repository.

    Args:
        db: Database session from FastAPI.

    Returns:
        Download_Job_Repository: Repository instance with injected session.
    """
    return Download_Job_Repository(db)

get_download_status_by_jobId(job_id, llm_repo=Depends(get_llm_repository), job_repo=Depends(get_download_job_repository), db=Depends(get_db))

Get download job status by ID with automatic cleanup for failed/cancelled jobs.

Polls the download job status and performs cleanup operations for terminal states (failed/cancelled/completed). Failed/cancelled jobs delete the temp LLM entry and clean up temporary files. Completed jobs mark the LLM as local=1 (ready).

Parameters:

Name Type Description Default
job_id int

The database ID of the download job to query.

required
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)
job_repo Download_Job_Repository

Injected download job repository.

Depends(get_download_job_repository)
db Session

Database session for transaction control.

Depends(get_db)

Returns:

Name Type Description
DownloadJobResponse

Download job with current status, progress, ETA, and file paths.

Raises:

Type Description
DownloadJobNotFoundException

If job_id not found.

ModelNotFoundException

If a completed job's associated LLM is missing.

DatabaseException

If status fetch fails.

Example

GET /llms/downloads/42/status Response: {"id": 42, "status": "running", "progress": 65.0, "eta_seconds": 120, ...}

Source code in backend/src/domains/llms/endpoints.py
@router.get(
    "/downloads/{job_id}/status",
    response_model=DownloadJobResponse,
    status_code=200,
)
def get_download_status_by_jobId(
    job_id: int,
    llm_repo: Llm_Repository = Depends(get_llm_repository),
    job_repo: Download_Job_Repository = Depends(get_download_job_repository),
    db: Session = Depends(get_db),
):
    """Get download job status by ID with automatic cleanup for failed/cancelled jobs.

    Polls the download job status and performs cleanup operations for terminal states
    (failed/cancelled/completed). Failed/cancelled jobs delete the temp LLM entry and
    clean up temporary files. Completed jobs mark the LLM as local=1 (ready).

    Args:
        job_id: The database ID of the download job to query.
        llm_repo: Injected LLM repository.
        job_repo: Injected download job repository.
        db: Database session for transaction control.

    Returns:
        DownloadJobResponse: Download job with current status, progress, ETA, and file paths.

    Raises:
        DownloadJobNotFoundException: If job_id not found.
        ModelNotFoundException: If a completed job's associated LLM is missing.
        DatabaseException: If status fetch fails.

    Example:
        GET /llms/downloads/42/status
        Response: {"id": 42, "status": "running", "progress": 65.0, "eta_seconds": 120, ...}
    """
    try:
        job = job_repo.get_by_id(job_id)
        if not job:
            raise DownloadJobNotFoundException(job_id)

        # Handle failed/cancelled jobs: cleanup temp files and LLM entry.
        # `cancel_download_job` already deletes the temp LLM (and Postgres
        # nulls local_model_id through the FK) before the job reaches
        # `cancelled`, so a missing row here is the common case, not an
        # error - only run cleanup when there is still something to clean.
        if job.status in ["failed", "cancelled"]:
            llm = llm_repo.get_by_id(job.local_model_id)
            if llm:
                # Delete temp LLM entry
                llm_repo.delete(llm)

                # Clean up temp files using repository method
                job_repo.cleanup_job_files(job)

                db.commit()
                db.refresh(job)
                logger.info(f"Cleaned up {job.status} download job {job_id}")

        # Handle completed jobs: mark LLM as ready, ONCE.
        #
        # The frontend polls this endpoint every 2s while a download is in
        # flight, and keeps polling a completed job until it navigates away. The
        # write below used to run on every one of those ticks: a GET handler
        # issuing an UPDATE plus a COMMIT, and an INFO log line, for a row that
        # already said local = 1. Guarding on the current value makes the write
        # idempotent in the real sense (it happens once) rather than merely
        # convergent (it happens forever but lands on the same value).
        elif job.status == "completed":
            llm = llm_repo.get_by_id(job.local_model_id)
            if not llm:
                raise ModelNotFoundException(f"LLM {job.local_model_id}")
            if llm.local != 1:
                llm_repo.update(llm, local=1)
                db.commit()
                db.refresh(llm)
                logger.info(f"Marked LLM {llm.id} as ready (download job {job_id} completed)")

        return job

    except (DownloadJobNotFoundException, ModelNotFoundException):
        raise
    except Exception as e:
        db.rollback()
        raise DatabaseException("Failed to get download status", trace=str(e))

get_download_status_without_jobId(llm_repo=Depends(get_llm_repository), job_repo=Depends(get_download_job_repository), db=Depends(get_db))

Get most recent active download job (for single-download UI polling).

Finds the most recently updated download job that is still running/pending within the last 60 seconds. Useful for UIs that only support one active download at a time and need to poll without tracking job IDs. Performs same cleanup as get_by_id.

Parameters:

Name Type Description Default
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)
job_repo Download_Job_Repository

Injected download job repository.

Depends(get_download_job_repository)
db Session

Database session for transaction control.

Depends(get_db)

Returns:

Name Type Description
DownloadJobResponse

The most recent active job with status and progress.

Raises:

Type Description
DownloadJobNotFoundException

If no active job found in last 60 seconds.

ModelNotFoundException

If a completed job's associated LLM is missing.

DatabaseException

If status fetch fails.

Example

GET /llms/downloads/status Response: {"id": 42, "status": "running", "progress": 65.0, ...}

Source code in backend/src/domains/llms/endpoints.py
@router.get(
    "/downloads/status",
    response_model=DownloadJobResponse,
    status_code=200,
)
def get_download_status_without_jobId(
    llm_repo: Llm_Repository = Depends(get_llm_repository),
    job_repo: Download_Job_Repository = Depends(get_download_job_repository),
    db: Session = Depends(get_db),
):
    """Get most recent active download job (for single-download UI polling).

    Finds the most recently updated download job that is still running/pending within
    the last 60 seconds. Useful for UIs that only support one active download at a time
    and need to poll without tracking job IDs. Performs same cleanup as get_by_id.

    Args:
        llm_repo: Injected LLM repository.
        job_repo: Injected download job repository.
        db: Database session for transaction control.

    Returns:
        DownloadJobResponse: The most recent active job with status and progress.

    Raises:
        DownloadJobNotFoundException: If no active job found in last 60 seconds.
        ModelNotFoundException: If a completed job's associated LLM is missing.
        DatabaseException: If status fetch fails.

    Example:
        GET /llms/downloads/status
        Response: {"id": 42, "status": "running", "progress": 65.0, ...}
    """
    try:
        # Get most recent active job
        job = job_repo.get_most_recent_active()
        if not job:
            raise DownloadJobNotFoundException("recent active")

        # Handle failed jobs: cleanup temp files and LLM entry. Same
        # already-cleaned-up guard as get_download_status_by_jobId - see #509.
        if job.status == "failed":
            llm = llm_repo.get_by_id(job.local_model_id)
            if llm:
                # Delete temp LLM entry
                llm_repo.delete(llm)

                # Clean up temp files using repository method
                job_repo.cleanup_job_files(job)

                db.commit()
                db.refresh(job)
                logger.info(f"Cleaned up failed download job {job.id}")

        # Handle completed jobs: mark LLM as ready, ONCE. Same idempotency
        # guard as get_download_status_by_jobId - see the comment there.
        elif job.status == "completed":
            llm = llm_repo.get_by_id(job.local_model_id)
            if not llm:
                raise ModelNotFoundException(f"LLM {job.local_model_id}")
            if llm.local != 1:
                llm_repo.update(llm, local=1)
                db.commit()
                db.refresh(llm)
                logger.info(f"Marked LLM {llm.id} as ready (download job {job.id} completed)")

        return job

    except (DownloadJobNotFoundException, ModelNotFoundException):
        raise
    except Exception as e:
        db.rollback()
        raise DatabaseException("Failed to get download status", trace=str(e))

get_llm_by_id(llm_id, llm_repo=Depends(get_llm_repository)) async

Get LLM details by ID.

Parameters:

Name Type Description Default
llm_id int

ID of the LLM to retrieve.

required
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)

Returns:

Name Type Description
LLMResponse

LLM metadata.

Raises:

Type Description
ModelNotFoundException

If LLM not found.

Source code in backend/src/domains/llms/endpoints.py
@router.get("/{llm_id}", response_model=LLMResponse)
async def get_llm_by_id(llm_id: int, llm_repo: Llm_Repository = Depends(get_llm_repository)):
    """Get LLM details by ID.

    Args:
        llm_id: ID of the LLM to retrieve.
        llm_repo: Injected LLM repository.

    Returns:
        LLMResponse: LLM metadata.

    Raises:
        ModelNotFoundException: If LLM not found.
    """
    # Read-only operation, no commit needed
    llm = llm_repo.get_by_id(llm_id)
    if not llm:
        raise ModelNotFoundException(f"LLM {llm_id}")
    return llm

get_llm_dependents(llm_id, llm_repo=Depends(get_llm_repository)) async

List the KB assistants that depend on this model's weights, plus the conversation counts a delete would orphan.

Dependents share the model's COPIED link (#209): deleting the base leaves their weights dangling. A model with no dependents returns an empty list with zero-or-more conversation counts. The frontend calls this before a base-model delete to render the choice dialog.

A KB assistant NEVER has dependents (#317): deleting it is a direct 200 that keeps the shared weights (they belong to the base), so sibling assistants copying the same link are not reported — this endpoint answers with the exact truth the guarded DELETE's 409 uses.

Parameters:

Name Type Description Default
llm_id int

ID of the (base) model to inspect.

required
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)

Returns:

Name Type Description
DependentsResponse

assistants + own/total conversation counts.

Raises:

Type Description
ModelNotFoundException

If the model is not found.

Source code in backend/src/domains/llms/endpoints.py
@router.get("/{llm_id}/dependents", response_model=DependentsResponse)
async def get_llm_dependents(
    llm_id: int,
    llm_repo: Llm_Repository = Depends(get_llm_repository),
):
    """List the KB assistants that depend on this model's weights, plus the
    conversation counts a delete would orphan.

    Dependents share the model's COPIED ``link`` (#209): deleting the base
    leaves their weights dangling. A model with no dependents returns an empty
    list with zero-or-more conversation counts. The frontend calls this before a
    base-model delete to render the choice dialog.

    A KB assistant NEVER has dependents (#317): deleting it is a direct 200
    that keeps the shared weights (they belong to the base), so sibling
    assistants copying the same link are not reported — this endpoint answers
    with the exact truth the guarded DELETE's 409 uses.

    Args:
        llm_id: ID of the (base) model to inspect.
        llm_repo: Injected LLM repository.

    Returns:
        DependentsResponse: assistants + own/total conversation counts.

    Raises:
        ModelNotFoundException: If the model is not found.
    """
    llm = llm_repo.get_by_id(llm_id)
    if not llm:
        raise ModelNotFoundException(f"LLM {llm_id}")
    if llm.is_attached_to_kb:
        own = llm_repo.count_conversations(llm.id)
        return {
            "assistants": [],
            "own_conversation_count": own,
            "total_conversation_count": own,
        }
    return _dependents_payload(llm_repo, llm)

get_llm_repository(db=Depends(get_db))

Dependency injection for Llm_Repository.

Parameters:

Name Type Description Default
db Session

Database session from FastAPI.

Depends(get_db)

Returns:

Name Type Description
Llm_Repository Llm_Repository

Repository instance with injected session.

Source code in backend/src/domains/llms/endpoints.py
def get_llm_repository(db: Session = Depends(get_db)) -> Llm_Repository:
    """Dependency injection for Llm_Repository.

    Args:
        db: Database session from FastAPI.

    Returns:
        Llm_Repository: Repository instance with injected session.
    """
    return Llm_Repository(db)

get_local_llms(llm_repo=Depends(get_llm_repository)) async

List only local (downloaded) LLMs ready for inference.

Parameters:

Name Type Description Default
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)

Returns:

Type Description

List[LLMResponse]: LLMs with local=1.

Source code in backend/src/domains/llms/endpoints.py
@router.get("/local", response_model=List[LLMResponse])
async def get_local_llms(llm_repo: Llm_Repository = Depends(get_llm_repository)):
    """List only local (downloaded) LLMs ready for inference.

    Args:
        llm_repo: Injected LLM repository.

    Returns:
        List[LLMResponse]: LLMs with local=1.
    """
    # Read-only operation, no commit needed
    llms = llm_repo.get_all_local()
    return llms

get_remote_llms(llm_repo=Depends(get_llm_repository)) async

List only remote (HuggingFace) LLMs available for download.

Parameters:

Name Type Description Default
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)

Returns:

Type Description

List[LLMResponse]: LLMs with local=0.

Source code in backend/src/domains/llms/endpoints.py
@router.get("/remote", response_model=List[LLMResponse])
async def get_remote_llms(llm_repo: Llm_Repository = Depends(get_llm_repository)):
    """List only remote (HuggingFace) LLMs available for download.

    Args:
        llm_repo: Injected LLM repository.

    Returns:
        List[LLMResponse]: LLMs with local=0.
    """
    # Read-only operation, no commit needed
    llms = llm_repo.get_all_remote()
    return llms

rebind_assistant(assistant_id, payload, llm_repo=Depends(get_llm_repository), db=Depends(get_db)) async

Re-point an orphaned KB assistant at a new base model.

Re-copies the base's weights link plus every descriptive COPIED_FIELDS entry (#209) onto the assistant, keeping its own name/description and KB wiring (kb_id, is_attached_to_kb). Used to recover an assistant whose base was deleted (weights_available False). The target must be a local, non-assistant model whose weights exist on disk.

Parameters:

Name Type Description Default
assistant_id int

ID of the KB assistant to rebind.

required
payload RebindRequest

RebindRequest with new_base_llm_id.

required
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)
db Session

Database session for transaction control.

Depends(get_db)

Returns:

Name Type Description
LLMResponse

The updated assistant (weights_available now True).

Raises:

Type Description
ModelNotFoundException

404 if the assistant or target base is missing.

StateConflictException

409 if the assistant is not a KB assistant, or the target is itself an assistant / not downloaded / has no weights.

DatabaseException

If the rebind fails.

Source code in backend/src/domains/llms/endpoints.py
@router.post("/{assistant_id}/rebind", response_model=LLMResponse)
async def rebind_assistant(
    assistant_id: int,
    payload: RebindRequest,
    llm_repo: Llm_Repository = Depends(get_llm_repository),
    db: Session = Depends(get_db),
):
    """Re-point an orphaned KB assistant at a new base model.

    Re-copies the base's weights ``link`` plus every descriptive COPIED_FIELDS
    entry (#209) onto the assistant, keeping its own name/description and KB
    wiring (kb_id, is_attached_to_kb). Used to recover an assistant whose base
    was deleted (weights_available False). The target must be a local,
    non-assistant model whose weights exist on disk.

    Args:
        assistant_id: ID of the KB assistant to rebind.
        payload: RebindRequest with ``new_base_llm_id``.
        llm_repo: Injected LLM repository.
        db: Database session for transaction control.

    Returns:
        LLMResponse: The updated assistant (weights_available now True).

    Raises:
        ModelNotFoundException: 404 if the assistant or target base is missing.
        StateConflictException: 409 if the assistant is not a KB assistant, or
            the target is itself an assistant / not downloaded / has no weights.
        DatabaseException: If the rebind fails.
    """
    try:
        assistant = llm_repo.get_by_id(assistant_id)
        if not assistant:
            raise ModelNotFoundException(f"LLM {assistant_id}")
        if not assistant.is_attached_to_kb:
            raise StateConflictException(
                f"Model '{assistant.name}' is not a KB assistant; only KB "
                "assistants can be rebound to a new base.",
                status_code=http_status.HTTP_409_CONFLICT,
            )

        new_base = llm_repo.get_by_id(payload.new_base_llm_id)
        if not new_base:
            raise ModelNotFoundException(f"LLM {payload.new_base_llm_id}")
        if new_base.is_attached_to_kb:
            raise StateConflictException(
                f"Target model '{new_base.name}' is itself a KB assistant; pick "
                "a standalone base model.",
                status_code=http_status.HTTP_409_CONFLICT,
            )
        if new_base.local != 1:
            raise StateConflictException(
                f"Target model '{new_base.name}' is not downloaded; its weights "
                "must exist to rebind onto it.",
                status_code=http_status.HTTP_409_CONFLICT,
            )
        if not new_base.link or not os.path.exists(new_base.link):
            raise StateConflictException(
                f"Target model '{new_base.name}' weights are missing on disk; "
                "cannot rebind onto it.",
                status_code=http_status.HTTP_409_CONFLICT,
            )

        # Re-copy link + descriptive columns; name/description/KB wiring untouched.
        updated = llm_repo.update(
            assistant,
            **{field: getattr(new_base, field) for field in COPIED_FIELDS},
        )
        db.commit()
        db.refresh(updated)
        logger.info(f"Rebound assistant {assistant_id} onto base {new_base.id}")
        return updated

    except (ModelNotFoundException, StateConflictException):
        raise
    except Exception as e:
        db.rollback()
        raise DatabaseException("Failed to rebind assistant", trace=str(e))

search_huggingface_route(q, limit=30) async

Live HuggingFace search beyond the curated catalog (#122 follow-up).

Searches HF directly for runnable models matching q in the active engine's format, filtered to chat/vision LLMs (so a query like "french" doesn't return token-classification repos). Results are ephemeral — they are NOT added to the catalog; download a chosen one via POST /download/huggingface.

Parameters:

Name Type Description Default
q str

Free-text query (model name, family, trait like "uncensored").

required
limit int

Max results to return (default 30).

30

Returns:

Type Description

List[HFSearchResult]: Downloadable matches with metadata + category.

Source code in backend/src/domains/llms/endpoints.py
@router.get("/search/huggingface", response_model=List[HFSearchResult])
async def search_huggingface_route(q: str, limit: int = 30):
    """Live HuggingFace search beyond the curated catalog (#122 follow-up).

    Searches HF directly for runnable models matching ``q`` in the active engine's
    format, filtered to chat/vision LLMs (so a query like "french" doesn't return
    token-classification repos). Results are ephemeral — they are NOT added to the
    catalog; download a chosen one via POST /download/huggingface.

    Args:
        q: Free-text query (model name, family, trait like "uncensored").
        limit: Max results to return (default 30).

    Returns:
        List[HFSearchResult]: Downloadable matches with metadata + category.
    """
    return search_huggingface(q, limit=limit)

search_llms(name, llm_repo=Depends(get_llm_repository)) async

Search LLMs by name (case-insensitive partial match).

Parameters:

Name Type Description Default
name str

Search query string.

required
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)

Returns:

Type Description

List[LLMResponse]: Matching LLMs.

Source code in backend/src/domains/llms/endpoints.py
@router.get("/search", response_model=List[LLMResponse])
async def search_llms(name: str, llm_repo: Llm_Repository = Depends(get_llm_repository)):
    """Search LLMs by name (case-insensitive partial match).

    Args:
        name: Search query string.
        llm_repo: Injected LLM repository.

    Returns:
        List[LLMResponse]: Matching LLMs.
    """
    # Read-only operation, no commit needed
    llms = llm_repo.search_by_name(name)
    return llms

update_llm(llm_id, llm_data, llm_repo=Depends(get_llm_repository), db=Depends(get_db)) async

Update LLM metadata (name, description, etc.).

Parameters:

Name Type Description Default
llm_id int

ID of the LLM to update.

required
llm_data LLMCreate

LLMCreate schema with new values.

required
llm_repo Llm_Repository

Injected LLM repository.

Depends(get_llm_repository)
db Session

Database session for transaction control.

Depends(get_db)

Returns:

Name Type Description
LLMResponse

Updated LLM.

Raises:

Type Description
ModelNotFoundException

If LLM not found.

DatabaseException

If update fails.

Source code in backend/src/domains/llms/endpoints.py
@router.put("/{llm_id}", response_model=LLMResponse)
async def update_llm(
    llm_id: int,
    llm_data: LLMCreate,
    llm_repo: Llm_Repository = Depends(get_llm_repository),
    db: Session = Depends(get_db),
):
    """Update LLM metadata (name, description, etc.).

    Args:
        llm_id: ID of the LLM to update.
        llm_data: LLMCreate schema with new values.
        llm_repo: Injected LLM repository.
        db: Database session for transaction control.

    Returns:
        LLMResponse: Updated LLM.

    Raises:
        ModelNotFoundException: If LLM not found.
        DatabaseException: If update fails.
    """
    try:
        llm = llm_repo.get_by_id(llm_id)
        if not llm:
            raise ModelNotFoundException(f"LLM {llm_id}")

        # Update fields from request
        updated_llm = llm_repo.update(llm, **llm_data.dict())
        db.commit()
        return updated_llm
    except ModelNotFoundException:
        raise
    except Exception as e:
        db.rollback()
        raise DatabaseException("Failed to update LLM", trace=str(e))

Repository (Data Access Layer)

src.domains.llms.repository

Data access layer for LLM and DownloadJob entities.

This module provides repository classes following the Repository pattern for database operations on LLMs and download jobs. Repositories encapsulate all SQLAlchemy queries and database interactions, keeping the service and endpoint layers clean.

Architecture
  • Llm_Repository: CRUD operations for LLM catalog entries.
  • Download_Job_Repository: CRUD operations for download job tracking.
  • update_db_with_progress: Background thread function for polling download progress.
Repository Pattern Benefits
  • Single source of truth for data access logic.
  • Easy to mock for testing.
  • Clear separation between business logic (services) and data access.
  • Consistent error handling and logging.
Example

from src.domains.llms.repository import Llm_Repository, Download_Job_Repository

In endpoint or service

llm_repo = Llm_Repository(db) llm = llm_repo.get_by_id(42)

Download_Job_Repository(db)

Repository for DownloadJob entity database operations.

Handles all CRUD operations and queries for download job tracking, including status updates, progress monitoring, and cleanup operations.

Attributes:

Name Type Description
db

SQLAlchemy database session (injected by FastAPI).

Example

job_repo = Download_Job_Repository(db) job = job_repo.create(remote_model_id="meta-llama/...", status="pending") job_repo.update_progress(job, progress=50.0, eta_seconds=120.0)

Initialize repository with database session.

Parameters:

Name Type Description Default
db Session

SQLAlchemy session for database operations.

required
Source code in backend/src/domains/llms/repository.py
def __init__(self, db: Session):
    """Initialize repository with database session.

    Args:
        db: SQLAlchemy session for database operations.
    """
    self.db = db
    logger.debug("Initializing Download_Job_Repository")

cleanup_job_files(job)

Clean up temporary and final model files for failed/cancelled job.

Parameters:

Name Type Description Default
job DownloadJobModel

Download job with file paths to clean up.

required
Note

Removes files from disk but does not modify database records.

Source code in backend/src/domains/llms/repository.py
def cleanup_job_files(self, job: DownloadJobModel) -> None:
    """Clean up temporary and final model files for failed/cancelled job.

    Args:
        job: Download job with file paths to clean up.

    Note:
        Removes files from disk but does not modify database records.
    """
    logger.info(f"Cleaning up files for job {job.id}")

    def _remove(kind: str, path) -> None:
        if not path or not os.path.exists(str(path)):
            return
        left = remove_tree_reporting(path)
        if left:
            # Say so. Startup's orphan sweep reclaims temp_* directories on
            # the next launch, so the bytes are not lost forever -- but a
            # session that cancels several large downloads holds on to all
            # of them, and claiming success hid that entirely.
            logger.warning(
                f"Could not remove {kind} for job {job.id}: {path} "
                f"({left} bytes still on disk; startup cleanup will reclaim it)"
            )
        else:
            logger.debug(f"Removed {kind}: {path}")

    # Clean temp directory
    if job.temp_local_model_link and job.temp_local_model_link != "":
        _remove("temp directory", job.temp_local_model_link)

        # Also check for temp_{id} pattern if not already in path
        if "temp" not in job.temp_local_model_link and job.local_model_id:
            _remove("temp fallback", config.LLM_DIR / f"temp_{job.local_model_id}")

    # Clean final directory
    if job.final_local_model_link and job.final_local_model_link != "":
        _remove("final directory", job.final_local_model_link)

create(remote_model_id, local_model_id, remote_model_link, temp_local_model_link, final_local_model_link, status='pending')

Create a new download job record.

Parameters:

Name Type Description Default
remote_model_id str

HuggingFace repo ID.

required
local_model_id int

Database ID of temp LLM entry.

required
remote_model_link str

HuggingFace URL.

required
temp_local_model_link str

Temp directory path.

required
final_local_model_link str

Final directory path.

required
status str

Initial job status (default "pending").

'pending'

Returns:

Name Type Description
DownloadJobModel DownloadJobModel

Created job entity (not yet committed, use flush()).

Source code in backend/src/domains/llms/repository.py
def create(
    self,
    remote_model_id: str,
    local_model_id: int,
    remote_model_link: str,
    temp_local_model_link: str,
    final_local_model_link: str,
    status: str = "pending",
) -> DownloadJobModel:
    """Create a new download job record.

    Args:
        remote_model_id: HuggingFace repo ID.
        local_model_id: Database ID of temp LLM entry.
        remote_model_link: HuggingFace URL.
        temp_local_model_link: Temp directory path.
        final_local_model_link: Final directory path.
        status: Initial job status (default "pending").

    Returns:
        DownloadJobModel: Created job entity (not yet committed, use flush()).
    """
    logger.info(f"Creating download job for model: {remote_model_id}")
    job = DownloadJobModel(
        remote_model_id=remote_model_id,
        local_model_id=local_model_id,
        remote_model_link=remote_model_link,
        temp_local_model_link=temp_local_model_link,
        final_local_model_link=final_local_model_link,
        status=status,
        total_bytes=0.0,
        progress=0.0,
    )
    self.db.add(job)
    self.db.flush()
    self.db.refresh(job)
    logger.info(f"Created download job {job.id}")
    return job

get_by_id(job_id)

Retrieve download job by primary key.

Parameters:

Name Type Description Default
job_id int

ID of the download job.

required

Returns:

Type Description
Optional[DownloadJobModel]

DownloadJobModel if found, None otherwise.

Source code in backend/src/domains/llms/repository.py
def get_by_id(self, job_id: int) -> Optional[DownloadJobModel]:
    """Retrieve download job by primary key.

    Args:
        job_id: ID of the download job.

    Returns:
        DownloadJobModel if found, None otherwise.
    """
    logger.debug(f"Retrieving download job by ID: {job_id}")
    return self.db.query(DownloadJobModel).filter(DownloadJobModel.id == job_id).first()

get_most_recent_active(max_age_seconds=60)

Retrieve most recent active download job (for single-download UI polling).

Finds the most recently updated job with status "running" or "pending" within the specified time window.

Parameters:

Name Type Description Default
max_age_seconds int

Maximum age in seconds (default 60).

60

Returns:

Type Description
Optional[DownloadJobModel]

DownloadJobModel if found, None otherwise.

Source code in backend/src/domains/llms/repository.py
def get_most_recent_active(self, max_age_seconds: int = 60) -> Optional[DownloadJobModel]:
    """Retrieve most recent active download job (for single-download UI polling).

    Finds the most recently updated job with status "running" or "pending" within
    the specified time window.

    Args:
        max_age_seconds: Maximum age in seconds (default 60).

    Returns:
        DownloadJobModel if found, None otherwise.
    """
    logger.debug(f"Retrieving most recent active job (max age: {max_age_seconds}s)")
    cutoff = datetime.now(timezone.utc) - timedelta(seconds=max_age_seconds)

    return (
        self.db.query(DownloadJobModel)
        .filter(DownloadJobModel.status.in_(["running", "pending"]))
        .filter(DownloadJobModel.updated_at >= cutoff)
        .order_by(DownloadJobModel.updated_at.desc())
        .first()
    )

mark_completed(job)

Mark download job as completed (100% progress).

Parameters:

Name Type Description Default
job DownloadJobModel

Download job entity to mark as completed.

required
Note

Does not commit. Caller must handle db.commit().

Source code in backend/src/domains/llms/repository.py
def mark_completed(self, job: DownloadJobModel) -> None:
    """Mark download job as completed (100% progress).

    Args:
        job: Download job entity to mark as completed.

    Note:
        Does not commit. Caller must handle db.commit().
    """
    logger.info(f"Marking job {job.id} as completed")
    job.status = "completed"
    job.progress = 100.0
    self.db.flush()

mark_failed(job, error_message)

Mark download job as failed and set error message.

Parameters:

Name Type Description Default
job DownloadJobModel

Download job entity to mark as failed.

required
error_message str

Error message describing the failure.

required
Note

Does not commit. Caller must handle db.commit().

Source code in backend/src/domains/llms/repository.py
def mark_failed(self, job: DownloadJobModel, error_message: str) -> None:
    """Mark download job as failed and set error message.

    Args:
        job: Download job entity to mark as failed.
        error_message: Error message describing the failure.

    Note:
        Does not commit. Caller must handle db.commit().
    """
    logger.error(f"Marking job {job.id} as failed: {error_message}")
    job.status = "failed"
    job.error_message = error_message
    self.db.flush()

update_progress(job, total_bytes=None, progress=None, elapsed_seconds=None, eta_seconds=None)

Update download job progress metrics.

Parameters:

Name Type Description Default
job DownloadJobModel

Download job entity to update.

required
total_bytes Optional[float]

Total download size in bytes.

None
progress Optional[float]

Progress percentage (0.0-100.0).

None
elapsed_seconds Optional[float]

Total elapsed time in seconds.

None
eta_seconds Optional[float]

Estimated time remaining in seconds.

None
Note

Does not commit. Caller must handle db.commit().

Source code in backend/src/domains/llms/repository.py
def update_progress(
    self,
    job: DownloadJobModel,
    total_bytes: Optional[float] = None,
    progress: Optional[float] = None,
    elapsed_seconds: Optional[float] = None,
    eta_seconds: Optional[float] = None,
) -> None:
    """Update download job progress metrics.

    Args:
        job: Download job entity to update.
        total_bytes: Total download size in bytes.
        progress: Progress percentage (0.0-100.0).
        elapsed_seconds: Total elapsed time in seconds.
        eta_seconds: Estimated time remaining in seconds.

    Note:
        Does not commit. Caller must handle db.commit().
    """
    if total_bytes is not None:
        job.total_bytes = total_bytes
    if progress is not None:
        job.progress = progress
    if elapsed_seconds is not None:
        job.total_time_elapsed = elapsed_seconds
    if eta_seconds is not None:
        job.time_left = eta_seconds
    self.db.flush()

update_status(job, status, error_message=None)

Update download job status and optionally set error message.

Parameters:

Name Type Description Default
job DownloadJobModel

Download job entity to update.

required
status str

New status (e.g., "running", "completed", "failed").

required
error_message Optional[str]

Optional error message if status="failed".

None
Note

Does not commit. Caller must handle db.commit().

Source code in backend/src/domains/llms/repository.py
def update_status(
    self, job: DownloadJobModel, status: str, error_message: Optional[str] = None
) -> None:
    """Update download job status and optionally set error message.

    Args:
        job: Download job entity to update.
        status: New status (e.g., "running", "completed", "failed").
        error_message: Optional error message if status="failed".

    Note:
        Does not commit. Caller must handle db.commit().
    """
    logger.info(f"Updating job {job.id} status: {status}")
    job.status = status
    if error_message:
        job.error_message = error_message
    self.db.flush()

Llm_Repository(db)

Repository for LLM entity database operations.

Handles all CRUD operations and queries for the LLM catalog, including filtering by download state (local/remote) and search functionality.

Attributes:

Name Type Description
db

SQLAlchemy database session (injected by FastAPI).

Example

llm_repo = Llm_Repository(db) local_llms = llm_repo.get_all_local() llm = llm_repo.create(name="Llama-3-8B", link="meta-llama/...", local=0)

Initialize repository with database session.

Parameters:

Name Type Description Default
db Session

SQLAlchemy session for database operations.

required
Source code in backend/src/domains/llms/repository.py
def __init__(self, db: Session):
    """Initialize repository with database session.

    Args:
        db: SQLAlchemy session for database operations.
    """
    self.db = db
    logger.debug("Initializing Llm_Repository")

count_conversations(llm_id)

Number of conversations currently bound to this model.

Parameters:

Name Type Description Default
llm_id int

The model id to count conversations for.

required

Returns:

Name Type Description
int int

Count of conversations with this llm_id.

Source code in backend/src/domains/llms/repository.py
def count_conversations(self, llm_id: int) -> int:
    """Number of conversations currently bound to this model.

    Args:
        llm_id: The model id to count conversations for.

    Returns:
        int: Count of conversations with this llm_id.
    """
    from src.entities.Conversation import Conversation

    return self.db.query(Conversation).filter(Conversation.llm_id == llm_id).count()

create(name, local, type, description=None, model_metadata=None, quantized=False, param_size=None, link=None, category='general', generation_hints=None, artifact_size_bytes=None)

Create a new LLM catalog entry.

Parameters:

Name Type Description Default
name str

Human-readable model name.

required
local int

Download state (0=remote, 1=local, 2=downloading).

required
type str

Model family (e.g., "llama", "qwen").

required
description Optional[str]

Optional model description.

None
model_metadata Optional[str]

Optional JSON metadata string.

None
quantized bool

Quantization state (False=not quantized, True=pre-quantized).

False
param_size Optional[float]

Model size in billions of parameters, or None if unmeasured (#201).

None
link Optional[str]

HuggingFace repo ID or local path.

None
artifact_size_bytes Optional[int]

Real download size in bytes, or None if unknown.

None

Returns:

Name Type Description
Llm Llm

Created LLM entity (not yet committed, use flush()).

Source code in backend/src/domains/llms/repository.py
def create(
    self,
    name: str,
    local: int,
    type: str,
    description: Optional[str] = None,
    model_metadata: Optional[str] = None,
    quantized: bool = False,
    param_size: Optional[float] = None,
    link: Optional[str] = None,
    category: str = "general",
    generation_hints: Optional[dict] = None,
    artifact_size_bytes: Optional[int] = None,
) -> Llm:
    """Create a new LLM catalog entry.

    Args:
        name: Human-readable model name.
        local: Download state (0=remote, 1=local, 2=downloading).
        type: Model family (e.g., "llama", "qwen").
        description: Optional model description.
        model_metadata: Optional JSON metadata string.
        quantized: Quantization state (False=not quantized, True=pre-quantized).
        param_size: Model size in billions of parameters, or None if unmeasured (#201).
        link: HuggingFace repo ID or local path.
        artifact_size_bytes: Real download size in bytes, or None if unknown.

    Returns:
        Llm: Created LLM entity (not yet committed, use flush()).
    """
    logger.info(f"Creating LLM: {name} (local={local})")
    llm = Llm(
        name=name,
        local=local,
        type=type,
        description=description,
        model_metadata=model_metadata,
        quantized=quantized,
        param_size=param_size,
        link=link,
        category=category,
        generation_hints=generation_hints,
        artifact_size_bytes=artifact_size_bytes,
    )
    self.db.add(llm)
    self.db.flush()
    self.db.refresh(llm)
    logger.info(f"Created LLM {llm.id}: {llm.name}")
    return llm

delete(llm)

Delete LLM entity and optionally clean up local files.

Parameters:

Name Type Description Default
llm Llm

LLM entity to delete.

required
Note

Does not commit. Caller must handle db.commit() and file cleanup.

Source code in backend/src/domains/llms/repository.py
def delete(self, llm: Llm) -> None:
    """Delete LLM entity and optionally clean up local files.

    Args:
        llm: LLM entity to delete.

    Note:
        Does not commit. Caller must handle db.commit() and file cleanup.
    """
    logger.info(f"Deleting LLM {llm.id}: {llm.name}")

    self.db.delete(llm)
    self.db.flush()

get_all()

Retrieve all LLMs (local and remote).

Returns:

Type Description
List[Llm]

List[Llm]: All LLM records ordered by ID.

Source code in backend/src/domains/llms/repository.py
def get_all(self) -> List[Llm]:
    """Retrieve all LLMs (local and remote).

    Returns:
        List[Llm]: All LLM records ordered by ID.
    """
    logger.debug("Retrieving all LLMs")
    return self.db.query(Llm).order_by(Llm.id).all()

get_all_local()

Retrieve only local (downloaded) LLMs ready for inference.

Returns:

Type Description
List[Llm]

List[Llm]: LLMs with local=1, ordered by ID.

Source code in backend/src/domains/llms/repository.py
def get_all_local(self) -> List[Llm]:
    """Retrieve only local (downloaded) LLMs ready for inference.

    Returns:
        List[Llm]: LLMs with local=1, ordered by ID.
    """
    logger.debug("Retrieving local LLMs")
    return self.db.query(Llm).filter(Llm.local == 1).order_by(Llm.id).all()

get_all_remote()

Retrieve only remote (HuggingFace) LLMs available for download.

Returns:

Type Description
List[Llm]

List[Llm]: LLMs with local=0, ordered by ID.

Source code in backend/src/domains/llms/repository.py
def get_all_remote(self) -> List[Llm]:
    """Retrieve only remote (HuggingFace) LLMs available for download.

    Returns:
        List[Llm]: LLMs with local=0, ordered by ID.
    """
    logger.debug("Retrieving remote LLMs")
    return self.db.query(Llm).filter(Llm.local == 0).order_by(Llm.id).all()

get_by_id(llm_id)

Retrieve LLM by primary key.

Parameters:

Name Type Description Default
llm_id int

ID of the LLM to retrieve.

required

Returns:

Type Description
Optional[Llm]

Llm if found, None otherwise.

Source code in backend/src/domains/llms/repository.py
def get_by_id(self, llm_id: int) -> Optional[Llm]:
    """Retrieve LLM by primary key.

    Args:
        llm_id: ID of the LLM to retrieve.

    Returns:
        Llm if found, None otherwise.
    """
    logger.debug(f"Retrieving LLM by ID: {llm_id}")
    return self.db.query(Llm).filter(Llm.id == llm_id).first()

get_dependent_assistants(base_llm)

KB assistants that share this model's weights (COPIED link, #209).

Dependents = local rows with is_attached_to_kb truthy and the SAME link, excluding the model itself. Deleting the base leaves their link dangling, so callers surface them first. Empty when the model has no link (nothing to share).

Parameters:

Name Type Description Default
base_llm Llm

The base model whose dependents to find.

required

Returns:

Type Description
List[Llm]

List[Llm]: Dependent assistants ordered by id (possibly empty).

Source code in backend/src/domains/llms/repository.py
def get_dependent_assistants(self, base_llm: Llm) -> List[Llm]:
    """KB assistants that share this model's weights (COPIED link, #209).

    Dependents = local rows with is_attached_to_kb truthy and the SAME
    ``link``, excluding the model itself. Deleting the base leaves their
    link dangling, so callers surface them first. Empty when the model has
    no link (nothing to share).

    Args:
        base_llm: The base model whose dependents to find.

    Returns:
        List[Llm]: Dependent assistants ordered by id (possibly empty).
    """
    if not base_llm.link:
        return []
    return (
        self.db.query(Llm)
        .filter(
            Llm.id != base_llm.id,
            Llm.is_attached_to_kb.is_(True),
            Llm.link == base_llm.link,
        )
        .order_by(Llm.id)
        .all()
    )

search_by_name(name)

Search LLMs by name (case-insensitive partial match).

Parameters:

Name Type Description Default
name str

Search query string.

required

Returns:

Type Description
List[Llm]

List[Llm]: Matching LLMs ordered by relevance.

Source code in backend/src/domains/llms/repository.py
def search_by_name(self, name: str) -> List[Llm]:
    """Search LLMs by name (case-insensitive partial match).

    Args:
        name: Search query string.

    Returns:
        List[Llm]: Matching LLMs ordered by relevance.
    """
    logger.debug(f"Searching LLMs by name: {name}")
    return self.db.query(Llm).filter(Llm.name.ilike(f"%{name}%")).order_by(Llm.id).all()

update(llm, **kwargs)

Update LLM fields (partial update).

Parameters:

Name Type Description Default
llm Llm

LLM entity to update.

required
**kwargs

Fields to update (e.g., name="New Name", local=1).

{}

Returns:

Name Type Description
Llm Llm

Updated LLM entity (not yet committed, use flush()).

Source code in backend/src/domains/llms/repository.py
def update(self, llm: Llm, **kwargs) -> Llm:
    """Update LLM fields (partial update).

    Args:
        llm: LLM entity to update.
        **kwargs: Fields to update (e.g., name="New Name", local=1).

    Returns:
        Llm: Updated LLM entity (not yet committed, use flush()).
    """
    logger.info(f"Updating LLM {llm.id}: {kwargs}")
    for key, value in kwargs.items():
        if hasattr(llm, key):
            setattr(llm, key, value)
    self.db.flush()
    self.db.refresh(llm)
    return llm

detect_supports_tools(local_path)

Static tool-calling capability for a freshly downloaded model (#84).

Reads the model's chat template via the active engine. Returns None (the column stays unset) when the engine or path is unavailable, or on any failure, so a detection problem never blocks download finalization and the model simply routes through the systematic KB path until recomputed.

Source code in backend/src/domains/llms/repository.py
def detect_supports_tools(local_path: Optional[str]) -> Optional[bool]:
    """Static tool-calling capability for a freshly downloaded model (#84).

    Reads the model's chat template via the active engine. Returns None (the
    column stays unset) when the engine or path is unavailable, or on any
    failure, so a detection problem never blocks download finalization and the
    model simply routes through the systematic KB path until recomputed.
    """
    engine = config.LLM_Engine
    if engine is None or not local_path:
        return None
    try:
        return engine.compute_supports_tools(local_path)
    except Exception:
        logger.warning(f"tool-calling detection failed for {local_path}")
        return None

detect_supports_vision(local_path)

Static vision (image-input) capability via the active engine (#133).

Deterministic, no model load (mmproj presence for llama.cpp, config.json for MLX). Returns None when the engine/path is unavailable or detection fails; the runtime treats None as not vision-capable (#212): images are stripped and the user is notified unless the capability is an explicit True.

Source code in backend/src/domains/llms/repository.py
def detect_supports_vision(local_path: Optional[str]) -> Optional[bool]:
    """Static vision (image-input) capability via the active engine (#133).

    Deterministic, no model load (mmproj presence for llama.cpp, ``config.json``
    for MLX). Returns None when the engine/path is unavailable or detection
    fails; the runtime treats None as not vision-capable (#212): images are
    stripped and the user is notified unless the capability is an explicit True.
    """
    engine = config.LLM_Engine
    if engine is None or not local_path:
        return None
    try:
        return engine.model_supports_vision(local_path)
    except Exception:
        logger.warning(f"vision detection failed for {local_path}")
        return None

detect_wire_tools(local_path)

Verified tool-call wire capability via the active engine (#298).

Whether the engine's local server actually parses this model's tool-call output into a structured call (per-model wire property, #273 matrix) — not just whether the template declares tools (detect_supports_tools). Tokenizer-level, no model load. Returns None (column stays unset -> the KB routing stays systematic) when the engine or path is unavailable, or on any failure, so detection never blocks download finalization or startup.

Source code in backend/src/domains/llms/repository.py
def detect_wire_tools(local_path: Optional[str]) -> Optional[bool]:
    """Verified tool-call wire capability via the active engine (#298).

    Whether the engine's local server actually parses this model's tool-call
    output into a structured call (per-model wire property, #273 matrix) —
    not just whether the template declares tools (``detect_supports_tools``).
    Tokenizer-level, no model load. Returns None (column stays unset -> the KB
    routing stays systematic) when the engine or path is unavailable, or on
    any failure, so detection never blocks download finalization or startup.
    """
    engine = config.LLM_Engine
    if engine is None or not local_path:
        return None
    try:
        return engine.compute_wire_tools(local_path)
    except Exception:
        logger.warning(f"wire tool-calling detection failed for {local_path}")
        return None

dir_size_bytes(path)

Total size of everything under path, or 0 if it does not exist.

Source code in backend/src/domains/llms/repository.py
def dir_size_bytes(path) -> int:
    """Total size of everything under `path`, or 0 if it does not exist."""
    total = 0
    for root, _dirs, files in os.walk(str(path)):
        for name in files:
            try:
                total += os.path.getsize(os.path.join(root, name))
            except OSError:
                pass  # vanished mid-walk, or unreadable: not our bytes to count
    return total

remove_tree_reporting(path, attempts=5, delay=0.4)

Delete a directory tree; return how many bytes are still there after.

shutil.rmtree(..., ignore_errors=True) is the wrong tool for download artifacts on Windows. POSIX lets you unlink a file another thread still has open, so a cancel that races the downloader cleans up fine there. Windows refuses, rmtree fails, and ignore_errors throws the failure away -- which is how a cancelled 1B download left 230 MB of temp_651 behind while the log said "deleted temp LLM 651".

Cancellation is only signalled, never awaited, so the downloader may still hold handles for a moment. Retry briefly to let it notice and let go, then report what survived instead of pretending it is gone.

Source code in backend/src/domains/llms/repository.py
def remove_tree_reporting(path, attempts: int = 5, delay: float = 0.4) -> int:
    """Delete a directory tree; return how many bytes are still there after.

    `shutil.rmtree(..., ignore_errors=True)` is the wrong tool for download
    artifacts on Windows. POSIX lets you unlink a file another thread still has
    open, so a cancel that races the downloader cleans up fine there. Windows
    refuses, rmtree fails, and ignore_errors throws the failure away -- which is
    how a cancelled 1B download left 230 MB of temp_651 behind while the log
    said "deleted temp LLM 651".

    Cancellation is only signalled, never awaited, so the downloader may still
    hold handles for a moment. Retry briefly to let it notice and let go, then
    report what survived instead of pretending it is gone.
    """
    path = str(path)
    for attempt in range(attempts):
        if not os.path.exists(path):
            return 0
        shutil.rmtree(path, ignore_errors=True)
        if not os.path.exists(path):
            return 0
        if attempt < attempts - 1:
            time.sleep(delay)
    return dir_size_bytes(path)

update_db_with_progress(job_tracker, job_id, model_id)

Background thread function that polls DownloadTracker and persists progress to database.

Runs in daemon thread (started by download_llm service). Loops at 1Hz polling job.percent, updates DownloadJobModel row with progress/ETA/elapsed time. On completion, sets status="completed" and llm.local=1. On exception, marks status="failed" and cleans up temp files and LLM entry.

This function is repository-level because its only job is database interaction. It runs in a separate thread with its own SessionLocal() instance to avoid SQLAlchemy threading conflicts with the main request thread.

Parameters:

Name Type Description Default
job_tracker

DownloadTracker instance to poll for progress state (from services.py).

required
job_id int

Database primary key of DownloadJobModel to update.

required
model_id int

LLM ID to mark local=1 on successful completion.

required
Warning

Runs in separate thread - uses separate SessionLocal() instance to avoid SQLAlchemy threading conflicts. This is the ONLY place in the codebase where we create a session outside the FastAPI request lifecycle.

Example

import threading from src.domains.llms.services import DownloadTracker tracker = DownloadTracker() threading.Thread( ... target=update_db_with_progress, ... args=(tracker, 42, 15), ... daemon=True ... ).start()

Source code in backend/src/domains/llms/repository.py
def update_db_with_progress(job_tracker, job_id: int, model_id: int) -> None:
    """Background thread function that polls DownloadTracker and persists progress to database.

    Runs in daemon thread (started by download_llm service). Loops at 1Hz polling job.percent,
    updates DownloadJobModel row with progress/ETA/elapsed time. On completion, sets
    status="completed" and llm.local=1. On exception, marks status="failed" and cleans
    up temp files and LLM entry.

    This function is repository-level because its only job is database interaction. It runs
    in a separate thread with its own SessionLocal() instance to avoid SQLAlchemy threading
    conflicts with the main request thread.

    Args:
        job_tracker: DownloadTracker instance to poll for progress state (from services.py).
        job_id: Database primary key of DownloadJobModel to update.
        model_id: LLM ID to mark local=1 on successful completion.

    Warning:
        Runs in separate thread - uses separate SessionLocal() instance to avoid
        SQLAlchemy threading conflicts. This is the ONLY place in the codebase where
        we create a session outside the FastAPI request lifecycle.

    Example:
        >>> import threading
        >>> from src.domains.llms.services import DownloadTracker
        >>> tracker = DownloadTracker()
        >>> threading.Thread(
        ...     target=update_db_with_progress,
        ...     args=(tracker, 42, 15),
        ...     daemon=True
        ... ).start()
    """
    session = SessionLocal()
    try:
        dbj = session.query(DownloadJobModel).get(job_id)
        llm = session.query(Llm).get(model_id)
        if not dbj or not llm:
            logger.error(f"DB row for job {job_id} or model {model_id} not found")
            return

        start_time = datetime.now(timezone.utc)
        # Loop until download completes or is cancelled
        while job_tracker.percent < 100.0:
            time.sleep(1)
            # Exit cleanly if the job was cancelled (cancel endpoint handles DB cleanup)
            if not job_tracker.should_continue():
                logger.info(f"Job {job_id} cancelled, stopping progress tracking")
                return
            dbj.total_bytes = job_tracker.total_bytes
            dbj.progress = job_tracker.percent
            dbj.total_time_elapsed = (datetime.now(timezone.utc) - start_time).total_seconds()
            dbj.time_left = job_tracker.eta_seconds or 0.0
            session.commit()
            logger.debug(f"Job {job_id}: {dbj.progress:.2f}% complete")

        # Finalization (status=completed, local=1, supports_tools) is handled by
        # _run_download_task in its own session after download_llm returns. That
        # guarantees completion is recorded even if this thread's session hangs.
        logger.info(f"Job {job_id} progress tracking finished")

    except Exception as e:
        logger.exception(f"Job {job_id} failed during progress tracking")
        try:
            dbj.status = "failed"
            dbj.error_message = str(e)

            # Clean up temp files
            if dbj.temp_local_model_link:
                if os.path.exists(dbj.temp_local_model_link):
                    shutil.rmtree(dbj.temp_local_model_link, ignore_errors=True)
                if "temp" not in dbj.temp_local_model_link:
                    temp_fallback = config.LLM_DIR / f"temp_{model_id}"
                    if temp_fallback.exists():
                        shutil.rmtree(temp_fallback, ignore_errors=True)

            if dbj.final_local_model_link and os.path.exists(dbj.final_local_model_link):
                shutil.rmtree(dbj.final_local_model_link, ignore_errors=True)

            # Delete the temp LLM entry
            session.delete(llm)
            session.commit()

        except Exception as cleanup_error:
            logger.exception(f"Failed to cleanup after job {job_id} failure: {cleanup_error}")

    finally:
        session.close()

Schemas (Pydantic Models)

src.domains.llms.schemas

Pydantic validation schemas for LLM management and download jobs.

This module defines data transfer objects (DTOs) for the LLMs domain, separating: - LLM metadata: Model catalog entries (name, link, quantization status). - Download jobs: Background tasks for downloading and quantizing models from HuggingFace.

Schema Hierarchy: - LLMBase → LLMCreate, LLMResponse (inheritance pattern). - DownloadJobResponse (standalone, aliased fields for API compatibility).

Model State Encoding (local field): - 0: Remote only (browsable but not downloaded). - 1: Local (downloaded and ready for inference). - 2: Downloading (temporary placeholder during download).

Quantization Status (quantized field): - False: Not quantized (full precision or unknown). - True: Pre-quantized (model already in 4-bit/8-bit format).

Example

from src.domains.llms.schemas import LLMCreate, DownloadJobResponse from fastapi import FastAPI

app = FastAPI()

@app.post("/llms", response_model=LLMResponse) def create_llm(llm: LLMCreate): return db.create(llm)

DependentAssistant

Bases: BaseModel

One KB assistant that shares a base model's weights (COPIED link, #209).

Deleting the base would leave this assistant's link dangling (orphaned), hence it is surfaced before the delete so the user can decide.

DependentsResponse

Bases: BaseModel

What a base-model deletion would affect (GET /llms/{id}/dependents, and the payload of the 409 raised by DELETE without orphan_dependents).

Attributes:

Name Type Description
assistants List[DependentAssistant]

KB assistants sharing the model's weights (empty when none).

own_conversation_count int

Conversations bound to the model itself.

total_conversation_count int

own + every dependent assistant's count.

DownloadJobResponse

Bases: BaseModel

Response schema for download job status with progress tracking.

Encodes the state of a background download task, including progress metrics, file paths, and error information. The job_id field is aliased from 'id' to avoid conflicts with LLM IDs in API responses.

Attributes:

Name Type Description
job_id int

Database primary key (aliased from 'id').

remote_model_id str

HuggingFace model ID (e.g., "meta-llama/Llama-3-8B").

local_model_id Optional[int]

ID of temp LLM entry created during download, None once that entry is deleted (cleanup nulls the FK server-side).

remote_model_link str

HuggingFace URL or API endpoint.

local_model_link Optional[str]

Filesystem path to final model after quantization.

status str

Job state - "pending", "running", "completed", "failed", "cancelled".

total_bytes float

Total download size in bytes.

progress float

Download progress percentage (0.0-100.0).

total_time_elapsed float

Seconds elapsed since job start.

time_left float

Estimated seconds remaining (updated each poll).

error_message Optional[str]

Exception message if status="failed", None otherwise.

created_at datetime

Job creation timestamp.

updated_at Optional[datetime]

Last status update timestamp (used for stale job detection).

Example

job = DownloadJobResponse( ... id=42, remote_model_id="Qwen/Qwen2.5-7B-Instruct", status="running", ... progress=65.0, time_left=120.5, ... ... ) print(job.job_id) # 42 (aliased from 'id')

Config

Pydantic configuration for DownloadJobResponse model.

Enables ORM mode and validates field names by alias (job_id aliased from id).

HFDownloadRequest

Bases: BaseModel

Body for POST /download/huggingface — download a model picked from HF search by its repo id, without it having to exist in the catalog first.

HFSearchResult

Bases: BaseModel

A live HuggingFace search hit (not a persisted catalog row).

Returned by GET /search/huggingface. The frontend renders these and POSTs the chosen one to POST /download/huggingface (by link), so results never pollute the curated catalog.

LLMBase

Bases: BaseModel

Base schema for LLM metadata with minimal required fields.

Attributes:

Name Type Description
name str

Human-readable model name (e.g., "llama-3-8b-instruct").

local int

Download state - 0=remote, 1=local/ready, 2=downloading.

link str

HuggingFace model ID or local filesystem path.

LLMCreate

Bases: LLMBase

Request schema for creating a new LLM catalog entry.

Inherits all fields from LLMBase. Used when seeding database from HuggingFace or manually registering a model.

Example

llm_data = LLMCreate(name="Qwen2.5-7B", local=0, link="Qwen/Qwen2.5-7B-Instruct") response = requests.post("/llms", json=llm_data.model_dump())

LLMResponse

Bases: LLMBase

Response schema with full LLM metadata including database ID.

Extends LLMBase with optional fields for detailed model information. Used by all read endpoints (list, get, search).

Attributes:

Name Type Description
id int

Database primary key.

type Optional[str]

Model family (e.g., "llama", "qwen", "mistral").

description Optional[str]

Model description from HuggingFace or user annotation.

model_metadata Optional[str]

JSON string with additional metadata (vocab size, context length).

quantized Optional[bool]

Boolean - False=not quantized, True=pre-quantized (already in 4-bit/8-bit format).

param_size Optional[float]

Model size in billions of parameters (positive), or None if unmeasured (#201).

allocated_context_window property

The window the engine's loaded child actually runs with, or None.

Non-None ONLY when this row is the CURRENTLY LOADED model: the value (BaseEngine.effective_context_tokens) belongs to a running child, depends on the machine and its memory state (llama-server's fit can shrink it below context_window), and is never persisted. Computed on the fly like runnable: cheap attribute reads, no I/O, and any failure degrades to None (window unknown).

context_window property

The model's TRAINED context window in tokens, or None when unknown.

A fact of the artifact (generation_hints.context_length: the GGUF metadata for llama.cpp entries, config.json elsewhere) -- it survives restarts and machines, unlike allocated_context_window below. First-class here so the UI does not dig through the hints blob.

runnable property

Whether this model can run on the active engine's hardware.

Computed on the fly (no DB column). The catalog is built only from repos in the engine's format (filter=FORMAT_TAG), so every entry is runnable by construction; the lone exception is a KNOWN_BROKEN quant that load-crashes on this engine (see BaseEngine.is_runnable). Defaults to True when the engine is unknown, so nothing is hidden by accident.

sampling_defaults property

Per-model sampling defaults resolved at read time (#388): the values a new conversation / arena panel seeds its sliders from. Pure function over this row (captured generation_config, else the neutral constants), no DB column.

supports_vision property

Image-input capability, derived for downloaded models (#133).

Computed on the fly (no DB column), like runnable: the engine reads the artifact (mmproj projector for llama.cpp, config.json for MLX). Only meaningful once downloaded, so remote rows stay None. None = unknown and the UI treats it as permissive — it disables the image attach button only on an explicit False, never blocking a real VLM by accident.

Short-circuits to None when the weights are known to be gone (weights_available False, #376): probing a path that cannot exist would make the engine raise a 500-class exception whose constructor logs an ERROR on every listing poll, for a request that returns 200.

weights_available property

Whether the model's weights still exist on disk (#208).

Computed on the fly (no DB column), like supports_vision: it is self-healing, so a base model whose files were removed to orphan its KB assistants reports False here without any migration. Only meaningful for a downloaded model, so remote/downloading rows stay None. A local model with a missing/empty link, or a link that no longer exists on disk, reports False -- the UI reads this to prompt a rebind onto a new base.

Config

Pydantic configuration for LLMResponse model.

Enables ORM mode to directly convert SQLAlchemy Llm entities to Pydantic models.

RebindRequest

Bases: BaseModel

Body for POST /llms/{assistant_id}/rebind -- re-point an orphaned KB assistant at a new base model whose weights exist.

SamplingDefaultsResponse

Bases: BaseModel

Resolved per-model sampling defaults (#388), the shape src.database.generation_hints.SamplingDefaults.to_dict produces.

source says which capture stage the values came from (base_generation_config / quant_generation_config / model_card) or none when the publisher gives no usable recommendation and the neutral defaults apply -- the UI tells the user in that case. evidence is the model-card sentence the values were read from (model_card only; debugging aid, not displayed). top_k / min_p / presence_penalty are None unless the captured block defines them (and are then the only case the backend puts them on the wire). max_tokens_cap is no longer a UI field ceiling -- there is no max-tokens field: it is the model-and-engine cap that max_tokens (the FALLBACK output budget, used only when the engine cannot report the window it loaded with) is clamped to, min(model context window, engine context window).

Services (Business Logic)

src.domains.llms.services

Business logic for LLM download orchestration with progress tracking.

This module manages the complete lifecycle of downloading LLM models from HuggingFace and tracking progress in real-time. Every catalog link is a pre-built engine-format quant, so downloads are moved into place with no local conversion.

Architecture
  1. download_llm() orchestrates the full pipeline (select files → download → move).
  2. _select_download_files() picks the exact files to fetch (single best GGUF quant).
  3. DownloadTracker monitors progress and ETA across concurrent file downloads.
  4. make_callback() creates fsspec hooks to update DownloadTracker on each chunk and to abort the transfer (DownloadCancelled) once the tracker is cancelled.
  5. update_db_with_progress() runs in background thread to persist status to DB.
  6. download_files_concurrent() parallelizes .safetensors shard downloads.
Engine Integration
  • Every catalog link is a pre-built engine-format quant; no local conversion.
  • GGUF engines (USES_GGUF) download one best quant file + mmproj + small aux files.
Download Flow

┌─────────────┐ │ HuggingFace │ │ Repository │ └──────┬──────┘ │ (1) Select files & compute total bytes ↓ ┌──────────────┐ │ temp_save_dir│ ← Download model weights + config files └──────┬───────┘ │ (2) shutil.move ↓ ┌──────────────┐ │final_save_dir│ ← Ready for inference └──────────────┘

Example

from src.domains.llms.services import download_llm import asyncio

Download a pre-built quant of Llama-3-8B

final_path = asyncio.run(download_llm( model_link="meta-llama/Llama-3-8B-Instruct", model_id=42, temp_save_dir=config.LLM_DIR / "temp_42", final_save_dir=config.LLM_DIR / "42", job_id=15 ))

→ Downloads to temp, moves to final, updates job #15

DownloadCancelled

Bases: Exception

Raised from inside a transfer once its DownloadTracker has been cancelled.

Cancellation is signal-and-check (#372): cancel_download_job flips the tracker flag and returns. This exception is how the download side reacts to that flag from where it is looked at -- before each shard starts and on every progress chunk -- so a cancelled job stops transferring instead of running every remaining shard to the end (#377). download_llm catches it and returns early; it never reaches the endpoint.

DownloadTracker()

Thread-safe progress tracker for multi-file downloads with ETA estimation.

Aggregates downloaded bytes across concurrent file transfers and periodically computes ETA based on moving average of download rate. Used by fsspec callbacks and background DB updater thread.

Attributes:

Name Type Description
total_bytes int

Total download size in bytes (set before download starts).

downloaded_bytes int

Bytes downloaded so far (updated by callbacks).

eta_seconds Optional[float]

Estimated seconds remaining (None until first ETA computation).

Example

tracker = DownloadTracker() tracker.total_bytes = 1_000_000_000 tracker.update(50_000_000) print(tracker.percent) 5.0

Source code in backend/src/domains/llms/services.py
def __init__(self) -> None:
    self.total_bytes: int = 0
    self.downloaded_bytes: int = 0
    self.eta_seconds: Optional[float] = None
    self._lock = Lock()
    self._cancelled: bool = False
    logger.info("DownloadJob initialized")

percent property

Compute download completion percentage with zero-division guard.

Returns:

Type Description
float

Progress percentage in range [0.0, 100.0]. Returns 0.0 if total_bytes is 0.

cancel()

Signal download cancellation (thread-safe).

Source code in backend/src/domains/llms/services.py
def cancel(self) -> None:
    """Signal download cancellation (thread-safe)."""
    with self._lock:
        self._cancelled = True

monitor_eta(interval=20.0) async

Continuously estimate remaining time based on download rate (runs until 100%).

Samples downloaded_bytes at regular intervals, computes delta rate, and updates eta_seconds. Runs as asyncio task in background until download completes.

Parameters:

Name Type Description Default
interval float

Seconds between ETA recalculations (default 20s).

20.0
Example

tracker = DownloadTracker() eta_task = asyncio.create_task(tracker.monitor_eta(interval=5.0))

... download happens in parallel ...

await eta_task # Wait for completion

Source code in backend/src/domains/llms/services.py
async def monitor_eta(self, interval: float = 20.0) -> None:
    """Continuously estimate remaining time based on download rate (runs until 100%).

    Samples downloaded_bytes at regular intervals, computes delta rate, and updates
    eta_seconds. Runs as asyncio task in background until download completes.

    Args:
        interval: Seconds between ETA recalculations (default 20s).

    Example:
        >>> tracker = DownloadTracker()
        >>> eta_task = asyncio.create_task(tracker.monitor_eta(interval=5.0))
        >>> # ... download happens in parallel ...
        >>> await eta_task  # Wait for completion
    """
    logger.info("Starting ETA monitoring")
    last_time = time.time()
    last_downloaded = 0
    while True:
        await asyncio.sleep(interval)
        with self._lock:
            current = self.downloaded_bytes
            total = self.total_bytes
        if current >= total:
            break

        now = time.time()
        delta_bytes = current - last_downloaded
        delta_time = now - last_time

        if delta_time > 0 and delta_bytes > 0:
            rate = delta_bytes / delta_time
            self.eta_seconds = (total - current) / rate

        last_time = now
        last_downloaded = current

    logger.info("ETA monitoring complete")

should_continue()

Return False if cancelled, True otherwise (thread-safe).

Source code in backend/src/domains/llms/services.py
def should_continue(self) -> bool:
    """Return False if cancelled, True otherwise (thread-safe)."""
    with self._lock:
        return not self._cancelled

update(bytes_downloaded)

Atomically increment downloaded byte count (thread-safe).

Parameters:

Name Type Description Default
bytes_downloaded int

Number of bytes downloaded in this chunk transfer.

required
Source code in backend/src/domains/llms/services.py
def update(self, bytes_downloaded: int) -> None:
    """Atomically increment downloaded byte count (thread-safe).

    Args:
        bytes_downloaded: Number of bytes downloaded in this chunk transfer.
    """
    with self._lock:
        self.downloaded_bytes += bytes_downloaded

cancel_download_job(job_id, job_repo, llm_repo, db)

Cancel an active download job, signal the tracker, and clean up partial files.

Parameters:

Name Type Description Default
job_id int

ID of the download job to cancel.

required
job_repo

Download_Job_Repository instance.

required
llm_repo

Llm_Repository instance.

required
db

SQLAlchemy session for transaction control.

required

Returns:

Type Description
dict

dict with cancellation status.

Raises:

Type Description
DownloadJobNotFoundException

no job with this id.

ModelNotFoundException

the job's model row is gone.

InvalidInputException

the job is not in a cancellable state.

StateConflictException

the transfer already finished.

Source code in backend/src/domains/llms/services.py
def cancel_download_job(job_id: int, job_repo, llm_repo, db) -> dict:
    """Cancel an active download job, signal the tracker, and clean up partial files.

    Args:
        job_id: ID of the download job to cancel.
        job_repo: Download_Job_Repository instance.
        llm_repo: Llm_Repository instance.
        db: SQLAlchemy session for transaction control.

    Returns:
        dict with cancellation status.

    Raises:
        DownloadJobNotFoundException: no job with this id.
        ModelNotFoundException: the job's model row is gone.
        InvalidInputException: the job is not in a cancellable state.
        StateConflictException: the transfer already finished.
    """
    job = job_repo.get_by_id(job_id)
    if not job:
        raise DownloadJobNotFoundException(job_id)
    if job.status in ["completed", "failed", "cancelled"]:
        raise StateConflictException(
            "Cannot cancel a job that is already completed, failed, or cancelled"
        )

    llm = llm_repo.get_by_id(job.local_model_id)
    if not llm:
        raise ModelNotFoundException(f"LLM {job.local_model_id}")
    if llm.local != 2:
        raise InvalidInputException("Download ended - cannot be cancelled, please delete the model")

    # Signal running download thread to stop
    tracker = get_active_download_tracker(job_id)
    if tracker:
        tracker.cancel()
        logger.info(f"Signaled cancellation for download job {job_id}")
    else:
        logger.warning(f"No active tracker for job {job_id}, proceeding with cleanup")

    # Clean up files and remove the pending LLM entry
    job_repo.update_status(job, "cancelled")
    job_repo.cleanup_job_files(job)
    llm_repo.delete(llm)
    db.commit()

    logger.info(f"Cancelled download job {job_id} and deleted temp LLM {llm.id}")
    return {"message": "Download cancellation initiated", "job_id": job_id, "status": "cancelled"}

download_files_concurrent(fs, callback, tasks, local_dir, tracker=None) async

Download multiple HuggingFace files in parallel using asyncio executor pool.

Wraps synchronous fs.get_file() calls in run_in_executor to achieve I/O concurrency. Useful for downloading .safetensors shards simultaneously to maximize bandwidth.

Parameters:

Name Type Description Default
fs HfFileSystem

HfFileSystem instance with auth token.

required
callback Callback

fsspec Callback for progress tracking (shared across all files).

required
tasks List[Tuple[str, str]]

List of (repo_id, file_path) tuples to download.

required
local_dir str

Base directory to save files (subdirectories created as needed).

required
tracker Optional[DownloadTracker]

When given, each shard checks tracker.should_continue() right before its transfer starts and raises DownloadCancelled otherwise, so a cancelled job never opens the shards still queued behind the in-flight ones (#377). The in-flight ones abort through the callback.

None

Raises:

Type Description
DownloadCancelled

the tracker was cancelled before or during a transfer.

Example

fs = HfFileSystem(token=HF_TOKEN) callback = make_callback(tracker) tasks = [("meta-llama/Llama-3-8B", "model-00001-of-00004.safetensors"), ...] await download_files_concurrent(fs, callback, tasks, config.LLM_DIR / "temp_42")

Source code in backend/src/domains/llms/services.py
async def download_files_concurrent(
    fs: HfFileSystem,
    callback: Callback,
    tasks: List[Tuple[str, str]],
    local_dir: str,
    tracker: Optional[DownloadTracker] = None,
) -> None:
    """Download multiple HuggingFace files in parallel using asyncio executor pool.

    Wraps synchronous fs.get_file() calls in run_in_executor to achieve I/O concurrency.
    Useful for downloading .safetensors shards simultaneously to maximize bandwidth.

    Args:
        fs: HfFileSystem instance with auth token.
        callback: fsspec Callback for progress tracking (shared across all files).
        tasks: List of (repo_id, file_path) tuples to download.
        local_dir: Base directory to save files (subdirectories created as needed).
        tracker: When given, each shard checks `tracker.should_continue()` right
            before its transfer starts and raises DownloadCancelled otherwise, so a
            cancelled job never opens the shards still queued behind the in-flight
            ones (#377). The in-flight ones abort through the callback.

    Raises:
        DownloadCancelled: the tracker was cancelled before or during a transfer.

    Example:
        >>> fs = HfFileSystem(token=HF_TOKEN)
        >>> callback = make_callback(tracker)
        >>> tasks = [("meta-llama/Llama-3-8B", "model-00001-of-00004.safetensors"), ...]
        >>> await download_files_concurrent(fs, callback, tasks, config.LLM_DIR / "temp_42")
    """
    loop = asyncio.get_running_loop()

    def get_file_unless_cancelled(remote: str, dest: str) -> None:
        if tracker is not None and not tracker.should_continue():
            raise DownloadCancelled(f"download cancelled before {remote} started")
        fs.get_file(remote, dest, callback)

    # Contain every destination BEFORE scheduling anything (#405): a hostile
    # member anywhere in the batch refuses the whole batch with no transfer
    # started and no directory created.
    dests = [(f"{repo_id}/{path}", resolve_member_path(local_dir, path)) for repo_id, path in tasks]
    coros = []
    for remote, dest in dests:
        dest.parent.mkdir(parents=True, exist_ok=True)
        coros.append(loop.run_in_executor(None, get_file_unless_cancelled, remote, str(dest)))
    await asyncio.gather(*coros)

download_llm(model_link, model_id, temp_save_dir, final_save_dir, job_id=None) async

Download a pre-built HuggingFace quant with progress tracking.

Complete pipeline: list repo files → select files to download → download to temp_save_dir → move to final_save_dir. Every catalog link is a pre-built quant, so no local conversion happens. Progress updates are persisted to DownloadJobModel if job_id provided.

Parameters:

Name Type Description Default
model_link str

HuggingFace repo ID (e.g., "meta-llama/Llama-3-8B-Instruct").

required
model_id int

Database ID of the LLM entry (used by the DB progress updater).

required
temp_save_dir str

Temp directory for the download (moved to final_save_dir on success).

required
final_save_dir str

Final directory for the model (ready for inference).

required
job_id Optional[int]

Optional DownloadJobModel ID for progress tracking (spawns background thread).

None

Returns:

Type Description
str

Path to temp_save_dir (note: moved to final_save_dir on success).

Raises:

Type Description
InvalidInputException

The repo ships no artefact in this engine's format (checked before any byte is transferred, #408).

Exception

If HuggingFace API fails or the download fails.

Example

final_path = await download_llm( ... model_link="meta-llama/Llama-3-8B-Instruct", ... model_id=42, ... temp_save_dir=config.LLM_DIR / "temp_42", ... final_save_dir=config.LLM_DIR / "42", ... job_id=15 ... )

Progress tracked in DownloadJobModel(id=15), final model in backend/data/models/42

Source code in backend/src/domains/llms/services.py
async def download_llm(
    model_link: str,
    model_id: int,
    temp_save_dir: str,
    final_save_dir: str,
    job_id: Optional[int] = None,
) -> str:
    """Download a pre-built HuggingFace quant with progress tracking.

    Complete pipeline: list repo files → select files to download → download to
    temp_save_dir → move to final_save_dir. Every catalog link is a pre-built quant,
    so no local conversion happens. Progress updates are persisted to
    DownloadJobModel if job_id provided.

    Args:
        model_link: HuggingFace repo ID (e.g., "meta-llama/Llama-3-8B-Instruct").
        model_id: Database ID of the LLM entry (used by the DB progress updater).
        temp_save_dir: Temp directory for the download (moved to final_save_dir on success).
        final_save_dir: Final directory for the model (ready for inference).
        job_id: Optional DownloadJobModel ID for progress tracking (spawns background thread).

    Returns:
        Path to temp_save_dir (note: moved to final_save_dir on success).

    Raises:
        InvalidInputException: The repo ships no artefact in this engine's format
            (checked before any byte is transferred, #408).
        Exception: If HuggingFace API fails or the download fails.

    Example:
        >>> final_path = await download_llm(
        ...     model_link="meta-llama/Llama-3-8B-Instruct",
        ...     model_id=42,
        ...     temp_save_dir=config.LLM_DIR / "temp_42",
        ...     final_save_dir=config.LLM_DIR / "42",
        ...     job_id=15
        ... )
        >>> # Progress tracked in DownloadJobModel(id=15), final model in backend/data/models/42
    """
    # The catalog link IS already a public engine-format quant (resolved at seed
    # time from a filter=FORMAT_TAG search), so we download it directly — no mapping.
    actual_download_link = model_link
    _assert_runnable(model_link)

    # Everything we download is a pre-built quant → no local conversion. GGUF repos
    # additionally need only the single best quant picked (pick_best_gguf), plus
    # every sibling part if that quant ships split (gguf_split_siblings).
    _uses_gguf = getattr(config.LLM_Engine, "USES_GGUF", False)

    # Prepare local path
    os.makedirs(temp_save_dir, exist_ok=True)
    os.makedirs(final_save_dir, exist_ok=True)
    logger.info(f"Starting download for {model_link} -> {temp_save_dir}")
    logger.info(f"Downloading pre-built quant directly: {actual_download_link}")

    # Initialize HF API & filesystem
    api = HfApi(token=HF_TOKEN)
    fs = HfFileSystem(token=HF_TOKEN)

    # Initialize tracking and register for cancellation support
    job = DownloadTracker()
    if job_id is not None:
        _register_tracker(job_id, job)

    try:
        # Gather file sizes, then select the exact files to download BEFORE
        # computing the total, so the logged size and progress reflect what is
        # actually fetched (a single GGUF quant, not the whole repo).
        info = api.repo_info(actual_download_link, files_metadata=True)
        file_sizes = {
            s.rfilename: s.size
            for s in info.siblings
            if s.size and s.rfilename not in FILES_TO_EXCLUDE
        }
        all_repo_files = list(api.list_repo_files(actual_download_link))
        # Refuse a repo with nothing this engine runs BEFORE any transfer (#408).
        _assert_repo_has_engine_artifact(actual_download_link, info, all_repo_files)
        selection = _select_download_files(all_repo_files, file_sizes, _uses_gguf)
        all_files = selection.files
        # Contain the WHOLE selection before a single byte is fetched (#405):
        # the names come from the Hub listing, so a hostile repo fails here
        # with a clear message and the job-failure path reports it.
        for path in all_files:
            resolve_member_path(temp_save_dir, path)
        if _uses_gguf:
            if selection.mmproj_files:
                logger.info(
                    f"GGUF download: {selection.best_gguf} + mmproj ({selection.mmproj_files[0]}) "
                    f"+ {len(selection.small_aux)} aux files"
                )
            else:
                logger.info(
                    f"GGUF download: {selection.best_gguf} + {len(selection.small_aux)} aux files"
                )

        job.total_bytes = sum(file_sizes.get(f, 0) for f in all_files)
        logger.info(f"Total size: {job.total_bytes} bytes")

        # Create progress callback
        callback = make_callback(job)
        callback.set_size(job.total_bytes)

        # Start DB updater if requested
        if job_id is not None:
            threading.Thread(
                target=update_db_with_progress, args=(job, job_id, model_id), daemon=True
            ).start()

        # Start ETA monitoring
        eta_task = asyncio.create_task(job.monitor_eta(interval=5.0))

        misc = [f for f in all_files if not f.endswith(".safetensors")]
        shards = [f for f in all_files if f.endswith(".safetensors")]

        try:
            # Download misc sequentially
            for path in misc:
                if not job.should_continue():
                    raise DownloadCancelled(f"download cancelled before {path} started")
                dest = resolve_member_path(temp_save_dir, path)
                dest.parent.mkdir(parents=True, exist_ok=True)
                await asyncio.to_thread(
                    fs.get_file, f"{actual_download_link}/{path}", str(dest), callback
                )
                logger.info(f"Downloaded {path}")

            # Download shards concurrently
            shard_tasks = [(actual_download_link, path) for path in shards]
            await download_files_concurrent(fs, callback, shard_tasks, temp_save_dir, tracker=job)
            logger.info("All shards downloaded")
        except DownloadCancelled:
            # Raised from the pre-shard check or from inside a transfer (#377).
            # The cancel endpoint already marked the job and cleaned the temp dir;
            # there is nothing to finalize and nothing to report as an error.
            eta_task.cancel()
            logger.info(
                f"Download job {job_id} was cancelled during transfer, skipping finalization"
            )
            return temp_save_dir

        # Cancel landed between the last chunk and here: same outcome, no finalization.
        if not job.should_continue():
            eta_task.cancel()
            logger.info(
                f"Download job {job_id} was cancelled during transfer, skipping finalization"
            )
            return temp_save_dir

        # Everything is a pre-built quant: move files straight to the final dir
        logger.info("Moving downloaded files to final location")
        if not os.path.exists(temp_save_dir):
            logger.warning(f"temp dir {temp_save_dir} missing (cancelled?), skipping move")
            return temp_save_dir
        if os.path.exists(final_save_dir):
            shutil.rmtree(final_save_dir, ignore_errors=True)
        shutil.move(temp_save_dir, final_save_dir)

        # Wait for ETA monitor to finish
        await eta_task
        logger.info("Download complete")

        return temp_save_dir

    except (GatedRepoError, HfHubHTTPError) as e:
        status = getattr(getattr(e, "response", None), "status_code", None)
        if isinstance(e, GatedRepoError) or status in (401, 403):
            raise UnsupportedPlatformException(
                feature=model_link,
                reason="requires HuggingFace authentication and cannot be downloaded anonymously",
            )
        raise
    except Exception as e:
        # Re-raised in both branches: the download task that owns the job
        # writes the record, with the traceback. Named here so the offline
        # case still says which model it was about.
        if _is_offline_download_error(e):
            raise HuggingFaceAPIException(
                OFFLINE_DOWNLOAD_MESSAGE, trace=f"{actual_download_link}: {e}"
            )
        raise
    finally:
        if job_id is not None:
            _unregister_tracker(job_id)

gguf_split_siblings(chosen, all_repo_files)

All part filenames of a split GGUF chosen belongs to (chosen included).

A quant like Qwen2.5-7B's Q4_K_M ships as two files, …-00001-of-00002.gguf and …-00002-of-00002.gguf. Downloading only the file pick_best_gguf happened to name left the model on disk with part 1 present and part 2 missing: llama-server correctly detects the split (it looks for the sibling next to part 1) and crashes on load with "failed to open GGUF file …-00002-of-00002.gguf" -- a real QA reproduction, not a hypothetical.

Returns [chosen] unchanged when chosen isn't a split filename, or if no siblings with a matching prefix/total are found in the repo listing (nothing to add — download proceeds with just chosen, same as before). Order is by part number so the download list reads naturally in logs.

Source code in backend/src/domains/llms/services.py
def gguf_split_siblings(chosen: str, all_repo_files: List[str]) -> List[str]:
    """All part filenames of a split GGUF `chosen` belongs to (chosen included).

    A quant like Qwen2.5-7B's Q4_K_M ships as two files,
    ``…-00001-of-00002.gguf`` and ``…-00002-of-00002.gguf``. Downloading only
    the file `pick_best_gguf` happened to name left the model on disk with
    part 1 present and part 2 missing: llama-server correctly detects the
    split (it looks for the sibling next to part 1) and crashes on load with
    "failed to open GGUF file …-00002-of-00002.gguf" -- a real QA reproduction,
    not a hypothetical.

    Returns `[chosen]` unchanged when `chosen` isn't a split filename, or if
    no siblings with a matching prefix/total are found in the repo listing
    (nothing to add — download proceeds with just `chosen`, same as before).
    Order is by part number so the download list reads naturally in logs.
    """
    match = _GGUF_SPLIT_RE.match(chosen)
    if not match:
        return [chosen]
    prefix, total = match.group("prefix"), match.group("total")
    siblings = []
    for name in all_repo_files:
        sibling_match = _GGUF_SPLIT_RE.match(name)
        if (
            sibling_match
            and sibling_match.group("prefix") == prefix
            and sibling_match.group("total") == total
        ):
            siblings.append((int(sibling_match.group("part")), name))
    if not siblings:
        return [chosen]
    return [name for _part, name in sorted(siblings)]

has_downloadable_gguf(repo_id, hf_api)

Whether the downloader would select a loadable GGUF artefact from repo_id.

Runs the download path's own selection (_select_download_files) over the repo's file listing, so a catalog build never offers a repo the downloader refuses: one whose only GGUF weights are raw byte pieces, or a llama.cpp split whose listing misses a part.

The listing is model_info(repo_id, expand=["siblings"]): one request, file names only, and the call the catalog's retrying client paces and retries on HTTP 429. A listing failure propagates; the caller decides what it means.

Source code in backend/src/domains/llms/services.py
def has_downloadable_gguf(repo_id: str, hf_api) -> bool:
    """Whether the downloader would select a loadable GGUF artefact from ``repo_id``.

    Runs the download path's own selection (`_select_download_files`) over the
    repo's file listing, so a catalog build never offers a repo the downloader
    refuses: one whose only GGUF weights are raw byte pieces, or a llama.cpp split
    whose listing misses a part.

    The listing is ``model_info(repo_id, expand=["siblings"])``: one request, file
    names only, and the call the catalog's retrying client paces and retries on
    HTTP 429. A listing failure propagates; the caller decides what it means.
    """
    info = hf_api.model_info(repo_id, expand=["siblings"])
    all_repo_files = [s.rfilename for s in (getattr(info, "siblings", None) or [])]
    try:
        selection = _select_download_files(all_repo_files, {}, uses_gguf=True)
    except HuggingFaceAPIException:
        # _assert_split_is_complete: the listing misses a part of the chosen
        # split, which the downloader refuses before any transfer.
        return False
    return selection.best_gguf is not None

is_byte_split_gguf(filename)

True when filename is one raw byte piece of a GGUF file (see _BYTE_SPLIT_GGUF_RE).

Source code in backend/src/domains/llms/services.py
def is_byte_split_gguf(filename: str) -> bool:
    """True when ``filename`` is one raw byte piece of a GGUF file (see _BYTE_SPLIT_GGUF_RE)."""
    return _BYTE_SPLIT_GGUF_RE.search(filename) is not None

make_callback(job)

Create fsspec Callback that updates DownloadTracker on each file transfer chunk.

The callback's transfer-chunk hook is invoked by fsspec after each network read. Computes delta bytes since last update to avoid double-counting (guards against negative deltas from fsspec resets).

Parameters:

Name Type Description Default
job DownloadTracker

DownloadTracker instance to update with progress.

required

Returns:

Type Description
Callback

Configured fsspec Callback with total size and chunk hook.

Example

tracker = DownloadTracker() tracker.total_bytes = 1_000_000 callback = make_callback(tracker) fs.get_file("repo/file.safetensors", "local.safetensors", callback)

Source code in backend/src/domains/llms/services.py
def make_callback(job: DownloadTracker) -> Callback:
    """Create fsspec Callback that updates DownloadTracker on each file transfer chunk.

    The callback's transfer-chunk hook is invoked by fsspec after each network read.
    Computes delta bytes since last update to avoid double-counting (guards against
    negative deltas from fsspec resets).

    Args:
        job: DownloadTracker instance to update with progress.

    Returns:
        Configured fsspec Callback with total size and chunk hook.

    Example:
        >>> tracker = DownloadTracker()
        >>> tracker.total_bytes = 1_000_000
        >>> callback = make_callback(tracker)
        >>> fs.get_file("repo/file.safetensors", "local.safetensors", callback)
    """
    # prev tracks the last cumulative value seen for the current file.
    # `value` is per-file cumulative (resets to 0 at the start of each file),
    # so when it drops below prev we know a new file started and reset.
    state = {"prev": 0}

    def after_chunk(size: int, value: int, **kwargs) -> None:
        if not job.should_continue():
            # Abort the transfer from inside fsspec's read loop (#377): raising here
            # unwinds fs.get_file in its worker thread, so the shard stops now
            # instead of streaming to the end after the user cancelled.
            raise DownloadCancelled("download cancelled during transfer")
        if value is None:
            return
        prev = state["prev"]
        delta = value if value < prev else value - prev
        if delta > 0:
            job.update(delta)
        state["prev"] = value

    return Callback(size=job.total_bytes, hooks={"transfer-chunk": after_chunk})

pick_best_gguf(filenames)

From a list of filenames in a GGUF repo, return the best quantization.

Uses the same priority order as CUDA_Engine._select_gguf so behavior is consistent between download-time selection and load-time selection.

Parameters:

Name Type Description Default
filenames list[str]

All filenames returned by HfApi.list_repo_files().

required

Returns:

Type Description
str | None

Filename of the chosen GGUF, or None if no loadable .gguf file is listed.

Source code in backend/src/domains/llms/services.py
def pick_best_gguf(filenames: list[str]) -> str | None:
    """From a list of filenames in a GGUF repo, return the best quantization.

    Uses the same priority order as CUDA_Engine._select_gguf so behavior is
    consistent between download-time selection and load-time selection.

    Args:
        filenames: All filenames returned by HfApi.list_repo_files().

    Returns:
        Filename of the chosen GGUF, or None if no loadable .gguf file is listed.
    """
    # Exclude multimodal projection files (mmproj-*.gguf) — these are vision
    # encoder weights, not the main text model, and must not be loaded as LLM —
    # and raw byte pieces of a GGUF file, which nothing loads without joining them.
    ggufs = [
        f
        for f in filenames
        if f.lower().endswith(".gguf")
        and not f.lower().startswith("mmproj")
        and not is_byte_split_gguf(f)
    ]
    if not ggufs:
        return None
    for quant in GGUF_QUANT_PRIORITY:
        for name in ggufs:
            if quant in name.lower():
                logger.info(f"Selected GGUF: {name} (quant={quant})")
                return name
    # Fall back to the first one alphabetically
    chosen = sorted(ggufs)[0]
    logger.warning(f"No preferred quant found; falling back to {chosen}")
    return chosen

resolve_member_path(root, member)

Map a Hub-listed file name onto root or refuse it.

Every destination the downloader writes is derived from names LISTED BY THE HUB (list_repo_files), and any repository can be downloaded by link. The rule is refuse, never normalise: the loaders (mlx-vlm, llama-server) expect the repo's own names and subfolders, so a rewritten name would silently produce a broken model and hide a hostile repo.

Rejected (InvalidInputException, ASCII message naming the member): - empty name, or one carrying a NUL byte; - any backslash (the Hub only ever lists /-separated names); - absolute forms on either family: POSIX (/x), Windows drive or drive-relative (C:/x, C:x) and UNC (//server/share/x), checked with PurePosixPath/PureWindowsPath explicitly so the outcome is identical on the three CI legs; - any .., . or empty segment (a/../x, ./x, a//b, a/); - anything that, once resolved, does not land under root.resolve() (belt and braces after the syntactic checks).

Returns root / member unchanged otherwise. Pure: nothing is created.

Source code in backend/src/domains/llms/services.py
def resolve_member_path(root: Union[str, Path], member: str) -> Path:
    """Map a Hub-listed file name onto ``root`` or refuse it.

    Every destination the downloader writes is derived from names LISTED BY THE
    HUB (``list_repo_files``), and any repository can be downloaded by link. The
    rule is refuse, never normalise: the loaders (mlx-vlm, llama-server) expect
    the repo's own names and subfolders, so a rewritten name would silently
    produce a broken model and hide a hostile repo.

    Rejected (``InvalidInputException``, ASCII message naming the member):
      - empty name, or one carrying a NUL byte;
      - any backslash (the Hub only ever lists ``/``-separated names);
      - absolute forms on either family: POSIX (``/x``), Windows drive or
        drive-relative (``C:/x``, ``C:x``) and UNC (``//server/share/x``),
        checked with ``PurePosixPath``/``PureWindowsPath`` explicitly so the
        outcome is identical on the three CI legs;
      - any ``..``, ``.`` or empty segment (``a/../x``, ``./x``, ``a//b``, ``a/``);
      - anything that, once resolved, does not land under ``root.resolve()``
        (belt and braces after the syntactic checks).

    Returns ``root / member`` unchanged otherwise. Pure: nothing is created.
    """
    root = Path(root)
    shown = _display_member(member) if isinstance(member, str) else repr(member)

    def refuse(reason: str) -> InvalidInputException:
        return InvalidInputException(
            f"Hub file name '{shown}' ({reason}; refusing to write outside the model directory)"
        )

    if not isinstance(member, str) or not member:
        raise refuse("empty name")
    if "\x00" in member:
        raise refuse("NUL byte")
    if "\\" in member:
        raise refuse("backslash")
    windows_form = PureWindowsPath(member)
    if (
        PurePosixPath(member).is_absolute()
        or windows_form.is_absolute()
        or windows_form.drive
        or windows_form.anchor
    ):
        raise refuse("absolute path")
    if any(segment in ("", ".", "..") for segment in member.split("/")):
        raise refuse("'.', '..' or empty path segment")

    root_resolved = root.resolve()
    if not (root_resolved / member).resolve().is_relative_to(root_resolved):
        raise refuse("resolves outside the model directory")
    return root / member