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
2 changes: 1 addition & 1 deletion backend/management/services/model/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ def create_embedding_model(model: dict) -> Any:

# Vendor dispatch (DashScope/Siliconflow/Jina/OpenAI) is resolved by the
# adapter registry; per-vendor request-body formatting lives in the
# embedding adapters. Built fresh (no gateway cache). Returns the adapter;
# embedding adapters. Built fresh. Returns the adapter;
# callers use adapter.get_embeddings / adapter.dimension_check unchanged.
return build_adapter_fresh(model_config, modality, slot, None)

Expand Down
6 changes: 3 additions & 3 deletions backend/services/model_gateway_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ def get_adapter_from_config(
tenant_id: Optional[str] = None,
**construct_extras: Any,
):
"""Resolve and return the adapter for ``cfg`` (cached by the gateway)."""
"""Resolve and return the adapter for ``cfg``."""
context = _config_to_context(cfg, modality, slot, tenant_id, **construct_extras)
return get_gateway().get_adapter(context)

Expand All @@ -186,11 +186,11 @@ def build_adapter_fresh(
tenant_id: Optional[str] = None,
**construct_extras: Any,
):
"""Build a fresh adapter for ``cfg`` WITHOUT the gateway instance cache.
"""Build a fresh adapter for ``cfg``.

Used by per-call construction sites (e.g. voice streaming sessions) where
vendor config carries per-request params (api_key, ws_url, voice, …) that
must not collide across tenants under a shared cache key.
must not collide across tenants.
"""
context = _config_to_context(cfg, modality, slot, tenant_id, **construct_extras)
cls = get_registry().resolve(context.factory, modality)
Expand Down
4 changes: 0 additions & 4 deletions sdk/nexent/core/gateway/model_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ class ModelContext:
observer: Any = None # cross-cutting: LLM/VLM/ModelEngine STT/TTS
timeout_seconds: Optional[float] = None # cross-cutting: all HTTP-backed adapters

def cache_key(self) -> tuple:
return (self.tenant_id or "", self.modality, self.slot or "",
self.model_name, self.factory)


@dataclass
class LLMContext(ModelContext):
Expand Down
28 changes: 6 additions & 22 deletions sdk/nexent/core/gateway/multimodal_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,36 @@

from __future__ import annotations

from typing import Any, Dict, Tuple
from typing import Any

from .multimodal_adapter import MultimodalAdapter
from .model_context import ModelContext
from .registry import AdapterRegistry, get_registry


class MultimodalGateway:
"""Resolve and cache :class:`MultimodalAdapter` instances by context."""
"""Resolve :class:`MultimodalAdapter` instances by context."""

def __init__(self, registry: AdapterRegistry = None) -> None:
"""Initializes the gateway with a registry and empty cache.
"""Initializes the gateway with a registry.

Args:
registry: The adapter registry to resolve from. Defaults to the
process-wide singleton.
"""
self._registry = registry or get_registry()
self._adapter_cache: Dict[Tuple, MultimodalAdapter] = {}

def get_adapter(self, context: ModelContext) -> MultimodalAdapter:
"""Returns the adapter for ``context``, building and caching it once.
"""Returns the adapter for ``context``.

Args:
context: The construction context identifying the desired model.

Returns:
The cached or newly built adapter instance.
The newly built adapter instance.
"""
cls = self._registry.resolve(context.factory, context.modality)
key = context.cache_key()
if key not in self._adapter_cache:
self._adapter_cache[key] = cls(context)
return self._adapter_cache[key]
return cls(context)

async def invoke(self, context: ModelContext, request: Any) -> Any:
"""Resolves the adapter for ``context`` and invokes it.
Expand Down Expand Up @@ -72,18 +68,6 @@ async def health_check(self, context: ModelContext) -> bool:
"""
return await self.get_adapter(context).health_check()

def invalidate(self, context: ModelContext = None) -> None:
"""Drops cached adapter instances.

Args:
context: If provided, drops only that context's cached adapter.
If None, drops the entire cache.
"""
if context is None:
self._adapter_cache.clear()
else:
self._adapter_cache.pop(context.cache_key(), None)


_gateway: MultimodalGateway = None

Expand Down
2 changes: 0 additions & 2 deletions sdk/nexent/memory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
from .embedding_model import (
EmbeddingModelInfo,
get_embedding_client,
reset_embedding_client_cache,
)
from .models import (
ExternalMemoryItem,
Expand Down Expand Up @@ -96,7 +95,6 @@
# Embedding
"EmbeddingModelInfo",
"get_embedding_client",
"reset_embedding_client_cache",
# Providers
"BaseMemoryProvider",
"SearchableMemoryProvider",
Expand Down
69 changes: 20 additions & 49 deletions sdk/nexent/memory/embedding_model.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Embedding model metadata and client cache for the memory system.
"""Embedding model metadata and client factory for the memory system.

