Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **`MemoryRoot.default()` → `MemoryRoot.resolve()`** — the classmethod that
resolves the memory root from `--root` / `EVEROS_ROOT` / default was
renamed to make its behavior explicit (`resolve` walks the precedence
chain; `default` was ambiguous with "default location"). `MemoryRoot`
is publicly exported from `everos.core.persistence`; callers outside
the repo may have used the old name. **A `default()` alias is kept**
as a backward-compatibility shim that forwards to `resolve()` and
emits a `DeprecationWarning`. The alias will be removed in a future
major release — update call sites when convenient.
- **Uncalibrated recall scores moved to their own name** — `KEYWORD` and
single-route `VECTOR` searches now report their top score as
`recall_top_score_raw`; `recall_top_score` is reserved for the calibrated
Expand Down
73 changes: 67 additions & 6 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,15 @@
"health"
],
"summary": "Health",
"description": "Liveness probe — returns ``{\"status\": \"ok\"}`` with HTTP 200.",
"description": "Liveness probe with capabilities and disabled features.",
"operationId": "health_health_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Response Health Health Get"
"$ref": "#/components/schemas/HealthResponse"
}
}
}
Expand Down Expand Up @@ -2719,6 +2715,71 @@
"type": "object",
"title": "HTTPValidationError"
},
"HealthCapabilities": {
"properties": {
"llm": {
"type": "boolean",
"title": "Llm"
},
"embed": {
"type": "boolean",
"title": "Embed"
},
"rerank": {
"type": "boolean",
"title": "Rerank"
},
"multimodal_llm": {
"type": "boolean",
"title": "Multimodal Llm"
},
"parser": {
"type": "boolean",
"title": "Parser"
}
},
"type": "object",
"required": [
"llm",
"embed",
"rerank",
"multimodal_llm",
"parser"
],
"title": "HealthCapabilities",
"description": "Availability flags for the five capability probes.\n\nField order matches the health-endpoint payload contract; clients\nkey off these names to decide whether to expose optional features."
},
"HealthResponse": {
"properties": {
"status": {
"type": "string",
"title": "Status"
},
"version": {
"type": "string",
"title": "Version"
},
"capabilities": {
"$ref": "#/components/schemas/HealthCapabilities"
},
"disabled_features": {
"items": {
"type": "string"
},
"type": "array",
"title": "Disabled Features"
}
},
"type": "object",
"required": [
"status",
"version",
"capabilities",
"disabled_features"
],
"title": "HealthResponse",
"description": "Response schema for ``GET /health``.\n\nDeclared as a Pydantic model (not ``dict``) so the generated\nOpenAPI schema carries the full field shape — ``capabilities`` and\n``disabled_features`` are typed. A bare ``-> dict`` return type\ndegrades the OpenAPI response to ``additionalProperties: true``,\nwhich robs clients (and codegen) of any structure to lean on."
},
"KnowledgeSearchRequest": {
"properties": {
"query": {
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ dependencies = [

# CLI + TUI
"typer>=0.12.0",
# click is a transitive dep of uvicorn today, but our CLI raises
# ``click.exceptions.Abort`` directly (e.g. tests that inject aborts)
# and typer 0.15+ vendored a copy of click under ``typer._click`` — so
# the two classes are DIFFERENT even though users only ever see
# ``typer.Abort``. We depend on the standalone click package so
# ``click.exceptions.Abort`` stays importable and both classes are
# covered in the Ctrl-C catch (see backfill exit-130 path).
"click>=8.1",
"textual>=8.2.7",

# Tokenization (BM25 Chinese support)
Expand Down
4 changes: 2 additions & 2 deletions scripts/e2e_memorize/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,15 @@ def _print_header(mode: str, fixture_path: Path, session_id: str) -> None:
print(f" everos e2e memorize · mode={mode}")
print(f" fixture : {fixture_path.name}")
print(f" session_id : {session_id}")
print(f" memory root : {MemoryRoot.default().root}")
print(f" memory root : {MemoryRoot.resolve().root}")
llm_state = "<configured>" if get_llm_client() else "<None — pipeline will skip>"
print(f" llm_client : {llm_state}")
print("=" * 72)


def _list_written_files(session_id: str, mode: str) -> None:
"""Walk memory root and print files touched in this run."""
root = MemoryRoot.default().root
root = MemoryRoot.resolve().root
cutoff = time.time() - 600 # files modified in the last 10 min
print()
print("─── files modified within the last 10 minutes under memory root ───")
Expand Down
74 changes: 74 additions & 0 deletions src/everos/component/capabilities.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"""Capabilities inference and feature availability logic.

Compute which features are disabled based on available capabilities.
Used by the health endpoint, startup banner, and related diagnostics.

Two distinct feature-name vocabularies exist in the refactored codebase.
They deliberately do NOT share a source of truth:

- ``ProviderNotConfiguredError.feature`` (raised by
``SearchManager._validate_components`` and error handlers): per-request
granular tag identifying the specific search mode or endpoint that failed —
``"vector"`` | ``"user_hybrid"`` | ``"agent_hybrid"`` | ``"agentic_search"``
| ``"knowledge"`` | ``"skill_extraction_backfill"``. Appears in HTTP 422
response ``message``. Naming mirrors the ``SearchMethod`` enum values plus
endpoint-scoped tags.

- ``compute_disabled_features()`` (below): capability-level tag identifying
a whole feature category disabled by the current tier —
``"vector_search"`` | ``"hybrid_search"`` | ``"agentic_search"``
| ``"reflection"`` | ``"skill_extraction"`` | ``"knowledge"``
| ``"multimodal_upload"``. Appears in ``GET /health`` response
``disabled_features``.

Callers of either surface should treat these vocabularies as stable client
contracts — do not unify them without a coordinated migration on the client side.
"""

from __future__ import annotations


def compute_disabled_features(caps: dict[str, bool]) -> list[str]:
"""Derive the list of disabled features from capability availability.

Args:
caps: Dictionary with keys "llm", "embed", "rerank", "multimodal_llm", "parser"
and boolean availability values. Note: ``caps["llm"]`` is
accepted for shape symmetry but NOT read here — LLM is a
Tier-1 hard requirement enforced at server startup
(``LLMLifespanProvider``), so any process reaching this
function is guaranteed to have LLM available. If LLM ever
becomes soft, add an ``if not caps["llm"]`` branch here
covering the LLM-dependent features.

Returns:
List of feature names that are disabled due to missing capabilities.
Possible values: "vector_search", "hybrid_search", "agentic_search",
"reflection", "skill_extraction", "knowledge", "multimodal_upload".
"""
disabled: list[str] = []

# Embedding-dependent features
if not caps["embed"]:
disabled.extend(
[
"vector_search",
"hybrid_search",
"reflection",
"skill_extraction",
]
)

# Rerank-dependent feature
if not caps["rerank"]:
disabled.append("agentic_search")

# Knowledge requires both embedding and rerank
if not (caps["embed"] and caps["rerank"]):
disabled.append("knowledge")

# Multimodal upload requires both multimodal_llm and parser
if not (caps["multimodal_llm"] and caps["parser"]):
disabled.append("multimodal_upload")

return disabled
17 changes: 13 additions & 4 deletions src/everos/component/embedding/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,18 @@
- :class:`EmbeddingProvider` — Protocol every provider satisfies.
- :class:`EmbeddingServiceError` — provider-side failure.
- :class:`EmbeddingError` — backward-compat alias for ``EmbeddingServiceError``.
- :class:`EmbeddingCapability` — soft-dependency wrapper around an
optional :class:`EmbeddingProvider` (``available`` / ``embed_or_none``
/ ``require``).
- :class:`OpenAIEmbeddingProvider` — concrete provider for any
OpenAI-protocol embeddings endpoint (DeepInfra, vLLM, OpenAI, …).
- :func:`build_embedding_provider` — settings-driven factory.
- :func:`get_embedding_capability` — process-wide lazy singleton
accessor for :class:`EmbeddingCapability`. There is no separate
``get_embedder`` accessor: consumers that need a provider call
``get_embedding_capability().require()``, which routes every caller
through a single shared provider (and its single ``AsyncOpenAI``
client + ``asyncio.Semaphore``).

External usage::

Expand All @@ -19,19 +28,19 @@

from everos.core.errors import EmbeddingServiceError as EmbeddingServiceError

from .accessor import EmbeddingNotConfiguredError as EmbeddingNotConfiguredError
from .accessor import get_embedder as get_embedder
from .accessor import get_embedding_capability as get_embedding_capability
from .capability import EmbeddingCapability as EmbeddingCapability
from .factory import build_embedding_provider as build_embedding_provider
from .openai_provider import OpenAIEmbeddingProvider as OpenAIEmbeddingProvider
from .protocol import EmbeddingError as EmbeddingError
from .protocol import EmbeddingProvider as EmbeddingProvider

__all__ = [
"EmbeddingCapability",
"EmbeddingError",
"EmbeddingNotConfiguredError",
"EmbeddingProvider",
"EmbeddingServiceError",
"OpenAIEmbeddingProvider",
"build_embedding_provider",
"get_embedder",
"get_embedding_capability",
]
68 changes: 39 additions & 29 deletions src/everos/component/embedding/accessor.py
Original file line number Diff line number Diff line change
@@ -1,48 +1,58 @@
"""Process-wide embedding provider accessor.

Lazy singleton mirror of :func:`everos.component.llm.get_llm_client`:
first call reads settings and builds the OpenAI-protocol embedding
client; subsequent calls return the cached instance. Strategies and
other components that need a process-wide embedder import this rather
than threading the provider through their constructors.

Raises :class:`EmbeddingNotConfiguredError` when credentials are missing
so misconfiguration surfaces at the call site (or at app startup via a
lifespan provider) instead of silently degrading.
"""Process-wide embedding capability accessor.

Lazy singleton for :class:`EmbeddingCapability`. The first call reads
settings and attempts to build an OpenAI-protocol embedding provider;
subsequent calls return the cached wrapper. Consumers that must have an
embedder call ``get_embedding_capability().require()``; consumers that
can degrade gracefully use ``.embed_or_none`` or check ``.available``.

There is deliberately no separate ``get_embedder()`` accessor: routing
every consumer through the capability keeps a single provider (and its
underlying ``AsyncOpenAI`` client + ``asyncio.Semaphore``) per process,
so the configured ``max_concurrent`` bound holds instead of silently
doubling.
"""

from __future__ import annotations

from everos.config import load_settings
from everos.core.observability.logging import get_logger

from .capability import EmbeddingCapability
from .factory import build_embedding_provider
from .protocol import EmbeddingProvider

logger = get_logger(__name__)


class EmbeddingNotConfiguredError(RuntimeError):
"""Raised when ``settings.embedding`` lacks ``model``/``api_key``/``base_url``."""

_capability: EmbeddingCapability | None = None

_embedder: EmbeddingProvider | None = None

def get_embedding_capability() -> EmbeddingCapability:
"""Return the process-wide :class:`EmbeddingCapability`. Never raises.

def get_embedder() -> EmbeddingProvider:
"""Return the singleton :class:`EmbeddingProvider`.
On the first call, builds and caches a capability from current
settings — ``available`` is ``False`` when the provider cannot be
built (missing fields, unsupported provider name, malformed URL, …).
The build outcome is cached, so a later settings change requires a
process restart to take effect.

Raises:
EmbeddingNotConfiguredError: When required settings fields are
unset. See :func:`build_embedding_provider` for the exact
keys.
Configuration failures (:class:`ValueError` from
:func:`build_embedding_provider`) are logged at ``warning`` level:
the downstream :class:`ProviderNotConfiguredError` message maps both
"user hasn't configured it" and "user configured it wrong" onto the
same HTTP 422, so the log line is the only place an operator can
tell those two states apart.
"""
global _embedder
if _embedder is not None:
return _embedder
global _capability
if _capability is not None:
return _capability
try:
_embedder = build_embedding_provider(load_settings().embedding)
provider = build_embedding_provider(load_settings().embedding)
except ValueError as exc:
raise EmbeddingNotConfiguredError(str(exc)) from exc
logger.info("embedder_built")
return _embedder
logger.warning(
"embedding_capability_build_failed",
reason=str(exc),
)
provider = None
_capability = EmbeddingCapability(provider=provider)
return _capability
Loading