Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
28ddc7f
feat: implement remote ML client and API endpoints
uNikhil-codes Jul 18, 2026
f72e646
fix: address bot review feedback
uNikhil-codes Jul 18, 2026
ff092c2
fix: prevent self-DoS by rejecting requests when ML_MODE=remote
uNikhil-codes Jul 18, 2026
811165a
Merge branch 'canary' into feat/remote-ml-endpoints
uNikhil-codes Jul 18, 2026
de3e998
fix: restore settings import, fix remote dispatch, and sanitize output
uNikhil-codes Jul 18, 2026
19760bd
fix: resolve merge conflicts locally
uNikhil-codes Jul 18, 2026
41cdc2c
style: fix linter errors and mitigate CodeQL exposure warnings
uNikhil-codes Jul 18, 2026
e2f8b10
style: remove unused io import in tests
uNikhil-codes Jul 18, 2026
520314c
style: remove remaining unused imports in tests
uNikhil-codes Jul 18, 2026
466a6d1
style: fix E402 import formatting in test file
uNikhil-codes Jul 18, 2026
e14c4fc
style: auto-format code with ruff
uNikhil-codes Jul 18, 2026
31dd450
style: auto-format code with ruff
uNikhil-codes Jul 18, 2026
100fcab
test: patch actual processor functions instead of router imports
uNikhil-codes Jul 18, 2026
350388b
Merge branch 'canary' into feat/remote-ml-endpoints
Abhash-Chakraborty Jul 30, 2026
e68cd84
test: generate the remote-ML test token instead of committing it
Abhash-Chakraborty Jul 30, 2026
f59571e
Merge branch 'canary' into feat/remote-ml-endpoints
Abhash-Chakraborty Jul 30, 2026
3eb347a
fix(ml): resolve remote to a real runtime mode instead of unavailable
Abhash-Chakraborty Jul 31, 2026
5eb9f5e
fix(privacy): require TLS for non-local REMOTE_ML_URL
Abhash-Chakraborty Jul 31, 2026
46ed693
fix(ml): restore the diagnostics and invariants dropped from processors
Abhash-Chakraborty Jul 31, 2026
4a58a52
feat(ml): dispatch clustering to the remote server
Abhash-Chakraborty Jul 31, 2026
bb0fc48
feat(ml): add remote text embedding so search works in remote mode
Abhash-Chakraborty Jul 31, 2026
3f2b0a7
test(ml): cover the remote seams that were previously unreachable
Abhash-Chakraborty Jul 31, 2026
72fe04c
fix(privacy): honour REMOTE_ML_FEATURES end to end
Abhash-Chakraborty Jul 31, 2026
47e36fa
fix(ml): validate and bound the remote clustering input
Abhash-Chakraborty Jul 31, 2026
946a3d5
test(ml): cover the feature contract, embed fallback and cluster limits
Abhash-Chakraborty Jul 31, 2026
82b99e8
Merge branch 'canary' into feat/remote-ml-endpoints
Abhash-Chakraborty Jul 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/alembic/versions/add_duplicate_of_to_media.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""add duplicate_of to media