This module provides:

Expand All @@ -10,11 +10,8 @@
mem_{model_repo}_{model_name}_{dimension}
mem_{model_name}_{dimension} # when model_repo is absent

- ``get_embedding_client()``: a process-wide cache that reuses
``OpenAICompatibleEmbeddingAdapter`` instances keyed by ``(model_name, dimension)``.
Creating an HTTP client per memory write would add unnecessary latency;
caching a single instance per model avoids that while keeping the SDK
layer stateless.
- ``get_embedding_client()``: builds an ``OpenAICompatibleEmbeddingAdapter``
from the caller-supplied configuration.

The SDK never talks to Elasticsearch directly. All vector writes go through
the backend layer (``memory_index_service``). This module is therefore purely
Expand All @@ -23,15 +20,12 @@

from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from typing import List, Optional
from typing import Optional

from ..core.gateway.modality import OpenAICompatibleEmbeddingAdapter
from ..core.gateway import EmbeddingContext

logger = logging.getLogger("memory_embedding_model")
from ..core.gateway.modality import OpenAICompatibleEmbeddingAdapter


def _sanitize_index_component(value: str) -> str:
Expand Down Expand Up @@ -79,10 +73,8 @@ def get_index_name(self) -> str:


# --------------------------------------------------------------------------- #
# Process-wide HTTP client cache #
# Embedding client factory #
# --------------------------------------------------------------------------- #
# Key = "model_name:dimension", Value = OpenAICompatibleEmbeddingAdapter instance.
_embedding_client_cache: dict[str, OpenAICompatibleEmbeddingAdapter] = {}