Revision ID: a1b2c3d4e5f6
Revises:
Revises:
Create Date: 2026-05-26
Comment thread
uNikhil-codes marked this conversation as resolved.
"""

from alembic import op
import sqlalchemy as sa

Expand Down
24 changes: 24 additions & 0 deletions backend/src/find_api/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import os
from typing import Literal, Optional
from urllib.parse import urlparse

from PIL import Image
from pydantic import field_validator, model_validator
Expand All @@ -12,6 +13,10 @@

PILLOW_MAX_IMAGE_PIXELS = Image.MAX_IMAGE_PIXELS or 89_478_485

# Hosts where plaintext HTTP to the remote ML server never leaves the machine
# (or the compose network), so requiring TLS would be pure friction.
_LOCAL_HOSTNAMES = {"localhost", "127.0.0.1", "::1", "[::1]"}


class Settings(BaseSettings):
"""Application settings"""
Expand Down Expand Up @@ -148,6 +153,25 @@ def validate_remote_ml_config(self):
raise ValueError("ML_MODE=remote requires REMOTE_ML_URL")
if not self.REMOTE_ML_API_KEY or not self.REMOTE_ML_API_KEY.strip():
raise ValueError("ML_MODE=remote requires REMOTE_ML_API_KEY")

# Remote mode ships photo bytes and the bearer token to another host.
# Over plaintext HTTP both are readable by anything on the path, which
# is the opposite of what a local-first tool promises, so require TLS
# unless the server is on this machine.
parsed = urlparse(self.REMOTE_ML_URL.strip())
if parsed.scheme not in {"http", "https"}:
raise ValueError(
"REMOTE_ML_URL must be an http:// or https:// URL "
f"(got {parsed.scheme or 'no'} scheme)"
)
hostname = (parsed.hostname or "").lower()
if parsed.scheme == "http" and hostname not in _LOCAL_HOSTNAMES:
raise ValueError(
"REMOTE_ML_URL must use https:// for non-local hosts: remote mode "
"transmits image bytes and the bearer token to it. Use https://, "
"or point REMOTE_ML_URL at localhost and terminate TLS in front "
"of the ML server."
)
return self

@model_validator(mode="after")
Expand Down
44 changes: 34 additions & 10 deletions backend/src/find_api/core/runtime_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@

AccelMode = Literal["auto", "gpu", "cpu"]
ConfiguredMLMode = Literal["disabled", "full", "mock", "remote"]
AppliedMLMode = Literal["disabled", "full", "mock", "unavailable"]
AppliedMLMode = Literal["disabled", "full", "mock", "remote", "unavailable"]

ACCEL_MODE_KEY = "accel_mode"
AI_ENABLED_KEY = "ai_enabled"
Expand All @@ -45,11 +45,17 @@
"clustering",
)

# Remote mode offloads inference to a user-controlled Find ML server, so it
# needs no local model runtime -- only httpx, which every artifact ships. It is
# therefore supported in every build profile, including the slim ones whose
# whole point is not carrying torch.
_REMOTE_MODE = "remote"

_PROFILE_MODES: dict[str, tuple[str, ...]] = {
"no-ai": ("disabled",),
"mock": ("disabled", "mock"),
"cpu": ("disabled", "mock", "full"),
"nvidia": ("disabled", "mock", "full"),
"no-ai": ("disabled", _REMOTE_MODE),
"mock": ("disabled", "mock", _REMOTE_MODE),
"cpu": ("disabled", "mock", "full", _REMOTE_MODE),
"nvidia": ("disabled", "mock", "full", _REMOTE_MODE),
}

_active_ml_mode: ContextVar[str | None] = ContextVar(
Expand Down Expand Up @@ -206,11 +212,29 @@ def resolve_runtime(
reason = None
restart_required = False
elif mode == "remote":
applied_mode = "unavailable"
reason = (
"Remote ML is configured but no remote inference client is installed. "
"Find will not fall back to local inference."
)
# The remote client ships in every artifact, so remote is unavailable
# only when it has not been pointed at a server. Resolving it to
# "unavailable" unconditionally made every applied_mode == "unavailable"
# guard fire -- process_image raised RuntimeUnavailableError before the
# remote dispatch in processors.py was ever reached.
missing = [
name
for name, value in (
("REMOTE_ML_URL", settings.REMOTE_ML_URL),
("REMOTE_ML_API_KEY", settings.REMOTE_ML_API_KEY),
)
if not (value or "").strip()
]
if missing:
applied_mode = "unavailable"
verb = "is" if len(missing) == 1 else "are"
reason = (
f"Remote ML is configured but {' and '.join(missing)} {verb} "
"not set. Find will not fall back to local inference."
)
else:
applied_mode = "remote"
reason = None
restart_required = False
elif mode not in modes:
applied_mode = "unavailable"
Expand Down
2 changes: 2 additions & 0 deletions backend/src/find_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
feedback,
gallery,
map,
ml,
people,
search,
status,
Expand Down Expand Up @@ -148,6 +149,7 @@ async def lifespan(app: FastAPI):
app.include_router(config.router, prefix="/api", tags=["config"])
app.include_router(people.router, prefix="/api", tags=["people"])
app.include_router(vault.router, prefix="/api", tags=["vault"])
app.include_router(ml.router)
app.include_router(feedback.router, tags=["feedback"])
app.include_router(duplicates_router)

Expand Down
78 changes: 77 additions & 1 deletion backend/src/find_api/ml/clusterer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
"""

import numpy as np
from sklearn.cluster import HDBSCAN
from typing import Tuple, Dict
import logging

Expand Down Expand Up @@ -128,6 +127,11 @@ def _fit_predict_cuml(self, embeddings: np.ndarray, metric: str) -> np.ndarray:
return np.asarray(labels, dtype=np.int32)

def _fit_predict_sklearn(self, embeddings: np.ndarray, metric: str) -> np.ndarray:
# Imported here rather than at module scope so compute_centroids() --
# pure numpy -- stays usable in remote mode, where clustering runs on
# the remote server and this artifact may not ship scikit-learn.
from sklearn.cluster import HDBSCAN

clusterer_kwargs = {
"min_cluster_size": self.min_cluster_size,
"min_samples": self.min_samples,
Expand Down Expand Up @@ -219,3 +223,75 @@ def assign_to_cluster(
def get_image_clusterer() -> ImageClusterer:
"""Create new clusterer instance"""
return ImageClusterer()


# Upper bound on a single remote clustering request. Clustering is O(n log n)
# at best and allocates an n x dim float matrix, so an unbounded list from the
# network is a memory and CPU amplification vector even behind bearer auth.
MAX_REMOTE_CLUSTER_POINTS = 100_000


class InvalidEmbeddingMatrix(ValueError):
"""Raised when a caller-supplied embedding matrix is not usable."""


def validate_embedding_matrix(embeddings, *, expected_dim: int | None = None):
"""Return a validated (n, dim) float32 matrix from untrusted input.

np.array() on a ragged or non-numeric list raises deep inside NumPy (or,
worse, silently builds an object array), which surfaces as an opaque 500.
Checking shape, dtype and finiteness up front turns that into a clear 4xx
and caps how much work an authenticated caller can ask for.
"""
if not isinstance(embeddings, (list, tuple)) or not embeddings:
raise InvalidEmbeddingMatrix(
"embeddings must be a non-empty list of float arrays."
)
if len(embeddings) > MAX_REMOTE_CLUSTER_POINTS:
raise InvalidEmbeddingMatrix(
f"embeddings exceeds the {MAX_REMOTE_CLUSTER_POINTS}-point limit "
f"({len(embeddings)} given)."
)

width = None
for row in embeddings:
if not isinstance(row, (list, tuple)):
raise InvalidEmbeddingMatrix("each embedding must be a list of floats.")
if width is None:
width = len(row)
elif len(row) != width:
raise InvalidEmbeddingMatrix(
f"embeddings must be rectangular (got rows of {width} and {len(row)})."
)
if not width:
raise InvalidEmbeddingMatrix("embeddings rows must be non-empty.")
if expected_dim is not None and width != expected_dim:
raise InvalidEmbeddingMatrix(
f"embeddings must have dimension {expected_dim}, got {width}."
)

try:
matrix = np.asarray(embeddings, dtype=np.float32)
except (TypeError, ValueError) as exc:
raise InvalidEmbeddingMatrix("embeddings must contain only numbers.") from exc
if not np.all(np.isfinite(matrix)):
raise InvalidEmbeddingMatrix("embeddings must not contain NaN or infinity.")
return matrix


def cluster_embedding_matrix(embeddings, *, expected_dim: int | None = None):
"""Validate and cluster an embedding matrix, returning (labels, info).

Lives here rather than in the router so the /api/ml/cluster endpoint stays
request/response handling only, matching how the rest of the ML work is
layered.
"""
matrix = validate_embedding_matrix(embeddings, expected_dim=expected_dim)
labels = get_image_clusterer().cluster(matrix)[0]
label_list = [int(label) for label in labels]

return label_list, {
"n_clusters": len({label for label in label_list if label >= 0}),
"n_noise": label_list.count(-1),
"n_points": len(label_list),
}
Loading
Loading