def get_embedding_client(
Expand All @@ -93,11 +85,7 @@ def get_embedding_client(
model_repo: Optional[str] = None,
ssl_verify: bool = True,
) -> OpenAICompatibleEmbeddingAdapter:
"""Return a cached ``OpenAICompatibleEmbeddingAdapter`` instance.

Instances are cached by ``(model_repo, model_name, dimension)`` so that
repeated memory writes within the same process reuse the underlying HTTP
client and connection pool.
"""Return an ``OpenAICompatibleEmbeddingAdapter`` instance.

When ``model_repo`` is provided (e.g. ``"BAAI"``), the fully-qualified
name ``"BAAI/bge-m3"`` is passed to the API. Some providers (e.g.
Expand All @@ -113,35 +101,18 @@ def get_embedding_client(
ssl_verify: Whether to verify SSL certificates.

Returns:
A cached (or newly created) ``OpenAICompatibleEmbeddingAdapter`` instance.
A newly created ``OpenAICompatibleEmbeddingAdapter`` instance.
"""
cache_key = f"{model_repo or ''}:{model_name}:{dimension}"
if cache_key not in _embedding_client_cache:
# Form the fully-qualified model name the API expects.
full_model_name = f"{model_repo}/{model_name}" if model_repo else model_name
_embedding_client_cache[cache_key] = OpenAICompatibleEmbeddingAdapter(
EmbeddingContext(
model_name=full_model_name,
base_url=base_url,
api_key=api_key,
modality="embedding",
factory="openai",
embedding_dim=dimension,
ssl_verify=ssl_verify,
)
)
logger.debug(
"Created and cached embedding client for model=%s dim=%d",
full_model_name,
dimension,
# Form the fully-qualified model name the API expects.
full_model_name = f"{model_repo}/{model_name}" if model_repo else model_name
return OpenAICompatibleEmbeddingAdapter(
EmbeddingContext(
model_name=full_model_name,
base_url=base_url,
api_key=api_key,
modality="embedding",
factory="openai",
embedding_dim=dimension,
ssl_verify=ssl_verify,
)
return _embedding_client_cache[cache_key]


def reset_embedding_client_cache() -> None:
"""Clear all cached embedding client instances.

Call this in test teardown to ensure test isolation.
"""
_embedding_client_cache.clear()
logger.debug("Cleared embedding client cache")
)
24 changes: 9 additions & 15 deletions test/sdk/core/gateway/test_model_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,7 @@
from nexent.core.gateway.model_context import LLMContext, VLMContext


def test_cache_key_uses_empty_defaults():
context = VLMContext(
model_name="qwen-vl-max",
base_url="https://api.example.com",
api_key="sk-key",
modality="vlm",
factory="openai",
)

assert context.cache_key() == ("", "vlm", "", "qwen-vl-max", "openai")


def test_cache_key_includes_tenant_and_slot():
def test_context_carries_full_connection_config():
context = VLMContext(
model_name="qwen-vl-max",
base_url="https://api.example.com",
Expand All @@ -24,9 +12,15 @@ def test_cache_key_includes_tenant_and_slot():
factory="openai",
tenant_id="tenant-1",
slot="vlm3",
ssl_verify=False,
timeout_seconds=12.5,
)

assert context.cache_key() == ("tenant-1", "vlm", "vlm3", "qwen-vl-max", "openai")
assert context.base_url == "https://api.example.com"
assert context.api_key == "sk-key"
assert context.ssl_verify is False
assert context.timeout_seconds == 12.5
assert (context.tenant_id, context.slot) == ("tenant-1", "vlm3")


def test_subclass_fields_are_independent():
Expand All @@ -41,4 +35,4 @@ def test_subclass_fields_are_independent():
)
assert llm.temperature == 0.2
assert llm.stream is True
assert not hasattr(llm, "capabilities")
assert not hasattr(llm, "capabilities")
52 changes: 29 additions & 23 deletions test/sdk/core/gateway/test_multimodal_gateway.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Unit tests for MultimodalGateway caching and delegation."""
"""Unit tests for MultimodalGateway adapter construction and delegation."""

import pytest
from nexent.core.gateway.model_context import VLMContext
Expand Down Expand Up @@ -49,19 +49,20 @@ def _make_context(model_name="dummy-model"):
)


def test_get_adapter_builds_and_caches_by_context():
def test_get_adapter_builds_a_new_instance_on_every_call():
gateway = MultimodalGateway(_make_registry())
context = _make_context()

first = gateway.get_adapter(context)
second = gateway.get_adapter(context)

assert isinstance(first, _FakeAdapter)
assert first is second
assert first is not second
assert first._context is context
assert second._context is context


def test_get_adapter_builds_separate_instance_for_different_key():
def test_get_adapter_builds_separate_instances_for_different_models():
gateway = MultimodalGateway(_make_registry())

first = gateway.get_adapter(_make_context("model-a"))
Expand All @@ -70,6 +71,29 @@ def test_get_adapter_builds_separate_instance_for_different_key():
assert first is not second


def test_get_adapter_does_not_share_endpoint_or_credentials():
gateway = MultimodalGateway(_make_registry())

stale = gateway.get_adapter(_make_context())
rotated_context = _make_context()
rotated_context.base_url = "https://rotated.example.com"
rotated_context.api_key = "sk-rotated"
rotated = gateway.get_adapter(rotated_context)

assert stale is not rotated
assert rotated._context.base_url == "https://rotated.example.com"
assert rotated._context.api_key == "sk-rotated"
assert stale._context.api_key == "sk-key"


def test_gateway_keeps_no_adapter_cache():
gateway = MultimodalGateway(_make_registry())
gateway.get_adapter(_make_context())

assert not hasattr(gateway, "_adapter_cache")
assert not hasattr(gateway, "invalidate")


def test_gateway_defaults_to_process_registry():
gateway = MultimodalGateway()
context = VLMContext(
Expand Down Expand Up @@ -103,24 +127,6 @@ async def test_health_check_delegates_to_adapter(gateway, context):
assert await gateway.health_check(context) is True


def test_invalidate_single_context(gateway, context):
cached = gateway.get_adapter(context)
assert gateway.get_adapter(context) is cached

gateway.invalidate(context)
assert gateway.get_adapter(context) is not cached


def test_invalidate_all_contexts(gateway, context):
other_context = _make_context("model-other")
first_cached = gateway.get_adapter(context)
other_cached = gateway.get_adapter(other_context)

gateway.invalidate()

assert gateway.get_adapter(context) is not first_cached
assert gateway.get_adapter(other_context) is not other_cached


def test_get_gateway_is_lazy_singleton():
from nexent.core.gateway import multimodal_gateway as gateway_module
Expand All @@ -140,4 +146,4 @@ def gateway():

@pytest.fixture
def context():
return _make_context()
return _make_context()
Loading
Loading