mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
chore: merge litellm_internal_staging and resolve type-discipline-budget conflict
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
commit
8442fb2784
35 changed files with 3061 additions and 1112 deletions
|
|
@ -24,7 +24,7 @@
|
|||
"limit": 19
|
||||
},
|
||||
"reportExplicitAny": {
|
||||
"limit": 4125
|
||||
"limit": 4124
|
||||
},
|
||||
"reportFunctionMemberAccess": {
|
||||
"limit": 7
|
||||
|
|
@ -99,7 +99,7 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportUnknownArgumentType": {
|
||||
"limit": 44364
|
||||
"limit": 44362
|
||||
},
|
||||
"reportUnknownLambdaType": {
|
||||
"limit": 109
|
||||
|
|
@ -117,13 +117,13 @@
|
|||
"limit": 111
|
||||
},
|
||||
"reportUnnecessaryComparison": {
|
||||
"limit": 692
|
||||
"limit": 687
|
||||
},
|
||||
"reportUnnecessaryContains": {
|
||||
"limit": 5
|
||||
"limit": 4
|
||||
},
|
||||
"reportUnnecessaryIsInstance": {
|
||||
"limit": 826
|
||||
"limit": 823
|
||||
},
|
||||
"reportUntypedBaseClass": {
|
||||
"limit": 0
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ def _get_prisma_env() -> dict:
|
|||
|
||||
_MIGRATION_TS_RE = re.compile(r"^(\d{14})_")
|
||||
|
||||
_MIGRATION_DEADLOCK_MARKER = "deadlock detected"
|
||||
|
||||
_SPEND_LOGS_ALTER_RE = re.compile(r'^ALTER\s+TABLE\s+"LiteLLM_SpendLogs"\s', re.IGNORECASE)
|
||||
_SPEND_LOGS_ARTIFACT_DROP_RE = re.compile(
|
||||
r'^DROP\s+TABLE\s+"LiteLLM_SpendLogs_[^"]*"', re.IGNORECASE
|
||||
|
|
@ -265,6 +267,50 @@ class ProxyExtrasDBManager:
|
|||
env=prisma_env,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _roll_back_migration_best_effort(migration_name: str) -> None:
|
||||
"""Mark a migration rolled back, tolerating a concurrent resolver
|
||||
having already done it."""
|
||||
try:
|
||||
ProxyExtrasDBManager._roll_back_migration(migration_name)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _failed_migration_logs(migration_name: str) -> Optional[str]:
|
||||
"""Return failed migration logs, or None if the ledger is unavailable."""
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
return None
|
||||
|
||||
try:
|
||||
import psycopg
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
cleaned_url = ProxyExtrasDBManager._strip_prisma_query_params(database_url)
|
||||
ledger_table = psycopg.sql.SQL("{}.{}").format(
|
||||
psycopg.sql.Identifier(
|
||||
ProxyExtrasDBManager._prisma_schema_param(database_url) or "public"
|
||||
),
|
||||
psycopg.sql.Identifier("_prisma_migrations"),
|
||||
)
|
||||
try:
|
||||
with psycopg.connect(
|
||||
cleaned_url, connect_timeout=10, autocommit=True
|
||||
) as conn:
|
||||
row = conn.execute(
|
||||
psycopg.sql.SQL(
|
||||
"SELECT logs FROM {} "
|
||||
"WHERE migration_name = %s AND finished_at IS NULL "
|
||||
"AND rolled_back_at IS NULL"
|
||||
).format(ledger_table),
|
||||
(migration_name,),
|
||||
).fetchone()
|
||||
except (psycopg.OperationalError, psycopg.DatabaseError):
|
||||
return None
|
||||
return (row[0] or "") if row else ""
|
||||
|
||||
@staticmethod
|
||||
def _resolve_specific_migration(migration_name: str):
|
||||
"""Mark a specific migration as applied"""
|
||||
|
|
@ -661,7 +707,8 @@ class ProxyExtrasDBManager:
|
|||
v2 migration resolver (opt-in via --use_v2_migration_resolver).
|
||||
|
||||
Runs `prisma migrate deploy` and handles standard recovery paths
|
||||
(P3005 baseline, P3009/P3018 idempotent errors). Critically, it does
|
||||
(P3005 baseline, P3009/P3018 idempotent errors, deadlocks against a
|
||||
concurrent migrate deploy). Critically, it does
|
||||
NOT call `_resolve_all_migrations` — the diff-and-force recovery that
|
||||
caused schema thrashing when two LiteLLM versions contended for the
|
||||
same DB during rolling deploys.
|
||||
|
|
@ -772,6 +819,20 @@ class ProxyExtrasDBManager:
|
|||
f"Detail: {resolve_err}"
|
||||
) from resolve_err
|
||||
continue
|
||||
if migration_match:
|
||||
migration_name = migration_match.group(1)
|
||||
ledger_logs = ProxyExtrasDBManager._failed_migration_logs(migration_name)
|
||||
if ledger_logs is not None and (
|
||||
ledger_logs == "" or _MIGRATION_DEADLOCK_MARKER in ledger_logs
|
||||
):
|
||||
logger.info(
|
||||
"Migration %s failed in a concurrent migrate deploy "
|
||||
"deadlock race, rolling its ledger row back and retrying",
|
||||
migration_name,
|
||||
)
|
||||
ProxyExtrasDBManager._roll_back_migration_best_effort(migration_name)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
|
|
@ -817,11 +878,42 @@ class ProxyExtrasDBManager:
|
|||
) from resolve_err
|
||||
continue
|
||||
|
||||
if migration_match and _MIGRATION_DEADLOCK_MARKER in stderr:
|
||||
logger.info(
|
||||
"Migration %s deadlocked against a concurrent "
|
||||
"migrate deploy, rolling its ledger row back "
|
||||
"and retrying",
|
||||
migration_match.group(1),
|
||||
)
|
||||
ProxyExtrasDBManager._roll_back_migration_best_effort(
|
||||
migration_match.group(1)
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
) from e
|
||||
|
||||
if _MIGRATION_DEADLOCK_MARKER in stderr:
|
||||
logger.info(
|
||||
"prisma migrate deploy attempt %s deadlocked against "
|
||||
"a concurrent migrate deploy, retrying",
|
||||
attempt + 1,
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
|
||||
if "P1002" in stderr and "advisory lock" in stderr:
|
||||
logger.info(
|
||||
"prisma migrate deploy attempt %s timed out waiting for "
|
||||
"the advisory lock a concurrent migrate deploy holds, retrying",
|
||||
attempt + 1,
|
||||
)
|
||||
time.sleep(random.randrange(5, 15))
|
||||
continue
|
||||
|
||||
raise RuntimeError(
|
||||
"Database migration failed and cannot be auto-recovered. "
|
||||
f"Manual intervention required.\n\nPrisma error:\n{stderr}"
|
||||
|
|
@ -829,9 +921,9 @@ class ProxyExtrasDBManager:
|
|||
|
||||
raise RuntimeError(
|
||||
"Database migration failed after 4 attempts (retry loop "
|
||||
"exhausted by timeouts or repeated idempotent-recovery "
|
||||
"continues). Check database connectivity, load, and "
|
||||
"_prisma_migrations ledger state, and raise "
|
||||
"exhausted by timeouts, deadlock retries, or repeated "
|
||||
"idempotent-recovery continues). Check database connectivity, "
|
||||
"load, and _prisma_migrations ledger state, and raise "
|
||||
f"{PRISMA_MIGRATE_DEPLOY_TIMEOUT_ENV_VAR} if the attempts timed out."
|
||||
)
|
||||
finally:
|
||||
|
|
|
|||
|
|
@ -240,3 +240,223 @@ def test_v2_does_not_call_resolve_all_migrations(monkeypatch, tmp_path):
|
|||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
assert resolve_called["n"] == 0, "v2 must not invoke the diff-and-force recovery"
|
||||
|
||||
|
||||
_DEADLOCK_P3018_STDERR = (
|
||||
"Error: P3018\n"
|
||||
"Migration name: 20260415120000_health_check_latest_per_model_index\n"
|
||||
"Database error code: 40P01\n"
|
||||
"deadlock detected"
|
||||
)
|
||||
|
||||
|
||||
def _stub_v2_env(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:9/x")
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager, "_warn_if_db_ahead_of_head", lambda _: None
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_get_prisma_dir", lambda: str(tmp_path))
|
||||
(tmp_path / "schema.prisma").write_text("// stub")
|
||||
monkeypatch.setattr("time.sleep", lambda _: None)
|
||||
|
||||
|
||||
def _succeed_after(failures: int, stderr: str):
|
||||
calls = {"n": 0}
|
||||
|
||||
class _OkResult:
|
||||
stdout = "Applied migration.\n"
|
||||
stderr = ""
|
||||
|
||||
def _run(*args, **kwargs):
|
||||
if "deploy" not in args[0]:
|
||||
return _OkResult()
|
||||
calls["n"] += 1
|
||||
if calls["n"] <= failures:
|
||||
raise subprocess.CalledProcessError(
|
||||
returncode=1, cmd=args[0], stderr=stderr, output=""
|
||||
)
|
||||
return _OkResult()
|
||||
|
||||
return _run
|
||||
|
||||
|
||||
def test_v2_p3018_deadlock_rolls_back_and_retries(monkeypatch, tmp_path):
|
||||
"""v2: losing the migrate deploy deadlock race against a concurrent
|
||||
instance rolls the ledger row back and retries instead of dying."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
|
||||
rolled_back = []
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_roll_back_migration",
|
||||
lambda name: rolled_back.append(name),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_resolve_specific_migration",
|
||||
lambda name: pytest.fail("a deadlocked migration must never be marked applied"),
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, _DEADLOCK_P3018_STDERR))
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
assert rolled_back == ["20260415120000_health_check_latest_per_model_index"]
|
||||
|
||||
|
||||
def test_v2_p3018_persistent_deadlock_exhausts_attempts(monkeypatch, tmp_path):
|
||||
"""v2: a deadlock on every attempt still fails after the retry budget."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_roll_back_migration", lambda name: None)
|
||||
|
||||
with patch(
|
||||
"subprocess.run",
|
||||
side_effect=_fake_migrate_deploy_failure(1, _DEADLOCK_P3018_STDERR),
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="after 4 attempts"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_v2_p3009_deadlocked_ledger_row_rolls_back_and_retries(monkeypatch, tmp_path):
|
||||
"""v2: the surviving instance sees the victim's failed ledger row as P3009.
|
||||
When that row's logs show a deadlock, roll it back and retry."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
|
||||
stderr = (
|
||||
"Error: P3009\n"
|
||||
"migrate found failed migrations in the target database\n"
|
||||
"The `20260415120000_health_check_latest_per_model_index` migration "
|
||||
"started at 2026-09-01 18:46:13 UTC failed"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_failed_migration_logs",
|
||||
lambda name: "ERROR: deadlock detected\nDETAIL: Process 72 waits for ShareLock",
|
||||
)
|
||||
rolled_back = []
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_roll_back_migration",
|
||||
lambda name: rolled_back.append(name),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_resolve_specific_migration",
|
||||
lambda name: pytest.fail("a deadlocked migration must never be marked applied"),
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr))
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
assert rolled_back == ["20260415120000_health_check_latest_per_model_index"]
|
||||
|
||||
|
||||
def test_v2_p3009_empty_ledger_logs_rolls_back_and_retries(monkeypatch, tmp_path):
|
||||
"""v2: empty failed ledger logs mean a concurrent deploy moved it on."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
|
||||
stderr = (
|
||||
"Error: P3009\n"
|
||||
"migrate found failed migrations in the target database\n"
|
||||
"The `20260415120000_health_check_latest_per_model_index` migration "
|
||||
"started at 2026-09-01 18:46:13 UTC failed"
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_failed_migration_logs", lambda name: "")
|
||||
rolled_back = []
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_roll_back_migration",
|
||||
lambda name: rolled_back.append(name),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_resolve_specific_migration",
|
||||
lambda name: pytest.fail("a deadlocked migration must never be marked applied"),
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr))
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
assert rolled_back == ["20260415120000_health_check_latest_per_model_index"]
|
||||
|
||||
|
||||
def test_v2_p3009_unreadable_ledger_still_raises(monkeypatch, tmp_path):
|
||||
"""v2: an unreadable ledger cannot establish that P3009 was a deadlock."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
|
||||
stderr = (
|
||||
"Error: P3009\n"
|
||||
"migrate found failed migrations in the target database\n"
|
||||
"The `20260415120000_health_check_latest_per_model_index` migration "
|
||||
"started at 2026-09-01 18:46:13 UTC failed"
|
||||
)
|
||||
monkeypatch.setattr(ProxyExtrasDBManager, "_failed_migration_logs", lambda name: None)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_roll_back_migration",
|
||||
lambda name: pytest.fail("an unreadable ledger must not trigger a retry"),
|
||||
)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr))
|
||||
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_v2_p3009_non_deadlock_ledger_row_still_raises(monkeypatch, tmp_path):
|
||||
"""v2: a failed ledger row whose logs show a real SQL error stays fatal."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
|
||||
stderr = (
|
||||
"Error: P3009\n"
|
||||
"migrate found failed migrations in the target database\n"
|
||||
"The `20260101000000_genuinely_broken` migration started at "
|
||||
"2026-09-01 18:46:13 UTC failed"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
ProxyExtrasDBManager,
|
||||
"_failed_migration_logs",
|
||||
lambda name: 'ERROR: syntax error at or near "BRKN"',
|
||||
)
|
||||
|
||||
with patch("subprocess.run", side_effect=_fake_migrate_deploy_failure(1, stderr)):
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
||||
|
||||
def test_v2_bare_deadlock_stderr_retries(monkeypatch, tmp_path):
|
||||
"""v2: a deadlock reported without a Prisma error code (the advisory-lock
|
||||
waiter as victim) is retried, not fatal."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"subprocess.run", _succeed_after(1, "Database error: deadlock detected")
|
||||
)
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
|
||||
|
||||
_P1002_ADVISORY_LOCK_STDERR = (
|
||||
"Error: P1002\n\n"
|
||||
"The database server at `127.0.0.1`:`45743` was reached but timed out.\n\n"
|
||||
"Context: Timed out trying to acquire a postgres advisory lock "
|
||||
"(SELECT pg_advisory_lock(72707369)). Elapsed: 10000ms."
|
||||
)
|
||||
|
||||
|
||||
def test_v2_advisory_lock_timeout_retries(monkeypatch, tmp_path):
|
||||
"""v2: the advisory-lock waiter that times out while a peer's retry holds
|
||||
the lock retries instead of dying."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(2, _P1002_ADVISORY_LOCK_STDERR))
|
||||
|
||||
ok = ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_v2_p1002_without_advisory_lock_context_still_raises(monkeypatch, tmp_path):
|
||||
"""v2: a plain P1002 (database unreachable) stays fatal."""
|
||||
_stub_v2_env(monkeypatch, tmp_path)
|
||||
stderr = "Error: P1002\n\nThe database server at `db`:`5432` was reached but timed out."
|
||||
monkeypatch.setattr("subprocess.run", _succeed_after(1, stderr))
|
||||
|
||||
with pytest.raises(RuntimeError, match="cannot be auto-recovered"):
|
||||
ProxyExtrasDBManager.setup_database(use_migrate=True, use_v2_resolver=True)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ This hook is called before making an LLM request when a vector store is configur
|
|||
It searches the vector store for relevant context and appends it to the messages.
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
import litellm
|
||||
|
|
@ -80,10 +81,17 @@ class VectorStorePreCallHook(CustomLogger):
|
|||
|
||||
# Get prisma_client for database fallback
|
||||
prisma_client = None
|
||||
llm_router = None
|
||||
try:
|
||||
from litellm.proxy.proxy_server import prisma_client as _prisma_client
|
||||
from litellm.proxy.proxy_server import (
|
||||
llm_router as _llm_router,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client as _prisma_client,
|
||||
)
|
||||
|
||||
prisma_client = _prisma_client
|
||||
llm_router = _llm_router
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
|
@ -114,12 +122,26 @@ class VectorStorePreCallHook(CustomLogger):
|
|||
vector_store_id = vector_store_to_run.get("vector_store_id", "")
|
||||
custom_llm_provider = vector_store_to_run.get("custom_llm_provider")
|
||||
litellm_params_for_vector_store = vector_store_to_run.get("litellm_params", {}) or {}
|
||||
# Call litellm.vector_stores.search() with the required parameters
|
||||
search_response = await litellm.vector_stores.asearch(
|
||||
request_litellm_params = litellm_logging_obj.model_call_details.get("litellm_params", {})
|
||||
request_metadata = (
|
||||
request_litellm_params.get("metadata", {}) if isinstance(request_litellm_params, dict) else {}
|
||||
)
|
||||
if llm_router is not None:
|
||||
search_function = cast( # cast-ok: normalize router search callable
|
||||
Callable[..., Awaitable[VectorStoreSearchResponse]],
|
||||
llm_router.avector_store_search,
|
||||
)
|
||||
else:
|
||||
search_function = cast( # cast-ok: normalize SDK search callable
|
||||
Callable[..., Awaitable[VectorStoreSearchResponse]],
|
||||
litellm.vector_stores.asearch,
|
||||
)
|
||||
search_response = await search_function(
|
||||
**{
|
||||
"vector_store_id": vector_store_id,
|
||||
"query": query,
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
"metadata": request_metadata,
|
||||
**litellm_params_for_vector_store,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,15 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure.common_utils import BaseAzureLLM
|
||||
from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig
|
||||
from litellm.llms.base_llm.vector_store.transformation import (
|
||||
BaseQueryEmbeddingVectorStoreConfig,
|
||||
VectorStoreEmbeddingExecutor,
|
||||
)
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.vector_stores import (
|
||||
BaseVectorStoreAuthCredentials,
|
||||
|
|
@ -26,7 +31,7 @@ else:
|
|||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
||||
class AzureAIVectorStoreConfig(BaseVectorStoreConfig, BaseAzureLLM):
|
||||
class AzureAIVectorStoreConfig(BaseQueryEmbeddingVectorStoreConfig, BaseAzureLLM):
|
||||
"""
|
||||
Configuration for Azure AI Search Vector Store
|
||||
|
||||
|
|
@ -110,83 +115,73 @@ class AzureAIVectorStoreConfig(BaseVectorStoreConfig, BaseAzureLLM):
|
|||
def transform_search_vector_store_request(
|
||||
self,
|
||||
vector_store_id: str,
|
||||
query: str | list[str],
|
||||
query: str | Sequence[str],
|
||||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
api_base: str,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""
|
||||
Transform search request for Azure AI Search API
|
||||
litellm_params: Mapping[str, object],
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: Router | None = None,
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
query_text: Final = self.query_text(query)
|
||||
query_vector: Final = self.embed_query(query_text, litellm_params, embedding_executor, router)
|
||||
return self._search_request(
|
||||
vector_store_id,
|
||||
query_text,
|
||||
query_vector,
|
||||
vector_store_search_optional_params,
|
||||
api_base,
|
||||
litellm_logging_obj,
|
||||
litellm_params,
|
||||
)
|
||||
|
||||
Generates embeddings using litellm.embeddings and constructs Azure AI Search request
|
||||
"""
|
||||
# Convert query to string if it's a list
|
||||
if isinstance(query, list):
|
||||
query = " ".join(query)
|
||||
async def atransform_search_vector_store_request(
|
||||
self,
|
||||
vector_store_id: str,
|
||||
query: str | Sequence[str],
|
||||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
api_base: str,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: Router | None = None,
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
query_text: Final = self.query_text(query)
|
||||
query_vector: Final = await self.aembed_query(query_text, litellm_params, embedding_executor, router)
|
||||
return self._search_request(
|
||||
vector_store_id,
|
||||
query_text,
|
||||
query_vector,
|
||||
vector_store_search_optional_params,
|
||||
api_base,
|
||||
litellm_logging_obj,
|
||||
litellm_params,
|
||||
)
|
||||
|
||||
# Get embedding model from litellm_params (required)
|
||||
embedding_model: Final = litellm_params.get("litellm_embedding_model")
|
||||
if not embedding_model:
|
||||
raise ValueError(
|
||||
"embedding_model is required in litellm_params for Azure AI Search. "
|
||||
"Example: litellm_params['embedding_model'] = 'azure/text-embedding-3-large'"
|
||||
)
|
||||
|
||||
embedding_config: Final = litellm_params.get("litellm_embedding_config", {})
|
||||
if not embedding_config:
|
||||
raise ValueError(
|
||||
"embedding_config is required in litellm_params for Azure AI Search. "
|
||||
"Example: litellm_params['embedding_config'] = {'api_base': 'https://krris-mh44uf7y-eastus2.cognitiveservices.azure.com/', 'api_key': 'os.environ/AZURE_API_KEY', 'api_version': '2025-09-01'}"
|
||||
)
|
||||
|
||||
# Get vector field name (defaults to contentVector)
|
||||
@staticmethod
|
||||
def _search_request(
|
||||
vector_store_id: str,
|
||||
query_text: str,
|
||||
query_vector: Sequence[float],
|
||||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
api_base: str,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
vector_field: Final = litellm_params.get("azure_search_vector_field", "contentVector")
|
||||
|
||||
# Get top_k (number of results to return)
|
||||
top_k: Final = vector_store_search_optional_params.get("top_k", 10)
|
||||
|
||||
# Generate embedding for the query using litellm.embeddings
|
||||
try:
|
||||
embedding_response: Final = litellm.embedding(
|
||||
model=embedding_model,
|
||||
input=[query],
|
||||
**embedding_config,
|
||||
)
|
||||
query_vector: Final = embedding_response.data[0]["embedding"]
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to generate embedding for query: {e}")
|
||||
|
||||
# Azure AI Search endpoint for search
|
||||
index_name: Final = vector_store_id # vector_store_id is the index name
|
||||
url: Final = f"{api_base}/indexes/{index_name}/docs/search?api-version=2024-07-01"
|
||||
|
||||
# Build the request body for Azure AI Search with vector search
|
||||
request_body: Final = {
|
||||
"search": "*", # Get all documents (filtered by vector similarity)
|
||||
"vectorQueries": [
|
||||
{
|
||||
"vector": query_vector,
|
||||
"fields": vector_field,
|
||||
"kind": "vector",
|
||||
"k": top_k, # Number of nearest neighbors to return
|
||||
}
|
||||
],
|
||||
"select": "id,content", # Fields to return (customize based on schema)
|
||||
litellm_logging_obj.model_call_details["input"] = query_text
|
||||
litellm_logging_obj.model_call_details["embedding_model"] = litellm_params.get("litellm_embedding_model")
|
||||
litellm_logging_obj.model_call_details["top_k"] = top_k
|
||||
return f"{api_base}/indexes/{vector_store_id}/docs/search?api-version=2024-07-01", {
|
||||
"search": "*",
|
||||
"vectorQueries": [{"vector": query_vector, "fields": vector_field, "kind": "vector", "k": top_k}],
|
||||
"select": "id,content",
|
||||
"top": top_k,
|
||||
}
|
||||
|
||||
#########################################################
|
||||
# Update logging object with details of the request
|
||||
#########################################################
|
||||
litellm_logging_obj.model_call_details["input"] = query
|
||||
litellm_logging_obj.model_call_details["embedding_model"] = embedding_model
|
||||
litellm_logging_obj.model_call_details["top_k"] = top_k
|
||||
|
||||
return url, request_body
|
||||
|
||||
def transform_search_vector_store_response(
|
||||
self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj
|
||||
) -> VectorStoreSearchResponse:
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, NoReturn
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, runtime_checkable
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
from litellm.types.vector_stores import (
|
||||
VECTOR_STORE_OPENAI_PARAMS,
|
||||
BaseVectorStoreAuthCredentials,
|
||||
|
|
@ -28,6 +34,95 @@ else:
|
|||
BaseLLMException = Any
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class VectorStoreEmbeddingExecutor(Protocol):
|
||||
def embed(self, model: str, query: str, configuration: Mapping[str, object]) -> EmbeddingResponse: ...
|
||||
|
||||
async def aembed(self, model: str, query: str, configuration: Mapping[str, object]) -> EmbeddingResponse: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiteLLMVectorStoreEmbeddingExecutor:
|
||||
def embed(self, model: str, query: str, configuration: Mapping[str, object]) -> EmbeddingResponse:
|
||||
import litellm
|
||||
|
||||
return litellm.embedding( # pyright: ignore[reportCallIssue, reportUnknownMemberType, reportUnknownVariableType] # provider kwargs are intentionally dynamic
|
||||
model=model,
|
||||
input=[query], # mutable-ok: LiteLLM embedding requires a mutable input list
|
||||
**dict(configuration), # pyright: ignore[reportArgumentType] # provider-specific embedding config is validated downstream # mutable-ok: kwargs require a concrete dict
|
||||
)
|
||||
|
||||
async def aembed(self, model: str, query: str, configuration: Mapping[str, object]) -> EmbeddingResponse:
|
||||
import litellm
|
||||
|
||||
return await litellm.aembedding( # pyright: ignore[reportUnknownMemberType] # provider kwargs are intentionally dynamic
|
||||
model=model,
|
||||
input=[query], # mutable-ok: LiteLLM embedding requires a mutable input list
|
||||
**dict(configuration), # pyright: ignore[reportArgumentType] # provider-specific embedding config is validated downstream # mutable-ok: kwargs require a concrete dict
|
||||
)
|
||||
|
||||
|
||||
_REQUEST_METADATA: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def vector_store_request_metadata(kwargs: Mapping[str, object]) -> Mapping[str, object]:
|
||||
litellm_metadata: Final = kwargs.get("litellm_metadata")
|
||||
if isinstance(litellm_metadata, dict):
|
||||
return _REQUEST_METADATA.validate_python(litellm_metadata)
|
||||
metadata: Final = kwargs.get("metadata")
|
||||
if isinstance(metadata, dict):
|
||||
return _REQUEST_METADATA.validate_python(metadata)
|
||||
return MappingProxyType({})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouterVectorStoreEmbeddingExecutor:
|
||||
router: Router
|
||||
metadata: Mapping[str, object]
|
||||
|
||||
def _embedding_kwargs(self, configuration: Mapping[str, object]) -> Mapping[str, object]:
|
||||
configured_metadata: Final = configuration.get("metadata")
|
||||
metadata: Final = {
|
||||
**(configured_metadata if isinstance(configured_metadata, Mapping) else {}),
|
||||
**self.metadata,
|
||||
}
|
||||
return {
|
||||
**{key: value for key, value in configuration.items() if key not in ("input", "metadata", "model")},
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
def _router_serves(self, model: str) -> bool:
|
||||
team_id: Final = self.metadata.get("user_api_key_team_id")
|
||||
resolved: Final = self.router.resolved_litellm_models(model, team_id if isinstance(team_id, str) else None)
|
||||
deployment_models: Final = (
|
||||
deployment.get("litellm_params", {}).get("model") for deployment in self.router.get_model_list() or ()
|
||||
)
|
||||
return bool(resolved) or model in deployment_models
|
||||
|
||||
def _embeds_through_sdk(self, model: str, configuration: Mapping[str, object]) -> bool:
|
||||
return bool(configuration) and not self._router_serves(model)
|
||||
|
||||
def embed(self, model: str, query: str, configuration: Mapping[str, object]) -> EmbeddingResponse:
|
||||
embedding_kwargs: Final = self._embedding_kwargs(configuration)
|
||||
if self._embeds_through_sdk(model, configuration):
|
||||
return LiteLLMVectorStoreEmbeddingExecutor().embed(model, query, embedding_kwargs)
|
||||
return self.router.embedding( # pyright: ignore[reportUnknownMemberType] # Router embedding input retains a legacy untyped list
|
||||
model=model,
|
||||
input=[query], # mutable-ok: Router embedding requires a mutable input list
|
||||
**embedding_kwargs, # pyright: ignore[reportArgumentType] # provider kwargs are intentionally dynamic
|
||||
)
|
||||
|
||||
async def aembed(self, model: str, query: str, configuration: Mapping[str, object]) -> EmbeddingResponse:
|
||||
embedding_kwargs: Final = self._embedding_kwargs(configuration)
|
||||
if self._embeds_through_sdk(model, configuration):
|
||||
return await LiteLLMVectorStoreEmbeddingExecutor().aembed(model, query, embedding_kwargs)
|
||||
return await self.router.aembedding( # pyright: ignore[reportUnknownMemberType] # Router embedding input retains a legacy untyped list
|
||||
model=model,
|
||||
input=[query], # mutable-ok: Router embedding requires a mutable input list
|
||||
**embedding_kwargs, # pyright: ignore[reportArgumentType] # provider kwargs are intentionally dynamic
|
||||
)
|
||||
|
||||
|
||||
class BaseVectorStoreConfig:
|
||||
def get_supported_openai_params(self, model: str) -> list[VECTOR_STORE_OPENAI_PARAMS]:
|
||||
return []
|
||||
|
|
@ -58,7 +153,7 @@ class BaseVectorStoreConfig:
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
router: Router | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
pass
|
||||
|
||||
|
|
@ -71,7 +166,7 @@ class BaseVectorStoreConfig:
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
router: Router | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Optional async version of transform_search_vector_store_request.
|
||||
|
|
@ -161,6 +256,116 @@ class BaseVectorStoreConfig:
|
|||
return 0.0, 0.0
|
||||
|
||||
|
||||
_EMPTY_EMBEDDING_CONFIGURATION: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
_QUERY_VECTOR: Final = TypeAdapter(list[float])
|
||||
|
||||
|
||||
class BaseQueryEmbeddingVectorStoreConfig(BaseVectorStoreConfig):
|
||||
@abstractmethod
|
||||
def transform_search_vector_store_request(
|
||||
self,
|
||||
vector_store_id: str,
|
||||
query: str | Sequence[str],
|
||||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
api_base: str,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: Router | None = None,
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
pass
|
||||
|
||||
async def atransform_search_vector_store_request(
|
||||
self,
|
||||
vector_store_id: str,
|
||||
query: str | Sequence[str],
|
||||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
api_base: str,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: Router | None = None,
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
return self.transform_search_vector_store_request(
|
||||
vector_store_id=vector_store_id,
|
||||
query=query,
|
||||
vector_store_search_optional_params=vector_store_search_optional_params,
|
||||
api_base=api_base,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
litellm_params=litellm_params,
|
||||
extra_body=extra_body,
|
||||
router=router,
|
||||
embedding_executor=embedding_executor,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def query_text(query: str | Sequence[str]) -> str:
|
||||
return query if isinstance(query, str) else " ".join(query)
|
||||
|
||||
@staticmethod
|
||||
def query_embedding_model(litellm_params: Mapping[str, object]) -> str:
|
||||
embedding_model: Final = litellm_params.get("litellm_embedding_model")
|
||||
if isinstance(embedding_model, str) and embedding_model:
|
||||
return embedding_model
|
||||
raise ValueError(
|
||||
"litellm_embedding_model is required in litellm_params for this vector store. "
|
||||
"Example: litellm_params['litellm_embedding_model'] = 'openai/text-embedding-3-small'"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def query_embedding_configuration(litellm_params: Mapping[str, object]) -> Mapping[str, object]:
|
||||
configuration: Final = litellm_params.get("litellm_embedding_config")
|
||||
if isinstance(configuration, Mapping):
|
||||
return {str(key): value for key, value in configuration.items()} # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] # litellm_params is an untyped dict, keys are re-validated as str here
|
||||
return _EMPTY_EMBEDDING_CONFIGURATION
|
||||
|
||||
@staticmethod
|
||||
def query_embedding_executor(
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None,
|
||||
router: Router | None,
|
||||
request_metadata: Mapping[str, object] = MappingProxyType({}),
|
||||
) -> VectorStoreEmbeddingExecutor:
|
||||
if embedding_executor is not None:
|
||||
return embedding_executor
|
||||
if router is not None:
|
||||
return RouterVectorStoreEmbeddingExecutor(router=router, metadata=request_metadata)
|
||||
return LiteLLMVectorStoreEmbeddingExecutor()
|
||||
|
||||
def embed_query(
|
||||
self,
|
||||
query_text: str,
|
||||
litellm_params: Mapping[str, object],
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None,
|
||||
router: Router | None = None,
|
||||
) -> Sequence[float]:
|
||||
model: Final = self.query_embedding_model(litellm_params)
|
||||
configuration: Final = self.query_embedding_configuration(litellm_params)
|
||||
executor: Final = self.query_embedding_executor(embedding_executor, router)
|
||||
try:
|
||||
response: Final = executor.embed(model, query_text, configuration)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to generate embedding for query: {e}")
|
||||
return _QUERY_VECTOR.validate_python(response.data[0]["embedding"]) # pyright: ignore[reportUnknownMemberType] # EmbeddingResponse.data is an untyped list, the vector is validated here
|
||||
|
||||
async def aembed_query(
|
||||
self,
|
||||
query_text: str,
|
||||
litellm_params: Mapping[str, object],
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None,
|
||||
router: Router | None = None,
|
||||
) -> Sequence[float]:
|
||||
model: Final = self.query_embedding_model(litellm_params)
|
||||
configuration: Final = self.query_embedding_configuration(litellm_params)
|
||||
executor: Final = self.query_embedding_executor(embedding_executor, router)
|
||||
try:
|
||||
response: Final = await executor.aembed(model, query_text, configuration)
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to generate embedding for query: {e}")
|
||||
return _QUERY_VECTOR.validate_python(response.data[0]["embedding"]) # pyright: ignore[reportUnknownMemberType] # EmbeddingResponse.data is an untyped list, the vector is validated here
|
||||
|
||||
|
||||
class BaseDirectVectorStoreConfig(BaseVectorStoreConfig):
|
||||
"""
|
||||
Base config for vector store providers whose datastore has no HTTP API
|
||||
|
|
@ -176,6 +381,7 @@ class BaseDirectVectorStoreConfig(BaseVectorStoreConfig):
|
|||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
) -> VectorStoreSearchResponse:
|
||||
pass
|
||||
|
|
@ -188,6 +394,7 @@ class BaseDirectVectorStoreConfig(BaseVectorStoreConfig):
|
|||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
) -> VectorStoreSearchResponse:
|
||||
pass
|
||||
|
|
@ -201,7 +408,7 @@ class BaseDirectVectorStoreConfig(BaseVectorStoreConfig):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: "Router | None" = None,
|
||||
router: Router | None = None,
|
||||
) -> NoReturn:
|
||||
raise NotImplementedError("Direct vector store providers execute the search themselves; no HTTP request shape")
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,9 @@ from litellm.llms.base_llm.skills.transformation import BaseSkillsAPIConfig
|
|||
from litellm.llms.base_llm.text_to_speech.transformation import BaseTextToSpeechConfig
|
||||
from litellm.llms.base_llm.vector_store.transformation import (
|
||||
BaseDirectVectorStoreConfig,
|
||||
BaseQueryEmbeddingVectorStoreConfig,
|
||||
BaseVectorStoreConfig,
|
||||
VectorStoreEmbeddingExecutor,
|
||||
)
|
||||
from litellm.llms.base_llm.vector_store_files.transformation import (
|
||||
BaseVectorStoreFilesConfig,
|
||||
|
|
@ -9701,6 +9703,7 @@ class BaseLLMHTTPHandler:
|
|||
custom_llm_provider: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
|
|
@ -9721,6 +9724,7 @@ class BaseLLMHTTPHandler:
|
|||
vector_store_search_optional_params=vector_store_search_optional_params,
|
||||
litellm_logging_obj=logging_obj,
|
||||
litellm_params=dict(litellm_params), # mutable-ok: snapshot GenericLiteLLMParams into the Mapping shape
|
||||
embedding_executor=embedding_executor,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
|
@ -9744,8 +9748,7 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=dict(litellm_params),
|
||||
)
|
||||
|
||||
# Check if provider has async transform method
|
||||
if hasattr(vector_store_provider_config, "atransform_search_vector_store_request"):
|
||||
if isinstance(vector_store_provider_config, BaseQueryEmbeddingVectorStoreConfig):
|
||||
(
|
||||
url,
|
||||
request_body,
|
||||
|
|
@ -9758,12 +9761,13 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=dict(litellm_params),
|
||||
extra_body=extra_body,
|
||||
router=router,
|
||||
embedding_executor=embedding_executor,
|
||||
)
|
||||
else:
|
||||
(
|
||||
url,
|
||||
request_body,
|
||||
) = vector_store_provider_config.transform_search_vector_store_request(
|
||||
) = await vector_store_provider_config.atransform_search_vector_store_request(
|
||||
vector_store_id=vector_store_id,
|
||||
query=query,
|
||||
vector_store_search_optional_params=vector_store_search_optional_params,
|
||||
|
|
@ -9818,6 +9822,7 @@ class BaseLLMHTTPHandler:
|
|||
custom_llm_provider: str,
|
||||
litellm_params: GenericLiteLLMParams,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
extra_headers: dict[str, object] | None = None,
|
||||
extra_body: dict[str, object] | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
|
|
@ -9834,6 +9839,7 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=litellm_params,
|
||||
logging_obj=logging_obj,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
embedding_executor=embedding_executor,
|
||||
extra_headers=extra_headers,
|
||||
extra_body=extra_body,
|
||||
timeout=timeout,
|
||||
|
|
@ -9854,6 +9860,7 @@ class BaseLLMHTTPHandler:
|
|||
vector_store_search_optional_params=vector_store_search_optional_params,
|
||||
litellm_logging_obj=logging_obj,
|
||||
litellm_params=dict(litellm_params), # mutable-ok: snapshot GenericLiteLLMParams into the Mapping shape
|
||||
embedding_executor=embedding_executor,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
|
@ -9874,19 +9881,35 @@ class BaseLLMHTTPHandler:
|
|||
litellm_params=dict(litellm_params),
|
||||
)
|
||||
|
||||
(
|
||||
url,
|
||||
request_body,
|
||||
) = vector_store_provider_config.transform_search_vector_store_request(
|
||||
vector_store_id=vector_store_id,
|
||||
query=query,
|
||||
vector_store_search_optional_params=vector_store_search_optional_params,
|
||||
api_base=api_base,
|
||||
litellm_logging_obj=logging_obj,
|
||||
litellm_params=dict(litellm_params),
|
||||
extra_body=extra_body,
|
||||
router=router,
|
||||
)
|
||||
if isinstance(vector_store_provider_config, BaseQueryEmbeddingVectorStoreConfig):
|
||||
(
|
||||
url,
|
||||
request_body,
|
||||
) = vector_store_provider_config.transform_search_vector_store_request(
|
||||
vector_store_id=vector_store_id,
|
||||
query=query,
|
||||
vector_store_search_optional_params=vector_store_search_optional_params,
|
||||
api_base=api_base,
|
||||
litellm_logging_obj=logging_obj,
|
||||
litellm_params=dict(litellm_params),
|
||||
extra_body=extra_body,
|
||||
router=router,
|
||||
embedding_executor=embedding_executor,
|
||||
)
|
||||
else:
|
||||
(
|
||||
url,
|
||||
request_body,
|
||||
) = vector_store_provider_config.transform_search_vector_store_request(
|
||||
vector_store_id=vector_store_id,
|
||||
query=query,
|
||||
vector_store_search_optional_params=vector_store_search_optional_params,
|
||||
api_base=api_base,
|
||||
litellm_logging_obj=logging_obj,
|
||||
litellm_params=dict(litellm_params),
|
||||
extra_body=extra_body,
|
||||
router=router,
|
||||
)
|
||||
|
||||
all_optional_params: Final[dict[str, object]] = dict(litellm_params)
|
||||
all_optional_params.update(vector_store_search_optional_params or {})
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig
|
||||
from litellm.llms.base_llm.vector_store.transformation import (
|
||||
BaseQueryEmbeddingVectorStoreConfig,
|
||||
VectorStoreEmbeddingExecutor,
|
||||
)
|
||||
from litellm.secret_managers.main import get_secret_str
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.vector_stores import (
|
||||
|
|
@ -37,7 +42,7 @@ MILVUS_OPTIONAL_PARAMS: Final = {
|
|||
}
|
||||
|
||||
|
||||
class MilvusVectorStoreConfig(BaseVectorStoreConfig):
|
||||
class MilvusVectorStoreConfig(BaseQueryEmbeddingVectorStoreConfig):
|
||||
"""
|
||||
Configuration for Milvus Vector Store
|
||||
|
||||
|
|
@ -118,78 +123,79 @@ class MilvusVectorStoreConfig(BaseVectorStoreConfig):
|
|||
def transform_search_vector_store_request(
|
||||
self,
|
||||
vector_store_id: str,
|
||||
query: str | list[str],
|
||||
query: str | Sequence[str],
|
||||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
api_base: str,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""
|
||||
Transform search request for Azure AI Search API
|
||||
litellm_params: Mapping[str, object],
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: Router | None = None,
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
query_text: Final = self.query_text(query)
|
||||
query_vector: Final = self.embed_query(query_text, litellm_params, embedding_executor, router)
|
||||
return self._search_request(
|
||||
vector_store_id,
|
||||
query_text,
|
||||
query_vector,
|
||||
vector_store_search_optional_params,
|
||||
api_base,
|
||||
litellm_logging_obj,
|
||||
litellm_params,
|
||||
)
|
||||
|
||||
Generates embeddings using litellm.embeddings and constructs Azure AI Search request
|
||||
"""
|
||||
# Convert query to string if it's a list
|
||||
if isinstance(query, list):
|
||||
query = " ".join(query)
|
||||
async def atransform_search_vector_store_request(
|
||||
self,
|
||||
vector_store_id: str,
|
||||
query: str | Sequence[str],
|
||||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
api_base: str,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: Router | None = None,
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
query_text: Final = self.query_text(query)
|
||||
query_vector: Final = await self.aembed_query(query_text, litellm_params, embedding_executor, router)
|
||||
return self._search_request(
|
||||
vector_store_id,
|
||||
query_text,
|
||||
query_vector,
|
||||
vector_store_search_optional_params,
|
||||
api_base,
|
||||
litellm_logging_obj,
|
||||
litellm_params,
|
||||
)
|
||||
|
||||
# Get embedding model from litellm_params (required)
|
||||
embedding_model: Final = litellm_params.get("litellm_embedding_model")
|
||||
if not embedding_model:
|
||||
raise ValueError(
|
||||
"embedding_model is required in litellm_params for Milvus. You can call any litellm embedding model."
|
||||
"Example: litellm_params['embedding_model'] = 'azure/text-embedding-3-large'"
|
||||
@staticmethod
|
||||
def _search_request(
|
||||
vector_store_id: str,
|
||||
query_text: str,
|
||||
query_vector: Sequence[float],
|
||||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
api_base: str,
|
||||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
scope: Final = {
|
||||
key: value
|
||||
for key, value in (
|
||||
("dbName", litellm_params.get("milvus_db_name")),
|
||||
("partitionNames", litellm_params.get("milvus_partition_names")),
|
||||
)
|
||||
|
||||
embedding_config: Final = litellm_params.get("litellm_embedding_config", {})
|
||||
if not embedding_config:
|
||||
raise ValueError(
|
||||
"embedding_config is required in litellm_params for Milvus. You can call any litellm embedding model."
|
||||
"Example: litellm_params['embedding_config'] = {'api_base': 'https://krris-mh44uf7y-eastus2.cognitiveservices.azure.com/', 'api_key': 'os.environ/AZURE_API_KEY', 'api_version': '2025-09-01'}"
|
||||
)
|
||||
|
||||
# Get top_k (number of results to return)
|
||||
# Generate embedding for the query using litellm.embeddings
|
||||
try:
|
||||
embedding_response: Final = litellm.embedding(
|
||||
model=embedding_model,
|
||||
input=[query],
|
||||
**embedding_config,
|
||||
)
|
||||
query_vector: Final = embedding_response.data[0]["embedding"]
|
||||
except Exception as e:
|
||||
raise Exception(f"Failed to generate embedding for query: {e}")
|
||||
|
||||
# Azure AI Search endpoint for search
|
||||
index_name: Final = vector_store_id # vector_store_id is the index name
|
||||
url: Final = f"{api_base}/v2/vectordb/entities/search"
|
||||
|
||||
# Build the request body for Azure AI Search with vector search
|
||||
request_body: Final[dict[str, Any]] = {
|
||||
"collectionName": index_name,
|
||||
if value
|
||||
}
|
||||
litellm_logging_obj.model_call_details["input"] = query_text
|
||||
litellm_logging_obj.model_call_details["embedding_model"] = litellm_params.get("litellm_embedding_model")
|
||||
return f"{api_base}/v2/vectordb/entities/search", {
|
||||
"collectionName": vector_store_id,
|
||||
"data": [query_vector],
|
||||
"annsField": "book_intro_vector",
|
||||
**vector_store_search_optional_params,
|
||||
**scope,
|
||||
}
|
||||
|
||||
db_name: Final = litellm_params.get("milvus_db_name")
|
||||
if db_name:
|
||||
request_body["dbName"] = db_name
|
||||
|
||||
partition_names: Final = litellm_params.get("milvus_partition_names")
|
||||
if partition_names:
|
||||
request_body["partitionNames"] = partition_names
|
||||
|
||||
#########################################################
|
||||
# Update logging object with details of the request
|
||||
#########################################################
|
||||
litellm_logging_obj.model_call_details["input"] = query
|
||||
litellm_logging_obj.model_call_details["embedding_model"] = embedding_model
|
||||
|
||||
return url, request_body
|
||||
|
||||
def transform_search_vector_store_response(
|
||||
self, response: httpx.Response, litellm_logging_obj: LiteLLMLoggingObj
|
||||
) -> VectorStoreSearchResponse:
|
||||
|
|
|
|||
|
|
@ -423,6 +423,7 @@ class OllamaChatConfig(BaseConfig):
|
|||
class OllamaChatCompletionResponseIterator(BaseModelResponseIterator):
|
||||
started_reasoning_content: bool = False
|
||||
finished_reasoning_content: bool = False
|
||||
seen_tool_calls: bool = False
|
||||
|
||||
def _is_function_call_complete(self, function_args: str | dict) -> bool:
|
||||
if isinstance(function_args, dict):
|
||||
|
|
@ -468,6 +469,7 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator):
|
|||
# process tool calls - if complete function arg - add id to tool call
|
||||
tool_calls: Final = chunk["message"].get("tool_calls")
|
||||
if tool_calls is not None:
|
||||
self.seen_tool_calls = True
|
||||
for tool_call in tool_calls:
|
||||
function_args = tool_call.get("function").get("arguments")
|
||||
if function_args is not None and len(function_args) > 0:
|
||||
|
|
@ -508,9 +510,10 @@ class OllamaChatCompletionResponseIterator(BaseModelResponseIterator):
|
|||
|
||||
if chunk["done"] is True:
|
||||
finish_reason = chunk.get("done_reason") or "stop"
|
||||
# Override finish_reason when tool_calls are present
|
||||
# Override finish_reason when tool_calls appeared in any chunk
|
||||
# Fixes: https://github.com/BerriAI/litellm/issues/18922
|
||||
if tool_calls is not None:
|
||||
# Fixes: https://github.com/BerriAI/litellm/issues/34692
|
||||
if self.seen_tool_calls:
|
||||
finish_reason = "tool_calls"
|
||||
choices = [
|
||||
StreamingChoices(
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ Output: response.output is List[GenericResponseOutputItem] where each has:
|
|||
- text: str
|
||||
"""
|
||||
|
||||
import copy
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
|
@ -36,7 +37,6 @@ from types import MappingProxyType
|
|||
from typing import TYPE_CHECKING, Any, Final, Union, cast
|
||||
|
||||
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
|
||||
from openai.types.responses.tool_param import FunctionToolParam
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
|
|
@ -49,6 +49,7 @@ from litellm.llms.base_llm.guardrail_translation.utils import (
|
|||
blocked_responses_stream_usage,
|
||||
stream_item_field,
|
||||
)
|
||||
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
|
|
@ -62,7 +63,6 @@ from litellm.types.llms.openai import (
|
|||
ContentPartDonePartOutputText,
|
||||
ErrorEvent,
|
||||
ErrorEventError,
|
||||
OpenAIMcpServerTool,
|
||||
OutputItemAddedEvent,
|
||||
OutputItemDoneEvent,
|
||||
OutputTextDeltaEvent,
|
||||
|
|
@ -157,23 +157,31 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
Handles both string input and list of message objects.
|
||||
"""
|
||||
input_data: Final[str | ResponseInputParam | None] = data.get("input")
|
||||
tools_to_check: Final[list[ChatCompletionToolParam]] = []
|
||||
if input_data is None:
|
||||
return data
|
||||
|
||||
structured_messages: Final = self.get_structured_messages(data)
|
||||
raw_tools: Final = data.get("tools")
|
||||
original_tools: Final[tuple[Mapping[str, object], ...]] = (
|
||||
tuple(raw_tools) if isinstance(raw_tools, list) else ()
|
||||
)
|
||||
flattened_tool_groups: Final = tuple(
|
||||
form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(original_tools)
|
||||
)
|
||||
flattened_tools: Final = tuple(
|
||||
cast(ChatCompletionToolParam, tool) # cast-ok: mcp tools ride along in the guardrail's tool list
|
||||
for group in flattened_tool_groups
|
||||
for tool in group
|
||||
)
|
||||
tools_to_check: Final[list[ChatCompletionToolParam]] = list( # mutable-ok: guardrail inputs want a list
|
||||
copy.deepcopy(flattened_tools)
|
||||
)
|
||||
|
||||
# Handle simple string input
|
||||
if isinstance(input_data, str):
|
||||
inputs = GenericGuardrailAPIInputs(texts=[input_data])
|
||||
original_tools: list[dict[str, object]] = []
|
||||
|
||||
# Extract and transform tools if present
|
||||
if "tools" in data and data["tools"]:
|
||||
original_tools = list(data["tools"])
|
||||
self._extract_and_transform_tools(data["tools"], tools_to_check)
|
||||
if tools_to_check:
|
||||
inputs["tools"] = tools_to_check
|
||||
if tools_to_check:
|
||||
inputs["tools"] = tools_to_check
|
||||
if structured_messages:
|
||||
inputs["structured_messages"] = structured_messages
|
||||
# Include model information if available
|
||||
|
|
@ -189,7 +197,9 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
)
|
||||
guardrailed_texts = guardrailed_inputs.get("texts", [])
|
||||
data["input"] = guardrailed_texts[0] if guardrailed_texts else input_data
|
||||
self._apply_guardrailed_tools_to_data(data, original_tools, guardrailed_inputs.get("tools"))
|
||||
self._apply_guardrailed_tools_to_data(
|
||||
data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools")
|
||||
)
|
||||
verbose_proxy_logger.debug("OpenAI Responses API: Processed string input")
|
||||
return data
|
||||
|
||||
|
|
@ -200,7 +210,6 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
texts_to_check: Final[list[str]] = []
|
||||
images_to_check: Final[list[str]] = []
|
||||
task_mappings: Final[list[tuple[int, int | None]]] = []
|
||||
original_tools_list: Final[list[dict[str, object]]] = list(data.get("tools") or [])
|
||||
|
||||
# Step 1: Extract all text content, images, and tools
|
||||
for msg_idx, message in enumerate(input_data):
|
||||
|
|
@ -212,10 +221,6 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
task_mappings=task_mappings,
|
||||
)
|
||||
|
||||
# Extract and transform tools if present
|
||||
if "tools" in data and data["tools"]:
|
||||
self._extract_and_transform_tools(data["tools"], tools_to_check)
|
||||
|
||||
# Step 2: Apply guardrail to all texts in batch
|
||||
if texts_to_check:
|
||||
inputs = GenericGuardrailAPIInputs(texts=texts_to_check)
|
||||
|
|
@ -238,9 +243,7 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
|
||||
guardrailed_texts = guardrailed_inputs.get("texts", [])
|
||||
self._apply_guardrailed_tools_to_data(
|
||||
data,
|
||||
original_tools_list,
|
||||
guardrailed_inputs.get("tools"),
|
||||
data, original_tools, flattened_tool_groups, guardrailed_inputs.get("tools")
|
||||
)
|
||||
|
||||
# Step 3: Map guardrail responses back to original input structure
|
||||
|
|
@ -267,73 +270,18 @@ class OpenAIResponsesHandler(BaseTranslation):
|
|||
names.append(str(tool["server_label"]))
|
||||
return names
|
||||
|
||||
def _extract_and_transform_tools(
|
||||
self,
|
||||
tools: list[FunctionToolParam | OpenAIMcpServerTool],
|
||||
tools_to_check: list[ChatCompletionToolParam],
|
||||
) -> None:
|
||||
"""
|
||||
Extract and transform tools from Responses API format to Chat Completion format.
|
||||
|
||||
Uses the LiteLLM transformation function to convert Responses API tools
|
||||
to Chat Completion tools that can be passed to guardrails.
|
||||
"""
|
||||
if tools is not None and isinstance(tools, list):
|
||||
# Transform Responses API tools to Chat Completion tools
|
||||
(
|
||||
transformed_tools,
|
||||
_,
|
||||
) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools)
|
||||
tools_to_check.extend(cast(list[ChatCompletionToolParam], transformed_tools))
|
||||
|
||||
def _remap_tools_to_responses_api_format(self, guardrailed_tools: list[Any]) -> list[dict[str, object]]:
|
||||
"""
|
||||
Remap guardrail-returned tools (Chat Completion format) back to
|
||||
Responses API request tool format.
|
||||
"""
|
||||
return LiteLLMCompletionResponsesConfig.transform_chat_completion_tool_params_to_responses_api_tools(
|
||||
guardrailed_tools
|
||||
)
|
||||
|
||||
def _merge_tools_after_guardrail(
|
||||
self,
|
||||
original_tools: list[dict[str, object]],
|
||||
remapped: list[dict[str, object]],
|
||||
) -> list[dict[str, object]]:
|
||||
"""
|
||||
Merge remapped guardrailed tools with original tools that were not sent
|
||||
to the guardrail (e.g. web_search, web_search_preview), preserving order.
|
||||
Tools a guardrail appended (``remapped`` longer than ``original_tools``)
|
||||
have no original slot and are kept so an injected tool is not dropped.
|
||||
"""
|
||||
if not original_tools:
|
||||
return remapped
|
||||
result: Final[list[dict[str, object]]] = []
|
||||
j = 0
|
||||
for tool in original_tools:
|
||||
if isinstance(tool, dict) and tool.get("type") in (
|
||||
"web_search",
|
||||
"web_search_preview",
|
||||
):
|
||||
result.append(tool)
|
||||
else:
|
||||
if j < len(remapped):
|
||||
result.append(remapped[j])
|
||||
j += 1
|
||||
# Keep guardrail-appended tools that matched no original slot above.
|
||||
result.extend(remapped[j:])
|
||||
return result
|
||||
|
||||
def _apply_guardrailed_tools_to_data(
|
||||
self,
|
||||
data: dict,
|
||||
original_tools: list[dict[str, object]],
|
||||
guardrailed_tools: list[ChatCompletionToolParam] | None,
|
||||
original_tools: Sequence[Mapping[str, object]],
|
||||
flattened_tool_groups: Sequence[Sequence[Mapping[str, object]]],
|
||||
guardrailed_tools: Sequence[ChatCompletionToolParam] | None,
|
||||
) -> None:
|
||||
"""Remap guardrailed tools to Responses API format and merge with original, then set data['tools']."""
|
||||
if guardrailed_tools is not None:
|
||||
remapped: Final = self._remap_tools_to_responses_api_format(guardrailed_tools)
|
||||
data["tools"] = self._merge_tools_after_guardrail(original_tools, remapped)
|
||||
if guardrailed_tools is None:
|
||||
return
|
||||
data["tools"] = list( # mutable-ok: downstream wants a list # rebind-ok: in-place request rewrite
|
||||
merge_guardrailed_tools(original_tools, flattened_tool_groups, guardrailed_tools)
|
||||
)
|
||||
|
||||
def _extract_input_text_and_images(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from itertools import accumulate, chain, groupby
|
||||
from types import MappingProxyType
|
||||
from typing import Final, TypeAlias
|
||||
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
NAMESPACE_DESCRIPTION_SEPARATOR,
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
|
||||
Tool: TypeAlias = Mapping[str, object]
|
||||
IndexedKey: TypeAlias = tuple[str, int]
|
||||
|
||||
_TOOL_ADAPTER: Final = TypeAdapter(dict[str, object])
|
||||
_CHAT_TOOL_TOP_LEVEL_KEYS: Final = frozenset({"type", "function"})
|
||||
|
||||
|
||||
def _as_tool(value: object) -> Tool | None:
|
||||
candidate: Final = value.model_dump(exclude_unset=True) if isinstance(value, BaseModel) else value
|
||||
try:
|
||||
return _TOOL_ADAPTER.validate_python(candidate)
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def _validated_tools(values: Iterable[object]) -> tuple[Tool, ...]:
|
||||
validated: Final = tuple(map(_as_tool, values))
|
||||
dropped: Final = sum(tool is None for tool in validated)
|
||||
if dropped:
|
||||
verbose_logger.warning("Dropping %d guardrail-returned tools that are not objects", dropped)
|
||||
return tuple(tool for tool in validated if tool is not None)
|
||||
|
||||
|
||||
def _is_function(tool: Tool) -> bool:
|
||||
return tool.get("type") == "function"
|
||||
|
||||
|
||||
def _chat_tool_key(tool: Tool) -> str:
|
||||
tool_type: Final = str(tool.get("type") or "")
|
||||
function: Final = _as_tool(tool.get("function"))
|
||||
if function is not None:
|
||||
return f"{tool_type}:{function.get('name') or ''}"
|
||||
return f"{tool_type}:{tool.get('server_label') or tool.get('name') or ''}"
|
||||
|
||||
|
||||
def _indexed_keys(tools: Sequence[Tool]) -> tuple[IndexedKey, ...]:
|
||||
keys: Final = tuple(_chat_tool_key(tool) for tool in tools)
|
||||
positions_by_key: Final = groupby(sorted(range(len(keys)), key=keys.__getitem__), key=keys.__getitem__)
|
||||
ordinal_by_position: Final = MappingProxyType(
|
||||
{position: ordinal for _, positions in positions_by_key for ordinal, position in enumerate(positions)}
|
||||
)
|
||||
return tuple((key, ordinal_by_position[position]) for position, key in enumerate(keys))
|
||||
|
||||
|
||||
def _namespace_members(namespace: Tool) -> tuple[Tool, ...]:
|
||||
members: Final = namespace.get("tools")
|
||||
if not isinstance(members, Sequence) or isinstance(members, (str, bytes)):
|
||||
return ()
|
||||
return tuple(member for member in map(_as_tool, members) if member is not None)
|
||||
|
||||
|
||||
def _function_fields(tool: Tool) -> Tool:
|
||||
function: Final = _as_tool(tool.get("function"))
|
||||
return function if function is not None else MappingProxyType({})
|
||||
|
||||
|
||||
def _without_namespace_prefix(key: str, value: object, prefix: str) -> object:
|
||||
if key != "description" or not isinstance(value, str) or not value.startswith(prefix):
|
||||
return value
|
||||
return value[len(prefix) :]
|
||||
|
||||
|
||||
def _rebuilt_member(member: Tool, flattened: Tool, guardrailed: Tool, namespace_description: str) -> Tool:
|
||||
flattened_function: Final = _function_fields(flattened)
|
||||
prefix: Final = f"{namespace_description}{NAMESPACE_DESCRIPTION_SEPARATOR}" if namespace_description else ""
|
||||
changed_function: Final = MappingProxyType(
|
||||
{
|
||||
key: _without_namespace_prefix(key, value, prefix)
|
||||
for key, value in _function_fields(guardrailed).items()
|
||||
if flattened_function.get(key) != value
|
||||
}
|
||||
)
|
||||
changed_extras: Final = MappingProxyType(
|
||||
{
|
||||
key: value
|
||||
for key, value in guardrailed.items()
|
||||
if key not in _CHAT_TOOL_TOP_LEVEL_KEYS and flattened.get(key) != value
|
||||
}
|
||||
)
|
||||
return {**member, **changed_extras, **changed_function} # mutable-ok: json.dumps rejects MappingProxyType
|
||||
|
||||
|
||||
def _rebuilt_function_members(
|
||||
function_members: Sequence[Tool],
|
||||
flattened_group: Sequence[Tool],
|
||||
group_keys: Sequence[IndexedKey],
|
||||
guardrailed_by_key: Mapping[IndexedKey, Tool],
|
||||
namespace_description: str,
|
||||
) -> tuple[Tool | None, ...]:
|
||||
return tuple(
|
||||
None
|
||||
if key not in guardrailed_by_key
|
||||
else member
|
||||
if guardrailed_by_key[key] == flattened
|
||||
else _rebuilt_member(member, flattened, guardrailed_by_key[key], namespace_description)
|
||||
for member, flattened, key in zip(function_members, flattened_group, group_keys)
|
||||
)
|
||||
|
||||
|
||||
def _rebuilt_namespace(
|
||||
original: Tool,
|
||||
members: Sequence[Tool],
|
||||
flattened_group: Sequence[Tool],
|
||||
group_keys: Sequence[IndexedKey],
|
||||
guardrailed_by_key: Mapping[IndexedKey, Tool],
|
||||
) -> tuple[Tool, ...]:
|
||||
namespace_description: Final = str(original.get("description") or "")
|
||||
rebuilt_functions: Final = iter(
|
||||
_rebuilt_function_members(
|
||||
tuple(member for member in members if _is_function(member)),
|
||||
flattened_group,
|
||||
group_keys,
|
||||
guardrailed_by_key,
|
||||
namespace_description,
|
||||
)
|
||||
)
|
||||
rebuilt_members: Final = tuple(
|
||||
rebuilt
|
||||
for rebuilt in (next(rebuilt_functions) if _is_function(member) else member for member in members)
|
||||
if rebuilt is not None
|
||||
)
|
||||
if not rebuilt_members:
|
||||
return ()
|
||||
return ({**original, "tools": list(rebuilt_members)},) # mutable-ok: json.dumps needs a plain dict and list
|
||||
|
||||
|
||||
def _merged_original(
|
||||
original: Tool,
|
||||
flattened_group: Sequence[Tool],
|
||||
group_keys: Sequence[IndexedKey],
|
||||
guardrailed_by_key: Mapping[IndexedKey, Tool],
|
||||
) -> tuple[Tool, ...]:
|
||||
if not group_keys:
|
||||
return (original,)
|
||||
guardrailed_group: Final = tuple(guardrailed_by_key[key] for key in group_keys if key in guardrailed_by_key)
|
||||
if guardrailed_group == tuple(flattened_group):
|
||||
return (original,)
|
||||
members: Final = _namespace_members(original) if original.get("type") == "namespace" else ()
|
||||
if members and sum(map(_is_function, members)) == len(flattened_group):
|
||||
return _rebuilt_namespace(original, members, flattened_group, group_keys, guardrailed_by_key)
|
||||
if not guardrailed_group:
|
||||
return ()
|
||||
return tuple(
|
||||
LiteLLMCompletionResponsesConfig.transform_chat_completion_tool_params_to_responses_api_tools(guardrailed_group)
|
||||
)
|
||||
|
||||
|
||||
def merge_guardrailed_tools(
|
||||
original_tools: Sequence[Tool],
|
||||
flattened_groups: Sequence[Sequence[Tool]],
|
||||
guardrailed_tools: Iterable[object],
|
||||
) -> tuple[Tool, ...]:
|
||||
guardrailed: Final = _validated_tools(guardrailed_tools)
|
||||
flattened_keys: Final = _indexed_keys(tuple(chain.from_iterable(flattened_groups)))
|
||||
guardrailed_keys: Final = _indexed_keys(guardrailed)
|
||||
guardrailed_by_key: Final = MappingProxyType(dict(zip(guardrailed_keys, guardrailed)))
|
||||
group_ends: Final = tuple(accumulate(len(group) for group in flattened_groups))
|
||||
group_key_slices: Final = tuple(
|
||||
flattened_keys[end - len(group) : end] for group, end in zip(flattened_groups, group_ends)
|
||||
)
|
||||
merged_originals: Final = chain.from_iterable(
|
||||
_merged_original(original, group, group_keys, guardrailed_by_key)
|
||||
for original, group, group_keys in zip(original_tools, flattened_groups, group_key_slices)
|
||||
)
|
||||
owned_keys: Final = frozenset(flattened_keys)
|
||||
appended: Final = LiteLLMCompletionResponsesConfig.transform_chat_completion_tool_params_to_responses_api_tools(
|
||||
tuple(tool for key, tool in zip(guardrailed_keys, guardrailed) if key not in owned_keys)
|
||||
)
|
||||
return tuple(chain(merged_originals, appended))
|
||||
|
|
@ -15,7 +15,10 @@ import httpx
|
|||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
import litellm
|
||||
from litellm.llms.base_llm.vector_store.transformation import BaseDirectVectorStoreConfig
|
||||
from litellm.llms.base_llm.vector_store.transformation import (
|
||||
BaseDirectVectorStoreConfig,
|
||||
VectorStoreEmbeddingExecutor,
|
||||
)
|
||||
from litellm.llms.valkey.common_utils import build_valkey_url, pack_vector
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
from litellm.types.vector_stores import (
|
||||
|
|
@ -213,6 +216,7 @@ class ValkeyVectorStoreConfig(BaseDirectVectorStoreConfig):
|
|||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
litellm_logging_obj: "LiteLLMLoggingObj",
|
||||
litellm_params: Mapping[str, object],
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
) -> VectorStoreSearchResponse:
|
||||
params: Final = _ValkeySearchParams.model_validate(litellm_params)
|
||||
|
|
@ -222,10 +226,18 @@ class ValkeyVectorStoreConfig(BaseDirectVectorStoreConfig):
|
|||
embedding_field=params.embedding_field,
|
||||
text_field=params.text_field,
|
||||
)
|
||||
embedding_response: Final = self.embedding_fn(
|
||||
model=params.require_embedding_model(),
|
||||
input=[query_text], # mutable-ok: litellm.embedding's input contract is a list
|
||||
**(params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG),
|
||||
embedding_response: Final = (
|
||||
embedding_executor.embed(
|
||||
params.require_embedding_model(),
|
||||
query_text,
|
||||
params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG,
|
||||
)
|
||||
if embedding_executor is not None
|
||||
else self.embedding_fn(
|
||||
model=params.require_embedding_model(),
|
||||
input=[query_text], # mutable-ok: the injected embedding callable requires list input
|
||||
**(params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG),
|
||||
)
|
||||
)
|
||||
vec_params: Final = {"vec": pack_vector(embedding_response.data[0]["embedding"])} # mutable-ok: redis-py API
|
||||
|
||||
|
|
@ -252,6 +264,7 @@ class ValkeyVectorStoreConfig(BaseDirectVectorStoreConfig):
|
|||
vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams,
|
||||
litellm_logging_obj: "LiteLLMLoggingObj",
|
||||
litellm_params: Mapping[str, object],
|
||||
embedding_executor: VectorStoreEmbeddingExecutor | None = None,
|
||||
timeout: float | httpx.Timeout | None = None,
|
||||
) -> VectorStoreSearchResponse:
|
||||
params: Final = _ValkeySearchParams.model_validate(litellm_params)
|
||||
|
|
@ -261,10 +274,18 @@ class ValkeyVectorStoreConfig(BaseDirectVectorStoreConfig):
|
|||
embedding_field=params.embedding_field,
|
||||
text_field=params.text_field,
|
||||
)
|
||||
embedding_response: Final = await self.aembedding_fn(
|
||||
model=params.require_embedding_model(),
|
||||
input=[query_text], # mutable-ok: litellm.embedding's input contract is a list
|
||||
**(params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG),
|
||||
embedding_response: Final = (
|
||||
await embedding_executor.aembed(
|
||||
params.require_embedding_model(),
|
||||
query_text,
|
||||
params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG,
|
||||
)
|
||||
if embedding_executor is not None
|
||||
else await self.aembedding_fn(
|
||||
model=params.require_embedding_model(),
|
||||
input=[query_text], # mutable-ok: the injected embedding callable requires list input
|
||||
**(params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG),
|
||||
)
|
||||
)
|
||||
vec_params: Final = {"vec": pack_vector(embedding_response.data[0]["embedding"])} # mutable-ok: redis-py API
|
||||
|
||||
|
|
|
|||
|
|
@ -729,7 +729,7 @@ async def rag_query(
|
|||
# conflict so callers cannot override the store's provider or credentials.
|
||||
managed_store: Final = resolved_stores.get(retrieval_config["vector_store_id"])
|
||||
store_data: Final = (
|
||||
await build_request_data_from_managed_vector_store(managed_store)
|
||||
build_request_data_from_managed_vector_store(managed_store)
|
||||
if managed_store is not None
|
||||
else MappingProxyType({})
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ from litellm.proxy._types import CommonProxyErrors, UserAPIKeyAuth
|
|||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessing
|
||||
from litellm.proxy.utils import jsonify_object
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import (
|
||||
_resolve_embedding_config,
|
||||
)
|
||||
from litellm.proxy.vector_store_endpoints.utils import (
|
||||
assert_proxy_admin_for_vector_store_index_management,
|
||||
assert_user_can_access_vector_store,
|
||||
|
|
@ -57,19 +54,9 @@ def reject_caller_embedding_selection_params(payload: Mapping[str, object], sour
|
|||
########################################################
|
||||
|
||||
|
||||
async def build_request_data_from_managed_vector_store(
|
||||
def build_request_data_from_managed_vector_store(
|
||||
vector_store: LiteLLM_ManagedVectorStore,
|
||||
) -> Mapping[str, object]:
|
||||
"""
|
||||
Build request params (provider, credential ref, litellm_params) from an
|
||||
already-resolved managed vector store.
|
||||
|
||||
``litellm_embedding_config`` is resolved here, at request-handling time,
|
||||
instead of at row-creation time: the resolved api_key/api_base/api_version
|
||||
lives only in the returned per-request mapping and is never persisted back
|
||||
to the registry cache. Legacy rows that already carry a resolved
|
||||
(cleartext) config skip the lookup and pass through unchanged.
|
||||
"""
|
||||
top_level: Final = MappingProxyType(
|
||||
{
|
||||
key: vector_store.get(key)
|
||||
|
|
@ -78,18 +65,7 @@ async def build_request_data_from_managed_vector_store(
|
|||
}
|
||||
)
|
||||
litellm_params: Final = vector_store.get("litellm_params") or MappingProxyType({})
|
||||
embedding_model: Final = litellm_params.get("litellm_embedding_model")
|
||||
if not embedding_model or litellm_params.get("litellm_embedding_config"):
|
||||
return MappingProxyType({**top_level, **litellm_params})
|
||||
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
resolved_config: Final = await _resolve_embedding_config(
|
||||
embedding_model=embedding_model, prisma_client=prisma_client
|
||||
)
|
||||
if not resolved_config:
|
||||
return MappingProxyType({**top_level, **litellm_params})
|
||||
return MappingProxyType({**top_level, **litellm_params, "litellm_embedding_config": resolved_config})
|
||||
return MappingProxyType({**top_level, **litellm_params})
|
||||
|
||||
|
||||
async def _update_request_data_with_litellm_managed_vector_store_registry(
|
||||
|
|
@ -118,7 +94,7 @@ async def _update_request_data_with_litellm_managed_vector_store_registry(
|
|||
vector_store=vector_store_to_run,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
return {**data, **(await build_request_data_from_managed_vector_store(vector_store_to_run))}
|
||||
return {**data, **build_request_data_from_managed_vector_store(vector_store_to_run)}
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
|
|||
|
|
@ -18,11 +18,8 @@ if TYPE_CHECKING:
|
|||
from prisma.models import LiteLLM_ManagedVectorStoresTable as _VectorStoreRow
|
||||
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
from litellm.router import Router
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching.in_memory_cache import InMemoryCache
|
||||
from litellm.constants import REDACTED_BY_LITELM_STRING
|
||||
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
|
||||
|
|
@ -32,13 +29,10 @@ from litellm.proxy._types import (
|
|||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
||||
from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user
|
||||
from litellm.proxy.vector_store_endpoints.utils import can_user_access_vector_store
|
||||
from litellm.repositories.model_repository import ModelRepository
|
||||
from litellm.repositories.prisma_protocols import TableActions
|
||||
from litellm.repositories.table_repositories import ManagedVectorStoresRepository
|
||||
from litellm.secret_managers.main import get_secret
|
||||
from litellm.types.vector_stores import (
|
||||
LiteLLM_ManagedVectorStore,
|
||||
LiteLLM_ManagedVectorStoreListResponse,
|
||||
|
|
@ -64,28 +58,6 @@ _LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker()
|
|||
|
||||
_REDACT_LITELLM_PARAMS_MAX_DEPTH: Final = 10
|
||||
|
||||
# Use-time embedding-config resolution runs on every vector-store request
|
||||
# whose persisted row carries only a model reference (the post-fix shape).
|
||||
# Without a cache, that's one ``litellm_proxymodeltable.find_first`` per
|
||||
# request — the no-DB-in-critical-path rule. Hold the resolved config in
|
||||
# memory for a short TTL so a hot model name pays the DB lookup at most
|
||||
# once per ``_EMBEDDING_CONFIG_CACHE_TTL`` seconds. Cleartext credentials
|
||||
# only ever live in process memory (never persisted, never echoed in
|
||||
# management responses), so the cache doesn't widen the disclosure surface.
|
||||
_EMBEDDING_CONFIG_CACHE_TTL: Final = 60
|
||||
_EMBEDDING_CONFIG_CACHE_MAX_SIZE: Final = 256
|
||||
_embedding_config_cache: InMemoryCache | None = None
|
||||
|
||||
|
||||
def _get_embedding_config_cache() -> InMemoryCache:
|
||||
global _embedding_config_cache
|
||||
if _embedding_config_cache is None:
|
||||
_embedding_config_cache = InMemoryCache(
|
||||
max_size_in_memory=_EMBEDDING_CONFIG_CACHE_MAX_SIZE,
|
||||
default_ttl=_EMBEDDING_CONFIG_CACHE_TTL,
|
||||
)
|
||||
return _embedding_config_cache
|
||||
|
||||
|
||||
def _redact_sensitive_litellm_params(litellm_params: Any, _depth: int = 0) -> Any:
|
||||
"""
|
||||
|
|
@ -155,235 +127,6 @@ async def _fetch_and_authorize_vector_store(
|
|||
return typed
|
||||
|
||||
|
||||
def _resolve_embedding_config_from_router(embedding_model: str, llm_router) -> dict[str, object] | None:
|
||||
"""
|
||||
Resolve embedding config from router's config-defined models.
|
||||
|
||||
Config-defined models (from proxy_config.yaml) are stored in the router's model_list,
|
||||
not in the database. This function looks up the model in the router and extracts
|
||||
api_key, api_base, and api_version from the deployment's litellm_params.
|
||||
|
||||
Args:
|
||||
embedding_model: The embedding model string (e.g., "text-embedding-ada-002" or "azure/text-embedding-3-large")
|
||||
llm_router: The LiteLLM router instance
|
||||
|
||||
Returns:
|
||||
Dictionary with api_key, api_base, and api_version if model found, None otherwise
|
||||
"""
|
||||
if not embedding_model or llm_router is None:
|
||||
return None
|
||||
|
||||
# Extract model name candidates - could be "text-embedding-ada-002" or "azure/text-embedding-3-large"
|
||||
# Try exact match first, then try without provider prefix
|
||||
model_name_candidates: Final = [embedding_model]
|
||||
if "/" in embedding_model:
|
||||
# If it has a provider prefix, also try without it
|
||||
_, model_name = embedding_model.split("/", 1)
|
||||
model_name_candidates.append(model_name)
|
||||
|
||||
# Try to find model in router
|
||||
for model_name in model_name_candidates:
|
||||
try:
|
||||
# Try to get deployment by model group name (model_name in config)
|
||||
deployment = llm_router.get_deployment_by_model_group_name(model_group_name=model_name)
|
||||
|
||||
if deployment is not None and deployment.litellm_params is not None:
|
||||
litellm_params = deployment.litellm_params
|
||||
|
||||
# Build embedding config from model params
|
||||
embedding_config: dict[str, object] = {}
|
||||
|
||||
# Extract api_key
|
||||
api_key = getattr(litellm_params, "api_key", None)
|
||||
if api_key:
|
||||
# Handle os.environ/ prefix
|
||||
if isinstance(api_key, str) and api_key.startswith("os.environ/"):
|
||||
api_key = get_secret(api_key)
|
||||
embedding_config["api_key"] = api_key
|
||||
|
||||
# Extract api_base
|
||||
api_base = getattr(litellm_params, "api_base", None)
|
||||
if api_base:
|
||||
# Handle os.environ/ prefix
|
||||
if isinstance(api_base, str) and api_base.startswith("os.environ/"):
|
||||
api_base = get_secret(api_base)
|
||||
embedding_config["api_base"] = api_base
|
||||
|
||||
# Extract api_version
|
||||
api_version = getattr(litellm_params, "api_version", None)
|
||||
if api_version:
|
||||
embedding_config["api_version"] = api_version
|
||||
|
||||
project_id = getattr(litellm_params, "project_id", None)
|
||||
if project_id:
|
||||
embedding_config["project_id"] = project_id
|
||||
|
||||
# Only return config if we have at least api_key or api_base
|
||||
if embedding_config:
|
||||
verbose_proxy_logger.debug(
|
||||
"Resolved embedding config from router model %s: %s", model_name, list(embedding_config.keys())
|
||||
)
|
||||
return embedding_config
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug("Error resolving embedding config from router for model %s: %s", model_name, e)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_embedding_config_from_db(
|
||||
embedding_model: str, prisma_client: "PrismaClient"
|
||||
) -> dict[str, object] | None:
|
||||
"""
|
||||
Resolve embedding config from database model configuration.
|
||||
|
||||
If litellm_embedding_model is provided but litellm_embedding_config is not,
|
||||
this function looks up the model in the database and extracts api_key, api_base,
|
||||
and api_version from the model's litellm_params to build the embedding config.
|
||||
|
||||
Args:
|
||||
embedding_model: The embedding model string (e.g., "text-embedding-ada-002" or "azure/text-embedding-3-large")
|
||||
prisma_client: The Prisma client instance
|
||||
|
||||
Returns:
|
||||
Dictionary with api_key, api_base, and api_version if model found, None otherwise
|
||||
"""
|
||||
if not embedding_model:
|
||||
return None
|
||||
|
||||
# Extract model name - could be "text-embedding-ada-002" or "azure/text-embedding-3-large"
|
||||
# Try to find model by exact match first, then try without provider prefix
|
||||
model_name_candidates: Final = [embedding_model]
|
||||
if "/" in embedding_model:
|
||||
# If it has a provider prefix, also try without it
|
||||
_, model_name = embedding_model.split("/", 1)
|
||||
model_name_candidates.append(model_name)
|
||||
|
||||
# Try to find model in database
|
||||
for model_name in model_name_candidates:
|
||||
try:
|
||||
db_model = await ModelRepository(prisma_client).table.find_first(where={"model_name": model_name})
|
||||
|
||||
if db_model and db_model.litellm_params:
|
||||
# Extract litellm_params (could be dict or JSON string)
|
||||
model_params = db_model.litellm_params
|
||||
if isinstance(model_params, str): # pyright: ignore[reportUnnecessaryIsInstance] # prisma Json is str
|
||||
model_params = json.loads(model_params)
|
||||
|
||||
# Decrypt values from database (similar to how proxy_server.py does it)
|
||||
# Values stored in DB are encrypted, so we need to decrypt them first
|
||||
decrypted_params = {}
|
||||
if isinstance(model_params, dict):
|
||||
for k, v in model_params.items():
|
||||
if isinstance(v, str):
|
||||
# Decrypt value - returns original value if decryption fails or no key is set
|
||||
decrypted_value = decrypt_value_helper(value=v, key=k, return_original_value=True)
|
||||
decrypted_params[k] = decrypted_value
|
||||
else:
|
||||
decrypted_params[k] = v
|
||||
else:
|
||||
decrypted_params = model_params
|
||||
|
||||
# Build embedding config from model params
|
||||
embedding_config = {}
|
||||
|
||||
# Extract api_key
|
||||
api_key = decrypted_params.get("api_key")
|
||||
if api_key:
|
||||
# Handle os.environ/ prefix (after decryption, values may be os.environ/ prefixed)
|
||||
if isinstance(api_key, str) and api_key.startswith("os.environ/"):
|
||||
api_key = get_secret(api_key)
|
||||
embedding_config["api_key"] = api_key
|
||||
|
||||
# Extract api_base
|
||||
api_base = decrypted_params.get("api_base")
|
||||
if api_base:
|
||||
# Handle os.environ/ prefix (after decryption, values may be os.environ/ prefixed)
|
||||
if isinstance(api_base, str) and api_base.startswith("os.environ/"):
|
||||
api_base = get_secret(api_base)
|
||||
embedding_config["api_base"] = api_base
|
||||
|
||||
# Extract api_version
|
||||
api_version = decrypted_params.get("api_version")
|
||||
if api_version:
|
||||
embedding_config["api_version"] = api_version
|
||||
|
||||
# Only return config if we have at least api_key or api_base
|
||||
if embedding_config:
|
||||
verbose_proxy_logger.debug(
|
||||
"Resolved embedding config from database model %s: %s",
|
||||
model_name,
|
||||
list(embedding_config.keys()),
|
||||
)
|
||||
return embedding_config
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.debug("Error resolving embedding config for model %s: %s", model_name, e)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _resolve_embedding_config(
|
||||
embedding_model: str, prisma_client: "PrismaClient | None", llm_router: "Router | None" = None
|
||||
) -> dict[str, object] | None:
|
||||
"""
|
||||
Resolve embedding config from either router (config-defined) or database models.
|
||||
|
||||
This function first checks the router for config-defined models, then falls back
|
||||
to the database. This allows users to use models defined in either location.
|
||||
|
||||
Results are cached in process memory for ``_EMBEDDING_CONFIG_CACHE_TTL``
|
||||
seconds so the request-handling path doesn't hit the database on every
|
||||
vector-store call. Negative results (model not found) are intentionally
|
||||
not cached to avoid blocking a freshly-added model behind the TTL.
|
||||
|
||||
Args:
|
||||
embedding_model: The embedding model string (e.g., "text-embedding-ada-002" or "azure/text-embedding-3-large")
|
||||
prisma_client: The Prisma client instance
|
||||
llm_router: The LiteLLM router instance (optional, will be imported if not provided)
|
||||
|
||||
Returns:
|
||||
Dictionary with api_key, api_base, and api_version if model found, None otherwise
|
||||
"""
|
||||
if not embedding_model:
|
||||
return None
|
||||
|
||||
cache: Final = _get_embedding_config_cache()
|
||||
cached: Final = cache.get_cache(embedding_model)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Import llm_router if not provided
|
||||
if llm_router is None:
|
||||
try:
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
except ImportError:
|
||||
llm_router = None
|
||||
|
||||
# First try to resolve from router (config-defined models)
|
||||
if llm_router is not None:
|
||||
router_config = _resolve_embedding_config_from_router(embedding_model=embedding_model, llm_router=llm_router)
|
||||
if router_config:
|
||||
verbose_proxy_logger.debug("Resolved embedding config from router for model %s", embedding_model)
|
||||
cache.set_cache(embedding_model, router_config)
|
||||
return router_config
|
||||
|
||||
# Fall back to database
|
||||
if prisma_client is not None:
|
||||
db_config: Final = await _resolve_embedding_config_from_db(
|
||||
embedding_model=embedding_model, prisma_client=prisma_client
|
||||
)
|
||||
if db_config:
|
||||
verbose_proxy_logger.debug("Resolved embedding config from database for model %s", embedding_model)
|
||||
cache.set_cache(embedding_model, db_config)
|
||||
return db_config
|
||||
|
||||
verbose_proxy_logger.debug(
|
||||
"Could not resolve embedding config for model %s from router or database", embedding_model
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
########################################################
|
||||
# Helper Functions
|
||||
########################################################
|
||||
|
|
@ -469,10 +212,9 @@ async def create_vector_store_in_db(
|
|||
# (``api_key``, ``api_base``, ``api_version``) into this row. That
|
||||
# exposed every env-stored embedding-model credential on the
|
||||
# ``/vector_store/{new,info,update,list}`` responses. Keep the user's
|
||||
# raw ``litellm_embedding_model`` reference; resolution now happens in
|
||||
# ``build_request_data_from_managed_vector_store``
|
||||
# at request-handling time so the cleartext config exists only in
|
||||
# per-request memory and never reaches the database.
|
||||
# raw ``litellm_embedding_model`` reference; each search embeds the
|
||||
# query through the router at request time, so the credentials stay
|
||||
# on the deployment and never reach the database.
|
||||
if litellm_params:
|
||||
litellm_params_dict: Final = GenericLiteLLMParams(**litellm_params).model_dump(exclude_none=True)
|
||||
data_to_create["litellm_params"] = safe_dumps(litellm_params_dict)
|
||||
|
|
@ -862,11 +604,9 @@ async def update_vector_store(
|
|||
|
||||
# Handle litellm_params if provided. As with the create path, the
|
||||
# embedding-config auto-resolve previously persisted cleartext
|
||||
# credentials into the row; resolution now happens at request-
|
||||
# handling time in
|
||||
# ``build_request_data_from_managed_vector_store``
|
||||
# so this row only ever stores the user-supplied
|
||||
# ``litellm_embedding_model`` reference.
|
||||
# credentials into the row; each search now embeds the query
|
||||
# through the router at request time, so this row only ever stores
|
||||
# the user-supplied ``litellm_embedding_model`` reference.
|
||||
if "litellm_params" in update_data:
|
||||
_input_litellm_params: Final[dict] = update_data.get("litellm_params", {}) or {}
|
||||
litellm_params_dict: Final = GenericLiteLLMParams(**_input_litellm_params).model_dump(exclude_none=True)
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import json
|
|||
import re
|
||||
import uuid
|
||||
from collections.abc import Iterable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
|
|
@ -102,6 +103,15 @@ from .custom_tools import (
|
|||
NamespaceNameMap: TypeAlias = Mapping[str, tuple[str, str]]
|
||||
NamespaceTool: TypeAlias = Mapping[str, object]
|
||||
ResponseTools: TypeAlias = Sequence[Mapping[str, object]] | None
|
||||
ChatToolParam: TypeAlias = ChatCompletionToolParam | OpenAIMcpServerTool
|
||||
NAMESPACE_DESCRIPTION_SEPARATOR: Final = "\n\n"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResponsesToolChatForm:
|
||||
chat_tools: tuple[ChatToolParam, ...]
|
||||
web_search_options: OpenAIWebSearchOptions | None
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.types.responses.response_apply_patch_tool_call import (
|
||||
|
|
@ -1771,7 +1781,7 @@ class LiteLLMCompletionResponsesConfig:
|
|||
tool_name: Final = str(namespace_tool.get("name") or "")
|
||||
raw_description: Final = str(namespace_tool.get("description") or "")
|
||||
description: Final = (
|
||||
f"{namespace_description}\n\n{raw_description}"
|
||||
f"{namespace_description}{NAMESPACE_DESCRIPTION_SEPARATOR}{raw_description}"
|
||||
if nested and namespace_description and raw_description
|
||||
else namespace_description
|
||||
if nested and namespace_description
|
||||
|
|
@ -1837,9 +1847,78 @@ class LiteLLMCompletionResponsesConfig:
|
|||
+ ", ".join(sorted(conflicting_tool_names))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _responses_tool_to_chat_form(tool: Mapping[str, object]) -> ResponsesToolChatForm:
|
||||
tool_type: Final = tool.get("type")
|
||||
if tool_type == "mcp":
|
||||
return ResponsesToolChatForm(chat_tools=(cast(OpenAIMcpServerTool, tool),), web_search_options=None)
|
||||
if tool_type == "web_search_preview" or tool_type == "web_search":
|
||||
_search_context_size: Final[Literal["low", "medium", "high"]] = cast(
|
||||
Literal["low", "medium", "high"], tool.get("search_context_size")
|
||||
)
|
||||
_user_location: Final[OpenAIWebSearchUserLocation | None] = cast(
|
||||
OpenAIWebSearchUserLocation | None,
|
||||
tool.get("user_location") or None,
|
||||
)
|
||||
return ResponsesToolChatForm(
|
||||
chat_tools=(),
|
||||
web_search_options=OpenAIWebSearchOptions(
|
||||
search_context_size=_search_context_size,
|
||||
user_location=_user_location,
|
||||
),
|
||||
)
|
||||
if tool_type == "function":
|
||||
typed_tool: Final = cast(FunctionToolParam, tool)
|
||||
raw_parameters: Final = typed_tool.get("parameters", {}) or {}
|
||||
parameters: Final = (
|
||||
{**raw_parameters} # mutable-ok: json.dumps rejects MappingProxyType
|
||||
if "type" in raw_parameters
|
||||
else {**raw_parameters, "type": "object"} # mutable-ok: json.dumps rejects MappingProxyType
|
||||
)
|
||||
chat_completion_tool: Final[dict[str, object]] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": typed_tool.get("name") or "",
|
||||
"description": typed_tool.get("description") or "",
|
||||
"parameters": parameters,
|
||||
"strict": typed_tool.get("strict", False) or False,
|
||||
},
|
||||
}
|
||||
if tool.get("cache_control"):
|
||||
chat_completion_tool["cache_control"] = tool.get("cache_control")
|
||||
if tool.get("defer_loading"):
|
||||
chat_completion_tool["defer_loading"] = tool.get("defer_loading")
|
||||
if tool.get("allowed_callers"):
|
||||
chat_completion_tool["allowed_callers"] = tool.get("allowed_callers")
|
||||
if tool.get("input_examples"):
|
||||
chat_completion_tool["input_examples"] = tool.get("input_examples")
|
||||
return ResponsesToolChatForm(
|
||||
chat_tools=(cast(ChatCompletionToolParam, chat_completion_tool),), web_search_options=None
|
||||
)
|
||||
if tool_type == "namespace":
|
||||
return ResponsesToolChatForm(
|
||||
chat_tools=LiteLLMCompletionResponsesConfig._namespace_chat_tools(tool), web_search_options=None
|
||||
)
|
||||
if tool_type == "custom":
|
||||
converted: Final = convert_custom_tool_to_function_tool(tool)
|
||||
return ResponsesToolChatForm(chat_tools=() if converted is None else (converted,), web_search_options=None)
|
||||
if tool_type in ("computer_use", "image_generation", "shell"):
|
||||
verbose_logger.warning(
|
||||
"Dropping Responses API tool of type '%s': it has no Chat Completions "
|
||||
"equivalent and the target provider would reject the request.",
|
||||
tool_type,
|
||||
)
|
||||
return ResponsesToolChatForm(chat_tools=(), web_search_options=None)
|
||||
return ResponsesToolChatForm(chat_tools=(cast(ChatToolParam, tool),), web_search_options=None)
|
||||
|
||||
@staticmethod
|
||||
def responses_tools_to_chat_forms(tools: ResponseTools) -> tuple[ResponsesToolChatForm, ...]:
|
||||
LiteLLMCompletionResponsesConfig._validate_namespace_name_collisions(tools)
|
||||
return tuple(LiteLLMCompletionResponsesConfig._responses_tool_to_chat_form(tool) for tool in tools or ())
|
||||
|
||||
@staticmethod
|
||||
def transform_responses_api_tools_to_chat_completion_tools(
|
||||
tools: list[FunctionToolParam | OpenAIMcpServerTool] | None,
|
||||
tools: ResponseTools,
|
||||
) -> tuple[
|
||||
list[ChatCompletionToolParam | OpenAIMcpServerTool],
|
||||
OpenAIWebSearchOptions | None,
|
||||
|
|
@ -1849,73 +1928,16 @@ class LiteLLMCompletionResponsesConfig:
|
|||
"""
|
||||
if tools is None:
|
||||
return [], None
|
||||
LiteLLMCompletionResponsesConfig._validate_namespace_name_collisions(tools)
|
||||
chat_completion_tools: Final[list[ChatCompletionToolParam | OpenAIMcpServerTool]] = []
|
||||
web_search_options: OpenAIWebSearchOptions | None = None
|
||||
for tool in tools:
|
||||
if tool.get("type") == "mcp":
|
||||
chat_completion_tools.append(cast(OpenAIMcpServerTool, tool))
|
||||
elif tool.get("type") == "web_search_preview" or tool.get("type") == "web_search":
|
||||
_search_context_size: Literal["low", "medium", "high"] = cast(
|
||||
Literal["low", "medium", "high"], tool.get("search_context_size")
|
||||
)
|
||||
_user_location: OpenAIWebSearchUserLocation | None = cast(
|
||||
OpenAIWebSearchUserLocation | None,
|
||||
tool.get("user_location") or None,
|
||||
)
|
||||
web_search_options = OpenAIWebSearchOptions(
|
||||
search_context_size=_search_context_size,
|
||||
user_location=_user_location,
|
||||
)
|
||||
elif tool.get("type") == "function":
|
||||
typed_tool = cast(FunctionToolParam, tool)
|
||||
# Ensure parameters has "type": "object" as required by providers like Anthropic
|
||||
parameters = dict(typed_tool.get("parameters", {}) or {})
|
||||
if not parameters or "type" not in parameters:
|
||||
parameters["type"] = "object"
|
||||
chat_completion_tool: dict[str, object] = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": typed_tool.get("name") or "",
|
||||
"description": typed_tool.get("description") or "",
|
||||
"parameters": parameters,
|
||||
"strict": typed_tool.get("strict", False) or False,
|
||||
},
|
||||
}
|
||||
if tool.get("cache_control"):
|
||||
chat_completion_tool["cache_control"] = tool.get("cache_control")
|
||||
if tool.get("defer_loading"):
|
||||
chat_completion_tool["defer_loading"] = tool.get("defer_loading")
|
||||
if tool.get("allowed_callers"):
|
||||
chat_completion_tool["allowed_callers"] = tool.get("allowed_callers")
|
||||
if tool.get("input_examples"):
|
||||
chat_completion_tool["input_examples"] = tool.get("input_examples")
|
||||
chat_completion_tools.append(cast(ChatCompletionToolParam, chat_completion_tool))
|
||||
elif tool.get("type") == "namespace":
|
||||
chat_completion_tools.extend(LiteLLMCompletionResponsesConfig._namespace_chat_tools(tool))
|
||||
elif tool.get("type") == "custom":
|
||||
converted = convert_custom_tool_to_function_tool(tool)
|
||||
if converted is not None:
|
||||
chat_completion_tools.append(converted)
|
||||
else:
|
||||
_tool_type = tool.get("type")
|
||||
if _tool_type in ("computer_use", "image_generation", "shell"):
|
||||
# Drop unsupported Responses-API-only tool types that have no
|
||||
# Chat Completions equivalent. Passing them through verbatim
|
||||
# causes providers to reject the request with "'function' is a
|
||||
# required property".
|
||||
verbose_logger.warning(
|
||||
"Dropping Responses API tool of type '%s': it has no Chat Completions "
|
||||
"equivalent and the target provider would reject the request.",
|
||||
_tool_type,
|
||||
)
|
||||
continue
|
||||
chat_completion_tools.append(cast(ChatCompletionToolParam | OpenAIMcpServerTool, tool))
|
||||
return chat_completion_tools, web_search_options
|
||||
forms: Final = LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(tools)
|
||||
web_search_options: Final = next(
|
||||
(form.web_search_options for form in reversed(forms) if form.web_search_options is not None),
|
||||
None,
|
||||
)
|
||||
return [chat_tool for form in forms for chat_tool in form.chat_tools], web_search_options
|
||||
|
||||
@staticmethod
|
||||
def transform_chat_completion_tool_params_to_responses_api_tools(
|
||||
chat_completion_tools: list[ChatCompletionToolParam | OpenAIMcpServerTool] | None,
|
||||
chat_completion_tools: Sequence[Mapping[str, object]] | None,
|
||||
) -> list[dict[str, object]]:
|
||||
"""
|
||||
Transform Chat Completion tool params (e.g. from guardrail output) back to
|
||||
|
|
@ -1926,9 +1948,6 @@ class LiteLLMCompletionResponsesConfig:
|
|||
return []
|
||||
result: Final[list[dict[str, object]]] = []
|
||||
for tool in chat_completion_tools:
|
||||
if not isinstance(tool, dict):
|
||||
result.append(tool)
|
||||
continue
|
||||
if tool.get("type") == "function":
|
||||
fn = cast(_ToolFunctionDefinition, tool.get("function") or {})
|
||||
parameters = dict(fn.get("parameters", {}) or {})
|
||||
|
|
|
|||
|
|
@ -85,6 +85,10 @@ from litellm.litellm_core_utils.sensitive_data_masker import (
|
|||
mask_credentials_in_payload,
|
||||
mask_sensitive_structure,
|
||||
)
|
||||
from litellm.llms.base_llm.vector_store.transformation import (
|
||||
RouterVectorStoreEmbeddingExecutor,
|
||||
vector_store_request_metadata,
|
||||
)
|
||||
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
|
||||
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
|
||||
from litellm.router_strategy.least_busy import LeastBusyLoggingHandler
|
||||
|
|
@ -354,6 +358,8 @@ _PreRoutingStrategyT = TypeVar("_PreRoutingStrategyT")
|
|||
|
||||
_ALIAS_PARAMS_NEVER_FORWARDED: Final = frozenset({"model", "api_base", "api_key", "api_version"})
|
||||
_ALIAS_MARKER_FORWARDED_PARAMS_KWARG: Final = "_alias_marker_forwarded_params"
|
||||
_CLAUDE_CODE_SESSION_ID_RE: Final = re.compile(r"^[a-zA-Z0-9_\-]{8,}$")
|
||||
_CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS: Final = 3600
|
||||
|
||||
_RUNTIME_TOGGLEABLE_PRE_CALL_CHECKS: Final[Mapping[str, type[CustomLogger]]] = MappingProxyType(
|
||||
{
|
||||
|
|
@ -810,6 +816,10 @@ class Router:
|
|||
self.cache = DualCache(
|
||||
redis_cache=redis_cache, in_memory_cache=InMemoryCache()
|
||||
) # use a dual cache (Redis+In-Memory) for tracking cooldowns, usage, etc.
|
||||
self._claude_code_session_router_cache: DualCache = DualCache(
|
||||
redis_cache=redis_cache,
|
||||
in_memory_cache=InMemoryCache(),
|
||||
)
|
||||
|
||||
### SCHEDULER ###
|
||||
self.scheduler = Scheduler(polling_interval=polling_interval, redis_cache=redis_cache)
|
||||
|
|
@ -1131,8 +1141,8 @@ class Router:
|
|||
```
|
||||
and caching to just work.
|
||||
"""
|
||||
if self.cache.redis_cache is None:
|
||||
self.cache.redis_cache = cache
|
||||
self.cache.attach_redis_cache(cache)
|
||||
self._claude_code_session_router_cache.attach_redis_cache(cache)
|
||||
|
||||
# Maps a routing strategy string to the attribute on `self` that holds
|
||||
# the default group's strategy selector for that strategy. (The selectors
|
||||
|
|
@ -6475,11 +6485,24 @@ class Router:
|
|||
if custom_llm_provider and "custom_llm_provider" not in kwargs
|
||||
else MappingProxyType(kwargs)
|
||||
)
|
||||
if provider_kwargs.get("model"):
|
||||
return self._generic_api_call_with_fallbacks(original_function=original_function, **provider_kwargs)
|
||||
search_kwargs: Final = (
|
||||
MappingProxyType(
|
||||
{
|
||||
**provider_kwargs,
|
||||
"_direct_vector_store_embedding_executor": RouterVectorStoreEmbeddingExecutor(
|
||||
router=self,
|
||||
metadata=self._vector_store_request_metadata(kwargs),
|
||||
),
|
||||
}
|
||||
)
|
||||
if call_type == "vector_store_search"
|
||||
else provider_kwargs
|
||||
)
|
||||
if search_kwargs.get("model"):
|
||||
return self._generic_api_call_with_fallbacks(original_function=original_function, **search_kwargs)
|
||||
if call_type == "vector_store_search":
|
||||
return original_function(**MappingProxyType({**provider_kwargs, "router": self}))
|
||||
return original_function(**provider_kwargs)
|
||||
return original_function(**MappingProxyType({**search_kwargs, "router": self}))
|
||||
return original_function(**search_kwargs)
|
||||
|
||||
return vector_store_sync_wrapper
|
||||
|
||||
|
|
@ -6652,11 +6675,22 @@ class Router:
|
|||
"avector_store_update",
|
||||
"avector_store_delete",
|
||||
):
|
||||
vector_store_kwargs: Final = (
|
||||
{ # mutable-ok: the async routed request requires dynamic keyword arguments
|
||||
**kwargs,
|
||||
"_direct_vector_store_embedding_executor": RouterVectorStoreEmbeddingExecutor(
|
||||
router=self,
|
||||
metadata=self._vector_store_request_metadata(kwargs),
|
||||
),
|
||||
}
|
||||
if call_type == "avector_store_search"
|
||||
else kwargs
|
||||
)
|
||||
return await self._init_vector_store_api_endpoints(
|
||||
original_function=original_function,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
call_type=call_type,
|
||||
**kwargs,
|
||||
**vector_store_kwargs,
|
||||
)
|
||||
elif call_type in ("afile_delete", "afile_content"):
|
||||
return await self._ageneric_api_call_with_fallbacks(
|
||||
|
|
@ -6692,6 +6726,10 @@ class Router:
|
|||
|
||||
return async_wrapper
|
||||
|
||||
@staticmethod
|
||||
def _vector_store_request_metadata(kwargs: Mapping[str, object]) -> Mapping[str, object]:
|
||||
return vector_store_request_metadata(kwargs)
|
||||
|
||||
async def _init_vector_store_api_endpoints(
|
||||
self,
|
||||
original_function: Callable,
|
||||
|
|
@ -12651,6 +12689,100 @@ class Router:
|
|||
return None
|
||||
return candidates[0]
|
||||
|
||||
@staticmethod
|
||||
def _request_header(request_kwargs: Mapping[str, object], header_name: str) -> str | None:
|
||||
proxy_server_request: Final = request_kwargs.get("proxy_server_request")
|
||||
if not isinstance(proxy_server_request, Mapping):
|
||||
return None
|
||||
headers: Final = proxy_server_request.get("headers")
|
||||
if not isinstance(headers, Mapping):
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
value
|
||||
for key, value in headers.items()
|
||||
if isinstance(key, str) and key.lower() == header_name and isinstance(value, str)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
def _claude_code_session_router_cache_key(self, request_kwargs: Mapping[str, object]) -> str | None:
|
||||
session_id: Final = self._request_header(request_kwargs, "x-claude-code-session-id")
|
||||
if session_id is None or _CLAUDE_CODE_SESSION_ID_RE.fullmatch(session_id) is None:
|
||||
return None
|
||||
metadata_name: Final = "litellm_metadata" if "litellm_metadata" in request_kwargs else "metadata"
|
||||
metadata: Final = request_kwargs.get(metadata_name)
|
||||
if not isinstance(metadata, Mapping):
|
||||
return None
|
||||
caller_scope: Final = metadata.get("user_api_key_hash")
|
||||
if not isinstance(caller_scope, str) or not caller_scope:
|
||||
return None
|
||||
return f"claude_code_session_router:v1:{caller_scope}:{session_id}"
|
||||
|
||||
async def _delete_claude_code_session_router_binding(self, cache_key: str) -> None:
|
||||
try:
|
||||
await self._claude_code_session_router_cache.async_delete_cache(key=cache_key)
|
||||
except Exception as e: # noqa: BLE001 # cache cleanup must not fail an otherwise routable request
|
||||
verbose_router_logger.warning(
|
||||
"Failed to delete Claude Code session router binding; the binding may remain until its TTL expires: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
async def _get_claude_code_session_router_binding(self, cache_key: str) -> object:
|
||||
session_cache: Final = self._claude_code_session_router_cache
|
||||
try:
|
||||
if session_cache.redis_cache is None:
|
||||
return await session_cache.async_get_cache(key=cache_key)
|
||||
return await session_cache.redis_cache.async_get_cache(key=cache_key)
|
||||
except Exception as e: # noqa: BLE001 # an optional binding must not make routing depend on Redis
|
||||
verbose_router_logger.warning(
|
||||
"Failed to read Claude Code session router binding; using the requested model: %s",
|
||||
e,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _resolve_claude_code_session_router(
|
||||
self,
|
||||
model: str,
|
||||
registered_model_name: str,
|
||||
request_kwargs: Mapping[str, object],
|
||||
) -> str:
|
||||
if not any((self.auto_routers, self.complexity_routers, self.adaptive_routers, self.quality_routers)):
|
||||
return registered_model_name
|
||||
cache_key: Final = self._claude_code_session_router_cache_key(request_kwargs)
|
||||
if cache_key is None or not isinstance(request_kwargs, dict):
|
||||
return registered_model_name
|
||||
if request_kwargs.get("fallback_depth") not in (None, 0):
|
||||
return registered_model_name
|
||||
|
||||
agent_id: Final = self._request_header(request_kwargs, "x-claude-code-agent-id")
|
||||
if agent_id is not None:
|
||||
bound_model: Final = await self._get_claude_code_session_router_binding(cache_key)
|
||||
if not isinstance(bound_model, str):
|
||||
return registered_model_name
|
||||
bound_registered_model: Final = self._get_model_from_alias(model=bound_model) or bound_model
|
||||
if self._select_pre_routing_strategy(bound_registered_model, request_kwargs) is None:
|
||||
await self._delete_claude_code_session_router_binding(cache_key)
|
||||
return registered_model_name
|
||||
await self._claude_code_session_router_cache.async_set_cache(
|
||||
key=cache_key,
|
||||
value=bound_model,
|
||||
ttl=_CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS,
|
||||
)
|
||||
self._stamp_or_clear_metadata_key(request_kwargs, "model_group", bound_model)
|
||||
return bound_registered_model
|
||||
|
||||
if self._request_header(request_kwargs, "x-app") != "cli":
|
||||
return registered_model_name
|
||||
if self._select_pre_routing_strategy(registered_model_name, request_kwargs) is None:
|
||||
return registered_model_name
|
||||
await self._claude_code_session_router_cache.async_set_cache(
|
||||
key=cache_key,
|
||||
value=model,
|
||||
ttl=_CLAUDE_CODE_SESSION_ROUTER_TTL_SECONDS,
|
||||
)
|
||||
return registered_model_name
|
||||
|
||||
async def async_pre_routing_hook(
|
||||
self,
|
||||
model: str,
|
||||
|
|
@ -12670,7 +12802,12 @@ class Router:
|
|||
the alias, since spend metadata is stamped before routing and the response carries the tier
|
||||
group the strategy picked.
|
||||
"""
|
||||
registered_model_name: Final = self._get_model_from_alias(model=model) or model
|
||||
requested_registered_model_name: Final = self._get_model_from_alias(model=model) or model
|
||||
registered_model_name: Final = await self._resolve_claude_code_session_router(
|
||||
model=model,
|
||||
registered_model_name=requested_registered_model_name,
|
||||
request_kwargs=request_kwargs,
|
||||
)
|
||||
|
||||
#########################################################
|
||||
# Run the routing-plugin pipeline, if any plugins are configured.
|
||||
|
|
@ -13467,6 +13604,9 @@ class Router:
|
|||
def flush_cache(self):
|
||||
litellm.cache = None
|
||||
self.cache.flush_cache()
|
||||
session_in_memory_cache: Final = self._claude_code_session_router_cache.in_memory_cache
|
||||
if session_in_memory_cache is not None:
|
||||
session_in_memory_cache.flush_cache()
|
||||
|
||||
def reset(self):
|
||||
## clean up on close
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any, Final
|
|||
import litellm
|
||||
from litellm._logging import verbose_router_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs
|
||||
from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_structure
|
||||
from litellm.router_utils.add_retry_fallback_headers import (
|
||||
add_fallback_headers_to_response,
|
||||
|
|
@ -231,8 +232,6 @@ def record_pre_routing_selection(request_kwargs: Mapping[str, Any] | None, selec
|
|||
on /v1/messages the top-level ``metadata`` dict is the provider's own request field,
|
||||
so a blanket write would forward the tier stamp upstream.
|
||||
"""
|
||||
from litellm.litellm_core_utils.core_helpers import get_metadata_variable_name_from_kwargs
|
||||
|
||||
if request_kwargs is None:
|
||||
return
|
||||
bucket: Final = request_kwargs.get(get_metadata_variable_name_from_kwargs(request_kwargs))
|
||||
|
|
@ -267,10 +266,13 @@ def get_pre_routing_selection(kwargs: Mapping[str, Any]) -> str | None:
|
|||
def fallback_lookup_groups(kwargs: Mapping[str, Any], model_group: str | None) -> tuple[str, ...]:
|
||||
"""
|
||||
Ordered keys for resolving a fallback chain: the tier a pre-routing hook selected wins,
|
||||
and the requested group still resolves when no tier-keyed chain exists, so configs keyed
|
||||
on the router name (the documented contract) keep working behind auto-routers.
|
||||
then the routed group, then the requested group. The routed group differs when Claude Code
|
||||
session affinity remaps a subagent's concrete model to its bound router.
|
||||
"""
|
||||
ordered: Final = (get_pre_routing_selection(kwargs), model_group)
|
||||
metadata: Final = kwargs.get(get_metadata_variable_name_from_kwargs(kwargs))
|
||||
routed_group_value: Final = metadata.get("model_group") if isinstance(metadata, Mapping) else None
|
||||
routed_group: Final = routed_group_value if isinstance(routed_group_value, str) else None
|
||||
ordered: Final = (get_pre_routing_selection(kwargs), routed_group, model_group)
|
||||
return tuple(dict.fromkeys(group for group in ordered if group))
|
||||
|
||||
|
||||
|
|
@ -470,10 +472,11 @@ async def run_async_fallback(
|
|||
attempted: Final = (
|
||||
carried_targets if isinstance(carried_targets, AttemptedFallbackTargets) else AttemptedFallbackTargets()
|
||||
)
|
||||
attempted.record(original_model_group)
|
||||
failed_model_group: Final = get_pre_routing_selection(kwargs) or original_model_group
|
||||
attempted.record(failed_model_group)
|
||||
|
||||
for mg in fallback_model_group:
|
||||
if mg == original_model_group:
|
||||
if mg == failed_model_group:
|
||||
continue
|
||||
if same_model_group_only and _get_fallback_target_model_group(mg) != original_model_group:
|
||||
verbose_router_logger.info(
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ import litellm
|
|||
from litellm.constants import request_timeout
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.vector_store.transformation import (
|
||||
BaseQueryEmbeddingVectorStoreConfig,
|
||||
VectorStoreEmbeddingExecutor,
|
||||
vector_store_request_metadata,
|
||||
)
|
||||
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.vector_stores import (
|
||||
|
|
@ -38,6 +43,16 @@ base_llm_http_handler = BaseLLMHTTPHandler()
|
|||
#################################################
|
||||
|
||||
|
||||
def _direct_vector_store_embedding_executor(
|
||||
value: object, router: "Router | None", request_kwargs: Mapping[str, object]
|
||||
) -> VectorStoreEmbeddingExecutor:
|
||||
if value is not None and not isinstance(value, VectorStoreEmbeddingExecutor):
|
||||
raise TypeError("Invalid direct vector store embedding executor")
|
||||
return BaseQueryEmbeddingVectorStoreConfig.query_embedding_executor(
|
||||
value, router, vector_store_request_metadata(request_kwargs)
|
||||
)
|
||||
|
||||
|
||||
def mock_vector_store_search_response(
|
||||
mock_results: list[VectorStoreSearchResult] | None = None,
|
||||
):
|
||||
|
|
@ -289,7 +304,12 @@ async def asearch(
|
|||
"""
|
||||
Async: Search a vector store for relevant chunks based on a query and file attributes filter.
|
||||
"""
|
||||
local_vars: Final = locals()
|
||||
embedding_executor: Final = _direct_vector_store_embedding_executor(
|
||||
kwargs.pop("_direct_vector_store_embedding_executor", None), router, kwargs
|
||||
)
|
||||
local_vars: Final = { # mutable-ok: exception logging requires a sanitized mutable snapshot
|
||||
key: value for key, value in locals().items() if key != "embedding_executor"
|
||||
}
|
||||
|
||||
try:
|
||||
loop: Final = asyncio.get_event_loop()
|
||||
|
|
@ -312,6 +332,7 @@ async def asearch(
|
|||
extra_body=extra_body,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
_direct_vector_store_embedding_executor=embedding_executor,
|
||||
router=router,
|
||||
**kwargs,
|
||||
)
|
||||
|
|
@ -369,12 +390,16 @@ def search(
|
|||
Returns:
|
||||
VectorStoreSearchResponse containing the search results.
|
||||
"""
|
||||
local_vars: Final = locals()
|
||||
embedding_executor: Final = _direct_vector_store_embedding_executor(
|
||||
kwargs.pop("_direct_vector_store_embedding_executor", None), router, kwargs
|
||||
)
|
||||
local_vars: Final = { # mutable-ok: exception logging requires a sanitized mutable snapshot
|
||||
key: value for key, value in locals().items() if key != "embedding_executor"
|
||||
}
|
||||
try:
|
||||
litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj")
|
||||
litellm_call_id: Final[str | None] = kwargs.get("litellm_call_id", None)
|
||||
_is_async: Final = kwargs.pop("asearch", False) is True
|
||||
|
||||
# pull credentials from registry if available
|
||||
if litellm.vector_store_registry is not None and vector_store_id is not None:
|
||||
try:
|
||||
|
|
@ -451,6 +476,7 @@ def search(
|
|||
custom_llm_provider=custom_llm_provider,
|
||||
litellm_params=litellm_params,
|
||||
logging_obj=litellm_logging_obj,
|
||||
embedding_executor=embedding_executor,
|
||||
extra_headers=extra_headers,
|
||||
extra_body=extra_body,
|
||||
timeout=timeout or request_timeout,
|
||||
|
|
|
|||
|
|
@ -177,7 +177,7 @@
|
|||
"limit": 8
|
||||
},
|
||||
"RUF019": {
|
||||
"limit": 31
|
||||
"limit": 27
|
||||
},
|
||||
"RUF046": {
|
||||
"limit": 4
|
||||
|
|
|
|||
|
|
@ -82,6 +82,11 @@ ignored_function_names = [
|
|||
"_invalidate_access_groups_cache", # Tested indirectly via set_model_list, upsert_model etc. (test files lack "router" in name)
|
||||
"has_buffered_provider_output", # Property, so its reads in test_router.py are never an ast.Call
|
||||
"_resolved_provider", # Tested via get_pattern in test_pattern_match_deployments.py (file lacks "router" in name)
|
||||
"_request_header", # Tested through Claude Code session routing in test_router.py
|
||||
"_claude_code_session_router_cache_key", # Tested through Claude Code session routing in test_router.py
|
||||
"_delete_claude_code_session_router_binding", # Tested through Redis cleanup failure in test_router.py
|
||||
"_resolve_claude_code_session_router", # Tested through Claude Code session routing in test_router.py
|
||||
"_get_claude_code_session_router_binding", # Tested through the two-worker session routing test in test_router.py
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -71,6 +71,48 @@ def setup_vector_store_registry():
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_store_hook_routes_search_through_proxy_router(
|
||||
setup_vector_store_registry,
|
||||
):
|
||||
proxy_router = Mock()
|
||||
proxy_router.avector_store_search = AsyncMock(
|
||||
return_value=VectorStoreSearchResponse(
|
||||
object="vector_store.search_results.page",
|
||||
search_query="what is litellm?",
|
||||
data=[
|
||||
VectorStoreSearchResult(
|
||||
score=1.0,
|
||||
content=[VectorStoreResultContent(text="routed context", type="text")],
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
logging_obj = Mock()
|
||||
logging_obj.model_call_details = {
|
||||
"litellm_params": {"metadata": {"user_api_key_team_id": "team-a"}}
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.proxy_server.llm_router", proxy_router):
|
||||
_, messages, _ = await VectorStorePreCallHook().async_get_chat_completion_prompt(
|
||||
model="chat-model",
|
||||
messages=[{"role": "user", "content": "what is litellm?"}],
|
||||
non_default_params={"vector_store_ids": ["T37J8R4WTM"]},
|
||||
prompt_id=None,
|
||||
prompt_variables=None,
|
||||
dynamic_callback_params={},
|
||||
litellm_logging_obj=logging_obj,
|
||||
)
|
||||
|
||||
proxy_router.avector_store_search.assert_awaited_once_with(
|
||||
vector_store_id="T37J8R4WTM",
|
||||
query="what is litellm?",
|
||||
custom_llm_provider="bedrock",
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
assert messages[0]["content"] == "Context:\n\nrouted context\n\n"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_e2e_bedrock_knowledgebase_retrieval_with_completion(
|
||||
setup_vector_store_registry,
|
||||
|
|
|
|||
|
|
@ -5,17 +5,218 @@ These tests simulate real-world scenarios where headers and configuration
|
|||
need to be properly propagated through the router to the LLM API.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
|
||||
import litellm
|
||||
from litellm import Router
|
||||
from litellm.llms.base_llm.vector_store.transformation import (
|
||||
LiteLLMVectorStoreEmbeddingExecutor,
|
||||
RouterVectorStoreEmbeddingExecutor,
|
||||
)
|
||||
|
||||
QUERY_VECTOR = [0.5, -0.25, 0.125]
|
||||
OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings"
|
||||
STORE_EMBEDDINGS_URL = "https://embedding.example/v1/embeddings"
|
||||
|
||||
|
||||
def _mock_embedding_route(respx_mock: respx.MockRouter, url: str) -> respx.Route:
|
||||
return respx_mock.post(url).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"object": "list",
|
||||
"data": [{"object": "embedding", "index": 0, "embedding": QUERY_VECTOR}],
|
||||
"model": "text-embedding-3-small",
|
||||
"usage": {"prompt_tokens": 2, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _sent(route: respx.Route, index: int) -> tuple[str, str, list[str]]:
|
||||
request = route.calls[index].request
|
||||
body = json.loads(request.read())
|
||||
return request.headers["authorization"], body["model"], body["input"]
|
||||
|
||||
|
||||
def _alias_router() -> Router:
|
||||
return Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "team-alias",
|
||||
"litellm_params": {
|
||||
"model": "openai/text-embedding-3-small",
|
||||
"api_key": "deployment-key",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class TestRouterEmbeddingIntegration:
|
||||
"""Integration tests for embedding with router configuration."""
|
||||
|
||||
def test_vector_store_request_metadata_prefers_litellm_metadata(self):
|
||||
assert Router._vector_store_request_metadata(
|
||||
{
|
||||
"litellm_metadata": {"user_api_key_team_id": "team-a"},
|
||||
"metadata": {"user_api_key_team_id": "team-b"},
|
||||
}
|
||||
) == {"user_api_key_team_id": "team-a"}
|
||||
|
||||
assert Router._vector_store_request_metadata({"metadata": {"user_api_key_team_id": "team-b"}}) == {
|
||||
"user_api_key_team_id": "team-b"
|
||||
}
|
||||
assert Router._vector_store_request_metadata({}) == {}
|
||||
|
||||
def test_sync_vector_store_wrapper_injects_router_embedding_executor(self):
|
||||
router = Router(model_list=[])
|
||||
original = MagicMock(return_value="searched")
|
||||
wrapped = router.factory_function(original, call_type="vector_store_search")
|
||||
|
||||
assert (
|
||||
wrapped(
|
||||
vector_store_id="store",
|
||||
query="query",
|
||||
custom_llm_provider="valkey",
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
== "searched"
|
||||
)
|
||||
|
||||
call_kwargs = original.call_args.kwargs
|
||||
assert call_kwargs["custom_llm_provider"] == "valkey"
|
||||
executor = call_kwargs["_direct_vector_store_embedding_executor"]
|
||||
assert isinstance(executor, RouterVectorStoreEmbeddingExecutor)
|
||||
assert executor.metadata == {"user_api_key_team_id": "team-a"}
|
||||
|
||||
def test_sync_vector_store_wrapper_preserves_model_routing(self):
|
||||
router = Router(model_list=[])
|
||||
original = MagicMock()
|
||||
wrapped = router.factory_function(original, call_type="vector_store_search")
|
||||
|
||||
with patch.object(router, "_generic_api_call_with_fallbacks", return_value="routed") as fallback:
|
||||
assert wrapped(model="vector-alias", vector_store_id="store", query="query") == "routed"
|
||||
|
||||
assert fallback.call_args.kwargs["model"] == "vector-alias"
|
||||
assert fallback.call_args.kwargs["original_function"] is original
|
||||
assert isinstance(
|
||||
fallback.call_args.kwargs["_direct_vector_store_embedding_executor"],
|
||||
RouterVectorStoreEmbeddingExecutor,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_store_embedding_executors_cover_sdk_and_router_paths(
|
||||
self, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
openai_route = _mock_embedding_route(respx_mock, OPENAI_EMBEDDINGS_URL)
|
||||
store_route = _mock_embedding_route(respx_mock, STORE_EMBEDDINGS_URL)
|
||||
sdk_executor = LiteLLMVectorStoreEmbeddingExecutor()
|
||||
|
||||
sync_response = sdk_executor.embed("openai/text-embedding-3-small", "sync", {"api_key": "explicit"})
|
||||
async_response = await sdk_executor.aembed("openai/text-embedding-3-small", "async", {"api_key": "explicit"})
|
||||
|
||||
assert sync_response.data[0]["embedding"] == QUERY_VECTOR
|
||||
assert async_response.data[0]["embedding"] == QUERY_VECTOR
|
||||
assert _sent(openai_route, 0) == ("Bearer explicit", "text-embedding-3-small", ["sync"])
|
||||
assert _sent(openai_route, 1) == ("Bearer explicit", "text-embedding-3-small", ["async"])
|
||||
|
||||
explicit_config = {
|
||||
"api_base": "https://embedding.example/v1",
|
||||
"api_key": "store-key",
|
||||
"metadata": {
|
||||
"configured": True,
|
||||
"user_api_key_team_id": "untrusted-team",
|
||||
},
|
||||
"model": "untrusted-model",
|
||||
}
|
||||
mock_router = MagicMock()
|
||||
mock_router.embedding.return_value = sync_response
|
||||
router_executor = RouterVectorStoreEmbeddingExecutor(
|
||||
router=mock_router,
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
assert router_executor.embed("team-alias", "query", explicit_config) is sync_response
|
||||
mock_router.embedding.assert_called_once_with(
|
||||
model="team-alias",
|
||||
input=["query"],
|
||||
api_base="https://embedding.example/v1",
|
||||
api_key="store-key",
|
||||
metadata={"configured": True, "user_api_key_team_id": "team-a"},
|
||||
)
|
||||
|
||||
alias_executor = RouterVectorStoreEmbeddingExecutor(
|
||||
router=_alias_router(),
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
sync_alias = alias_executor.embed("team-alias", "sync query", explicit_config)
|
||||
async_alias = await alias_executor.aembed("team-alias", "async query", explicit_config)
|
||||
|
||||
assert sync_alias.data[0]["embedding"] == QUERY_VECTOR
|
||||
assert async_alias.data[0]["embedding"] == QUERY_VECTOR
|
||||
assert openai_route.call_count == 2
|
||||
assert _sent(store_route, 0) == ("Bearer store-key", "text-embedding-3-small", ["sync query"])
|
||||
assert _sent(store_route, 1) == ("Bearer store-key", "text-embedding-3-small", ["async query"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_executor_falls_back_to_sdk_for_models_the_router_does_not_serve(
|
||||
self, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
store_route = _mock_embedding_route(respx_mock, STORE_EMBEDDINGS_URL)
|
||||
executor = RouterVectorStoreEmbeddingExecutor(
|
||||
router=_alias_router(),
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
inline_config = {"api_base": "https://embedding.example/v1", "api_key": "store-key"}
|
||||
|
||||
sync_response = executor.embed("openai/text-embedding-3-large", "sync query", inline_config)
|
||||
async_response = await executor.aembed("openai/text-embedding-3-large", "async query", inline_config)
|
||||
|
||||
assert sync_response.data[0]["embedding"] == QUERY_VECTOR
|
||||
assert async_response.data[0]["embedding"] == QUERY_VECTOR
|
||||
assert _sent(store_route, 0) == ("Bearer store-key", "text-embedding-3-large", ["sync query"])
|
||||
assert _sent(store_route, 1) == ("Bearer store-key", "text-embedding-3-large", ["async query"])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_executor_rejects_unserved_models_without_explicit_config(
|
||||
self, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "env-key")
|
||||
openai_route = _mock_embedding_route(respx_mock, OPENAI_EMBEDDINGS_URL)
|
||||
executor = RouterVectorStoreEmbeddingExecutor(
|
||||
router=_alias_router(),
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
executor.embed("openai/text-embedding-3-large", "sync query", {})
|
||||
with pytest.raises(litellm.BadRequestError):
|
||||
await executor.aembed("openai/text-embedding-3-large", "async query", {})
|
||||
|
||||
assert openai_route.call_count == 0
|
||||
|
||||
def test_router_executor_routes_deployment_model_names_through_the_router(
|
||||
self, respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
openai_route = _mock_embedding_route(respx_mock, OPENAI_EMBEDDINGS_URL)
|
||||
executor = RouterVectorStoreEmbeddingExecutor(router=_alias_router(), metadata={})
|
||||
|
||||
response = executor.embed("openai/text-embedding-3-small", "query", {})
|
||||
|
||||
assert response.data[0]["embedding"] == QUERY_VECTOR
|
||||
assert _sent(openai_route, 0) == ("Bearer deployment-key", "text-embedding-3-small", ["query"])
|
||||
|
||||
def test_embedding_with_deployment_specific_headers(self):
|
||||
"""
|
||||
Test that deployment-specific headers are propagated.
|
||||
|
|
@ -122,9 +323,7 @@ class TestRouterEmbeddingIntegration:
|
|||
|
||||
router = Router(
|
||||
model_list=model_list,
|
||||
default_litellm_params={
|
||||
"metadata": {"environment": "test", "service": "embedding-service"}
|
||||
},
|
||||
default_litellm_params={"metadata": {"environment": "test", "service": "embedding-service"}},
|
||||
)
|
||||
|
||||
with patch("litellm.embedding") as mock_embedding:
|
||||
|
|
@ -240,9 +439,7 @@ class TestRouterEmbeddingIntegration:
|
|||
# Make multiple calls and verify headers are always present
|
||||
for i in range(5):
|
||||
with patch("litellm.embedding") as mock_embedding:
|
||||
mock_embedding.return_value = MagicMock(
|
||||
data=[{"embedding": [0.1, 0.2]}]
|
||||
)
|
||||
mock_embedding.return_value = MagicMock(data=[{"embedding": [0.1, 0.2]}])
|
||||
|
||||
router.embedding(model="shared-embedding-model", input=[f"test {i}"])
|
||||
|
||||
|
|
@ -327,9 +524,7 @@ class TestRouterEmbeddingIntegration:
|
|||
|
||||
router = Router(
|
||||
model_list=model_list,
|
||||
default_litellm_params={
|
||||
"headers": {"X-Custom-Azure-Header": "azure-value"}
|
||||
},
|
||||
default_litellm_params={"headers": {"X-Custom-Azure-Header": "azure-value"}},
|
||||
)
|
||||
|
||||
with patch("litellm.embedding") as mock_embedding:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
Regression tests for issue #34692.
|
||||
|
||||
ollama_chat streams tool_calls in a mid-stream chunk while its final
|
||||
(``done: true``) chunk carries only ``done_reason: "stop"``. The provider
|
||||
iterator must remember the earlier tool_calls and stamp
|
||||
``finish_reason="tool_calls"`` on the final chunk, so the Anthropic
|
||||
``/v1/messages`` bridge emits ``stop_reason: "tool_use"``. Before the fix the
|
||||
bridge emitted ``stop_reason: "end_turn"`` and Anthropic tool-runners
|
||||
(Claude Code, ``messages.stream``) silently dropped the tool call.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.llms.anthropic.experimental_pass_through.adapters.streaming_iterator import (
|
||||
AnthropicStreamWrapper,
|
||||
)
|
||||
from litellm.llms.ollama.chat.transformation import (
|
||||
OllamaChatCompletionResponseIterator,
|
||||
)
|
||||
from litellm.types.utils import ModelResponseStream
|
||||
|
||||
_OLLAMA_TOOL_CHUNK = {
|
||||
"model": "qwen3:8b",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"function": {"name": "get_weather", "arguments": {"city": "San Francisco"}}}],
|
||||
},
|
||||
"done": False,
|
||||
}
|
||||
_OLLAMA_DONE_CHUNK = {
|
||||
"model": "qwen3:8b",
|
||||
"message": {"role": "assistant", "content": ""},
|
||||
"done": True,
|
||||
"done_reason": "stop",
|
||||
"prompt_eval_count": 100,
|
||||
"eval_count": 20,
|
||||
}
|
||||
|
||||
|
||||
def _ollama_streamed_chunks() -> list[ModelResponseStream]:
|
||||
iterator = OllamaChatCompletionResponseIterator(streaming_response=iter([]), sync_stream=True)
|
||||
return [iterator.chunk_parser(_OLLAMA_TOOL_CHUNK), iterator.chunk_parser(_OLLAMA_DONE_CHUNK)]
|
||||
|
||||
|
||||
class _AsyncStream:
|
||||
def __init__(self, items: list[ModelResponseStream]):
|
||||
self._it = iter(items)
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
try:
|
||||
return next(self._it)
|
||||
except StopIteration:
|
||||
raise StopAsyncIteration
|
||||
|
||||
|
||||
def _assert_tool_use_stop_reason(events: list[dict]) -> None:
|
||||
block_types = [e["content_block"]["type"] for e in events if e.get("type") == "content_block_start"]
|
||||
assert "tool_use" in block_types, f"no tool_use content block opened: {events}"
|
||||
message_deltas = [e for e in events if e.get("type") == "message_delta"]
|
||||
assert message_deltas, f"no message_delta emitted: {events}"
|
||||
assert message_deltas[-1]["delta"]["stop_reason"] == "tool_use", (
|
||||
f"expected stop_reason 'tool_use', got: {message_deltas[-1]}"
|
||||
)
|
||||
|
||||
|
||||
def test_ollama_mid_stream_tool_call_yields_tool_use_stop_reason_sync():
|
||||
wrapper = AnthropicStreamWrapper(completion_stream=iter(_ollama_streamed_chunks()), model="qwen3:8b")
|
||||
_assert_tool_use_stop_reason(list(wrapper))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ollama_mid_stream_tool_call_yields_tool_use_stop_reason_async():
|
||||
wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(_ollama_streamed_chunks()), model="qwen3:8b")
|
||||
_assert_tool_use_stop_reason([event async for event in wrapper])
|
||||
|
|
@ -615,6 +615,46 @@ class TestOllamaFinishReasonLength:
|
|||
result.choices[0].finish_reason == "stop"
|
||||
), f"Expected 'stop' for natural finish, got '{result.choices[0].finish_reason}'"
|
||||
|
||||
def test_finish_reason_tool_calls_streamed_before_done_chunk(self):
|
||||
"""Streaming: tool_calls arriving mid-stream (not on the done chunk) must
|
||||
still produce finish_reason='tool_calls' on the final chunk.
|
||||
|
||||
Regression test for https://github.com/BerriAI/litellm/issues/34692:
|
||||
Ollama emits tool_calls in an earlier chunk and the done chunk carries
|
||||
none, which left finish_reason at 'stop' and made the Anthropic
|
||||
/v1/messages bridge emit stop_reason 'end_turn' instead of 'tool_use'.
|
||||
"""
|
||||
iterator = OllamaChatCompletionResponseIterator(
|
||||
streaming_response=iter([]),
|
||||
sync_stream=True,
|
||||
)
|
||||
|
||||
tool_chunk = {
|
||||
"model": "qwen3:8b",
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{"function": {"name": "get_weather", "arguments": {"city": "San Francisco"}}}
|
||||
],
|
||||
},
|
||||
"done": False,
|
||||
}
|
||||
done_chunk = {
|
||||
"model": "qwen3:8b",
|
||||
"message": {"role": "assistant", "content": ""},
|
||||
"done": True,
|
||||
"done_reason": "stop",
|
||||
}
|
||||
|
||||
tool_result = iterator.chunk_parser(tool_chunk)
|
||||
assert tool_result.choices[0].delta.tool_calls is not None
|
||||
|
||||
done_result = iterator.chunk_parser(done_chunk)
|
||||
assert (
|
||||
done_result.choices[0].finish_reason == "tool_calls"
|
||||
), f"Expected 'tool_calls' when tool_calls were streamed earlier, got '{done_result.choices[0].finish_reason}'"
|
||||
|
||||
|
||||
class TestOllamaReasoningContentStreaming:
|
||||
"""Test that reasoning_content is properly extracted from all thinking chunks."""
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ Tests the handler's ability to process input/output for the Responses API
|
|||
with guardrail transformations.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from collections.abc import Callable
|
||||
from typing import Any, List, Literal, Optional, Tuple
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
|
|
@ -19,6 +21,10 @@ from litellm.llms import get_guardrail_translation_mapping
|
|||
from litellm.llms.openai.responses.guardrail_translation.handler import (
|
||||
OpenAIResponsesHandler,
|
||||
)
|
||||
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.responses.main import GenericResponseOutputItem, OutputText
|
||||
from litellm.types.utils import CallTypes, GenericGuardrailAPIInputs
|
||||
|
|
@ -1287,14 +1293,14 @@ class TestOpenAIResponsesHandlerToolInjection:
|
|||
"""A tool a guardrail injects must survive the write-back to Responses format."""
|
||||
|
||||
def test_merge_keeps_guardrail_appended_tool(self):
|
||||
"""_merge_tools_after_guardrail must not drop the extra appended tool."""
|
||||
handler = OpenAIResponsesHandler()
|
||||
"""merge_guardrailed_tools must not drop the extra appended tool."""
|
||||
original = [{"type": "function", "name": "a"}]
|
||||
remapped = [
|
||||
{"type": "function", "name": "a"},
|
||||
{"type": "function", "name": "b"},
|
||||
groups = [form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(original)]
|
||||
guardrailed = [
|
||||
*groups[0],
|
||||
{"type": "function", "function": {"name": "b", "description": "", "parameters": {"type": "object"}}},
|
||||
]
|
||||
merged = handler._merge_tools_after_guardrail(original, remapped)
|
||||
merged = merge_guardrailed_tools(original, groups, guardrailed)
|
||||
assert [t["name"] for t in merged] == ["a", "b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1323,6 +1329,194 @@ class TestOpenAIResponsesHandlerToolInjection:
|
|||
assert "injected_tool" in names
|
||||
|
||||
|
||||
class ToolEditingGuardrail(CustomGuardrail):
|
||||
"""Guardrail that rewrites the flattened chat tools it was handed through ``edit``"""
|
||||
|
||||
def __init__(self, edit: Callable[[list[dict]], list[dict]], **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.edit = edit
|
||||
|
||||
async def apply_guardrail(
|
||||
self,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: dict,
|
||||
input_type: Literal["request", "response"],
|
||||
logging_obj: Any | None = None,
|
||||
) -> GenericGuardrailAPIInputs:
|
||||
inputs["tools"] = self.edit(list(inputs.get("tools") or []))
|
||||
return inputs
|
||||
|
||||
|
||||
def _codex_request(input_value):
|
||||
"""A Responses API request shaped like what the Codex CLI sends when an MCP server is configured"""
|
||||
return {
|
||||
"model": "gpt-5.3-codex",
|
||||
"input": input_value,
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "get_weather",
|
||||
"description": "Weather lookup",
|
||||
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}},
|
||||
"strict": False,
|
||||
},
|
||||
{
|
||||
"type": "namespace",
|
||||
"name": "mcp__confluence",
|
||||
"description": "Confluence tools",
|
||||
"tools": [
|
||||
{
|
||||
"type": "function",
|
||||
"name": "confluence_get_page",
|
||||
"description": "Get a page",
|
||||
"parameters": {"type": "object", "properties": {"id": {"type": "string"}}, "required": ["id"]},
|
||||
"strict": False,
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"name": "confluence_search",
|
||||
"description": "Search pages",
|
||||
"parameters": {"type": "object", "properties": {"q": {"type": "string"}}},
|
||||
"strict": False,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "custom",
|
||||
"name": "apply_patch",
|
||||
"description": "Apply a patch",
|
||||
"format": {"type": "grammar", "syntax": "lark", "definition": 'start: "x"'},
|
||||
},
|
||||
{"type": "web_search"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _tool_named(tools, name):
|
||||
return next(tool for tool in tools if tool.get("name") == name)
|
||||
|
||||
|
||||
class TestOpenAIResponsesHandlerNamespaceTools:
|
||||
"""Codex sends MCP tools as ``namespace`` tools; a guardrail must never flatten them (GH #39183)"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"input_value",
|
||||
["hi", [{"role": "user", "content": "hi", "type": "message"}]],
|
||||
ids=["string_input", "list_input"],
|
||||
)
|
||||
async def test_pass_through_guardrail_leaves_tools_untouched(self, input_value):
|
||||
data = _codex_request(input_value)
|
||||
expected_tools = copy.deepcopy(data["tools"])
|
||||
|
||||
result = await OpenAIResponsesHandler().process_input_messages(
|
||||
data, MockPassThroughGuardrail(guardrail_name="test")
|
||||
)
|
||||
|
||||
assert result["tools"] == expected_tools
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_appending_guardrail_keeps_namespace_and_adds_tool(self):
|
||||
data = _codex_request("hi")
|
||||
expected_tools = copy.deepcopy(data["tools"])
|
||||
|
||||
result = await OpenAIResponsesHandler().process_input_messages(
|
||||
data, ToolAppendingGuardrail(guardrail_name="test")
|
||||
)
|
||||
|
||||
assert result["tools"][:-1] == expected_tools
|
||||
assert result["tools"][-1]["type"] == "function"
|
||||
assert result["tools"][-1]["name"] == "injected_tool"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dropping_one_member_prunes_only_that_member(self):
|
||||
data = _codex_request("hi")
|
||||
expected_tools = copy.deepcopy(data["tools"])
|
||||
guardrail = ToolEditingGuardrail(
|
||||
edit=lambda tools: [t for t in tools if t["function"]["name"] != "mcp__confluence__confluence_search"],
|
||||
guardrail_name="test",
|
||||
)
|
||||
|
||||
result = await OpenAIResponsesHandler().process_input_messages(data, guardrail)
|
||||
|
||||
namespace = _tool_named(result["tools"], "mcp__confluence")
|
||||
assert [member["name"] for member in namespace["tools"]] == ["confluence_get_page"]
|
||||
assert namespace["tools"][0] == expected_tools[1]["tools"][0]
|
||||
assert [t for t in result["tools"] if t is not namespace] == [expected_tools[0], *expected_tools[2:]]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_editing_a_member_lands_on_that_member_without_the_namespace_prefix(self):
|
||||
data = _codex_request("hi")
|
||||
expected_tools = copy.deepcopy(data["tools"])
|
||||
|
||||
def redact_search(tools):
|
||||
for tool in tools:
|
||||
if tool["function"]["name"] == "mcp__confluence__confluence_search":
|
||||
tool["function"]["description"] = "Confluence tools\n\nREDACTED"
|
||||
return tools
|
||||
|
||||
result = await OpenAIResponsesHandler().process_input_messages(
|
||||
data, ToolEditingGuardrail(edit=redact_search, guardrail_name="test")
|
||||
)
|
||||
|
||||
namespace = _tool_named(result["tools"], "mcp__confluence")
|
||||
assert namespace["tools"][0] == expected_tools[1]["tools"][0]
|
||||
assert namespace["tools"][1] == {**expected_tools[1]["tools"][1], "description": "REDACTED"}
|
||||
assert {k: v for k, v in namespace.items() if k != "tools"} == {
|
||||
k: v for k, v in expected_tools[1].items() if k != "tools"
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dropping_every_member_drops_the_namespace(self):
|
||||
data = _codex_request("hi")
|
||||
expected_tools = copy.deepcopy(data["tools"])
|
||||
guardrail = ToolEditingGuardrail(
|
||||
edit=lambda tools: [t for t in tools if not t["function"]["name"].startswith("mcp__confluence__")],
|
||||
guardrail_name="test",
|
||||
)
|
||||
|
||||
result = await OpenAIResponsesHandler().process_input_messages(data, guardrail)
|
||||
|
||||
assert result["tools"] == [expected_tools[0], *expected_tools[2:]]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edited_top_level_function_is_rewritten_in_place(self):
|
||||
data = _codex_request("hi")
|
||||
expected_tools = copy.deepcopy(data["tools"])
|
||||
|
||||
def rename_weather(tools):
|
||||
for tool in tools:
|
||||
if tool["function"]["name"] == "get_weather":
|
||||
tool["function"]["description"] = "Weather lookup (guarded)"
|
||||
return tools
|
||||
|
||||
result = await OpenAIResponsesHandler().process_input_messages(
|
||||
data, ToolEditingGuardrail(edit=rename_weather, guardrail_name="test")
|
||||
)
|
||||
|
||||
assert result["tools"][0] == {**expected_tools[0], "description": "Weather lookup (guarded)"}
|
||||
assert result["tools"][1:] == expected_tools[1:]
|
||||
|
||||
|
||||
class TestOpenAIResponsesHandlerMalformedTools:
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_tools_that_are_not_a_list_never_reach_the_guardrail(self):
|
||||
handler = OpenAIResponsesHandler()
|
||||
seen: list[list[dict]] = []
|
||||
|
||||
def record(tools):
|
||||
seen.append(tools)
|
||||
return tools
|
||||
|
||||
guardrail = ToolEditingGuardrail(edit=record, guardrail_name="test")
|
||||
data = {"input": "hi", "tools": {"type": "function", "name": "get_weather"}}
|
||||
|
||||
result = await handler.process_input_messages(data, guardrail)
|
||||
|
||||
assert seen == [[]]
|
||||
assert result["input"] == "hi"
|
||||
|
||||
|
||||
class TestBuildBlockSseChunks:
|
||||
"""build_block_sse_chunks turns a streaming ModifyResponseException into 200 SSE events"""
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,198 @@
|
|||
"""
|
||||
Unit tests for merge_guardrailed_tools, which writes guardrail-returned chat tools back onto the
|
||||
Responses API request tools they were flattened from
|
||||
"""
|
||||
|
||||
import copy
|
||||
|
||||
from litellm.llms.openai.responses.guardrail_translation.tool_merge import merge_guardrailed_tools
|
||||
from litellm.responses.litellm_completion_transformation.transformation import (
|
||||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import GuardrailToolParam
|
||||
|
||||
|
||||
def _groups(tools):
|
||||
return [form.chat_tools for form in LiteLLMCompletionResponsesConfig.responses_tools_to_chat_forms(tools)]
|
||||
|
||||
|
||||
def _flat(groups):
|
||||
return [chat_tool for group in groups for chat_tool in group]
|
||||
|
||||
|
||||
def _function(name, description=""):
|
||||
return {"type": "function", "name": name, "description": description, "parameters": {"type": "object"}}
|
||||
|
||||
|
||||
def test_unchanged_tools_come_back_as_the_original_objects():
|
||||
original = [
|
||||
_function("a"),
|
||||
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("x"), _function("y")]},
|
||||
{"type": "mcp", "server_label": "deepwiki", "server_url": "https://mcp.deepwiki.com/mcp"},
|
||||
{"type": "web_search"},
|
||||
]
|
||||
groups = _groups(original)
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, _flat(groups))
|
||||
|
||||
assert list(merged) == original
|
||||
assert all(merged_tool is original_tool for merged_tool, original_tool in zip(merged, original))
|
||||
|
||||
|
||||
def test_guardrail_reordering_unchanged_tools_keeps_request_order():
|
||||
original = [_function("a"), {"type": "namespace", "name": "ns", "tools": [_function("x")]}, {"type": "web_search"}]
|
||||
groups = _groups(original)
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, list(reversed(_flat(groups))))
|
||||
|
||||
assert list(merged) == original
|
||||
|
||||
|
||||
def test_duplicate_function_names_are_matched_by_ordinal():
|
||||
original = [_function("dup", "first"), _function("dup", "second")]
|
||||
groups = _groups(original)
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, _flat(groups)[:1])
|
||||
|
||||
assert list(merged) == [original[0]]
|
||||
|
||||
|
||||
def test_interleaved_duplicate_names_keep_their_own_ordinals():
|
||||
original = [
|
||||
_function("dup", "a"),
|
||||
_function("other", "x"),
|
||||
_function("dup", "b"),
|
||||
_function("dup", "c"),
|
||||
_function("other", "y"),
|
||||
]
|
||||
groups = _groups(original)
|
||||
flat = _flat(groups)
|
||||
edited = {**flat[3], "function": {**flat[3]["function"], "description": "changed"}}
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, [*flat[:3], edited, flat[4]])
|
||||
|
||||
assert list(merged) == [*original[:3], {**_function("dup", "changed"), "strict": False}, original[4]]
|
||||
assert all(merged[position] is original[position] for position in (0, 1, 2, 4))
|
||||
|
||||
|
||||
def test_edited_mcp_tool_is_rewritten():
|
||||
original = [{"type": "mcp", "server_label": "deepwiki", "server_url": "https://mcp.deepwiki.com/mcp"}]
|
||||
groups = _groups(original)
|
||||
edited = [{**groups[0][0], "allowed_tools": ["read_wiki_structure"]}]
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, edited)
|
||||
|
||||
assert list(merged) == edited
|
||||
|
||||
|
||||
def test_injected_tool_lands_after_the_request_tools_when_request_had_none():
|
||||
injected = {"type": "function", "function": {"name": "b", "description": "d", "parameters": {"type": "object"}}}
|
||||
|
||||
merged = merge_guardrailed_tools([], [], [injected])
|
||||
|
||||
assert list(merged) == [
|
||||
{"type": "function", "name": "b", "description": "d", "parameters": {"type": "object"}, "strict": False}
|
||||
]
|
||||
|
||||
|
||||
def test_empty_guardrail_output_keeps_only_tools_never_sent_to_the_guardrail():
|
||||
original = [_function("a"), {"type": "web_search"}, {"type": "namespace", "name": "ns", "tools": [_function("x")]}]
|
||||
|
||||
merged = merge_guardrailed_tools(original, _groups(original), [])
|
||||
|
||||
assert list(merged) == [{"type": "web_search"}]
|
||||
|
||||
|
||||
def test_member_edit_strips_only_the_namespace_description_prefix():
|
||||
original = [{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("x", "X doc")]}]
|
||||
groups = _groups(original)
|
||||
assert groups[0][0]["function"]["description"] == "NS\n\nX doc"
|
||||
edited = [{**groups[0][0], "function": {**groups[0][0]["function"], "description": "NS\n\nX doc (guarded)"}}]
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, edited)
|
||||
|
||||
assert list(merged) == [
|
||||
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("x", "X doc (guarded)")]}
|
||||
]
|
||||
|
||||
|
||||
def test_namespace_keeps_a_non_function_member_when_a_function_member_is_edited():
|
||||
custom_member = {"type": "custom", "name": "grep", "description": "Grep", "format": {"type": "text"}}
|
||||
original = [
|
||||
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("read", "Read"), custom_member]}
|
||||
]
|
||||
groups = _groups(original)
|
||||
edited = copy.deepcopy(_flat(groups))
|
||||
edited[0]["function"]["description"] = "NS\n\nEDITED"
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, edited)
|
||||
|
||||
assert len(merged) == 1
|
||||
assert [member["name"] for member in merged[0]["tools"]] == ["read", "grep"]
|
||||
assert merged[0]["tools"][0]["description"] == "EDITED"
|
||||
assert merged[0]["tools"][1] == custom_member
|
||||
|
||||
|
||||
def test_namespace_keeps_its_non_function_members_when_every_function_member_is_dropped():
|
||||
custom_member = {"type": "custom", "name": "grep", "description": "Grep", "format": {"type": "text"}}
|
||||
original = [
|
||||
{"type": "namespace", "name": "ns", "description": "NS", "tools": [_function("read"), custom_member]},
|
||||
_function("a"),
|
||||
]
|
||||
groups = _groups(original)
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, [groups[1][0]])
|
||||
|
||||
assert list(merged) == [{"type": "namespace", "name": "ns", "description": "NS", "tools": [custom_member]}, _function("a")]
|
||||
|
||||
|
||||
def test_member_extras_edited_by_the_guardrail_land_on_that_member():
|
||||
original = [{"type": "namespace", "name": "ns", "tools": [_function("read")]}]
|
||||
groups = _groups(original)
|
||||
edited = copy.deepcopy(_flat(groups))
|
||||
edited[0]["cache_control"] = {"type": "ephemeral"}
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, edited)
|
||||
|
||||
assert merged[0]["tools"][0]["cache_control"] == {"type": "ephemeral"}
|
||||
assert merged[0]["tools"][0]["name"] == "read"
|
||||
|
||||
|
||||
def test_guardrail_output_is_read_once():
|
||||
original = [_function("a"), {"type": "namespace", "name": "ns", "tools": [_function("x")]}]
|
||||
groups = _groups(original)
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, (chat_tool for chat_tool in _flat(groups)))
|
||||
|
||||
assert list(merged) == original
|
||||
|
||||
|
||||
def test_pydantic_guardrail_tools_round_trip_like_dicts():
|
||||
original = [_function("a"), {"type": "namespace", "name": "ns", "tools": [_function("x", "X doc")]}]
|
||||
groups = _groups(original)
|
||||
models = [GuardrailToolParam.model_validate(chat_tool) for chat_tool in _flat(groups)]
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, models)
|
||||
|
||||
assert list(merged) == original
|
||||
assert all(merged_tool is original_tool for merged_tool, original_tool in zip(merged, original))
|
||||
|
||||
|
||||
def test_pydantic_guardrail_edit_lands_on_the_member():
|
||||
original = [{"type": "namespace", "name": "ns", "tools": [_function("x", "X doc")]}]
|
||||
groups = _groups(original)
|
||||
edited = copy.deepcopy(_flat(groups))
|
||||
edited[0]["function"]["description"] = "EDITED"
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, [GuardrailToolParam.model_validate(edited[0])])
|
||||
|
||||
assert list(merged) == [{"type": "namespace", "name": "ns", "tools": [_function("x", "EDITED")]}]
|
||||
|
||||
|
||||
def test_non_object_guardrail_items_are_dropped():
|
||||
original = [_function("a")]
|
||||
groups = _groups(original)
|
||||
|
||||
merged = merge_guardrailed_tools(original, groups, [*_flat(groups), "junk", None])
|
||||
|
||||
assert list(merged) == original
|
||||
|
|
@ -67,20 +67,52 @@ class FakeAsyncEmbeddingFn(FakeEmbeddingFn):
|
|||
return SimpleNamespace(data=[{"embedding": self.embedding}])
|
||||
|
||||
|
||||
class FakeEmbeddingExecutor:
|
||||
def __init__(self, embedding):
|
||||
self.embedding = embedding
|
||||
self.captured = None
|
||||
|
||||
def embed(self, model, query, configuration):
|
||||
self.captured = (model, query, configuration)
|
||||
return SimpleNamespace(data=[{"embedding": self.embedding}])
|
||||
|
||||
async def aembed(self, model, query, configuration):
|
||||
self.captured = (model, query, configuration)
|
||||
return SimpleNamespace(data=[{"embedding": self.embedding}])
|
||||
|
||||
|
||||
def _doc(doc_id, distance, **fields):
|
||||
return SimpleNamespace(id=doc_id, vector_distance=str(distance), **fields)
|
||||
|
||||
|
||||
def _search(config, client=None, query="what is litellm", optional_params=None, litellm_params=None):
|
||||
def _search(config, client=None, query="what is litellm", optional_params=None, litellm_params=None, executor=None):
|
||||
return config.execute_search_vector_store_request(
|
||||
vector_store_id="my_index",
|
||||
query=query,
|
||||
vector_store_search_optional_params=optional_params or {},
|
||||
litellm_logging_obj=MagicMock(),
|
||||
litellm_params={"litellm_embedding_model": "openai/text-embedding-3-small", **(litellm_params or {})},
|
||||
embedding_executor=executor,
|
||||
)
|
||||
|
||||
|
||||
def test_sync_search_uses_request_embedding_executor_without_overwriting_explicit_config():
|
||||
executor = FakeEmbeddingExecutor([0.1, 0.2])
|
||||
config = ValkeyVectorStoreConfig(sync_client=FakeRedis())
|
||||
embedding_config = {"api_key": "store-specific-key", "aws_region_name": "us-west-2"}
|
||||
|
||||
_search(
|
||||
config,
|
||||
litellm_params={
|
||||
"litellm_embedding_model": "team-embedding-alias",
|
||||
"litellm_embedding_config": embedding_config,
|
||||
},
|
||||
executor=executor,
|
||||
)
|
||||
|
||||
assert executor.captured == ("team-embedding-alias", "what is litellm", embedding_config)
|
||||
|
||||
|
||||
def test_sync_search_builds_knn_query_with_packed_vector():
|
||||
embedding_fn = FakeEmbeddingFn([0.1, 0.2, 0.3])
|
||||
client = FakeRedis()
|
||||
|
|
|
|||
|
|
@ -2,29 +2,24 @@ from datetime import datetime, timezone
|
|||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
import litellm
|
||||
from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import (
|
||||
LiteLLM_ManagedVectorStore,
|
||||
)
|
||||
from litellm.llms.base_llm.vector_store.transformation import (
|
||||
LiteLLMVectorStoreEmbeddingExecutor,
|
||||
RouterVectorStoreEmbeddingExecutor,
|
||||
)
|
||||
from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.vector_store_endpoints.endpoints import (
|
||||
_update_request_data_with_litellm_managed_vector_store_registry,
|
||||
index_create,
|
||||
index_list,
|
||||
)
|
||||
from litellm.proxy.vector_store_files_endpoints.endpoints import (
|
||||
_update_request_data_with_model_routing_hint,
|
||||
)
|
||||
from litellm.proxy.vector_store_endpoints.management_endpoints import (
|
||||
_check_vector_store_access,
|
||||
_resolve_embedding_config,
|
||||
_resolve_embedding_config_from_db,
|
||||
_resolve_embedding_config_from_router,
|
||||
create_vector_store_in_db,
|
||||
new_vector_store,
|
||||
)
|
||||
|
|
@ -33,8 +28,12 @@ from litellm.proxy.vector_store_endpoints.utils import (
|
|||
is_allowed_to_call_vector_store_endpoint,
|
||||
is_allowed_to_call_vector_store_files_endpoint,
|
||||
)
|
||||
from litellm.proxy.vector_store_files_endpoints.endpoints import (
|
||||
_update_request_data_with_model_routing_hint,
|
||||
)
|
||||
from litellm.types.utils import EmbeddingResponse, LlmProviders
|
||||
from litellm.types.vector_stores import IndexCreateRequest, IndexListResponse
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.vector_stores.main import _direct_vector_store_embedding_executor
|
||||
|
||||
|
||||
def _serialize_litellm_params(litellm_params):
|
||||
|
|
@ -51,17 +50,113 @@ def _serialize_litellm_params(litellm_params):
|
|||
return json.dumps(litellm_params or {})
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_embedding_config_cache():
|
||||
"""The use-time embedding-config resolver caches results in process
|
||||
memory across calls. Reset it before every test so the resolver
|
||||
actually exercises the router/DB path under test instead of returning
|
||||
a value cached by an earlier test."""
|
||||
from litellm.proxy.vector_store_endpoints import management_endpoints
|
||||
def test_direct_vector_store_embedding_executor_rejects_invalid_value():
|
||||
with pytest.raises(TypeError, match="Invalid direct vector store embedding executor"):
|
||||
_direct_vector_store_embedding_executor(object(), None, {})
|
||||
|
||||
management_endpoints._embedding_config_cache = None
|
||||
yield
|
||||
management_endpoints._embedding_config_cache = None
|
||||
|
||||
def test_router_vector_store_search_injects_executor_and_request_metadata():
|
||||
router = litellm.Router(model_list=[])
|
||||
original = MagicMock(return_value="searched")
|
||||
wrapped = router.factory_function(original, call_type="vector_store_search")
|
||||
|
||||
assert (
|
||||
wrapped(
|
||||
vector_store_id="store",
|
||||
query="query",
|
||||
custom_llm_provider="valkey",
|
||||
litellm_metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
== "searched"
|
||||
)
|
||||
|
||||
call_kwargs = original.call_args.kwargs
|
||||
assert call_kwargs["custom_llm_provider"] == "valkey"
|
||||
executor = call_kwargs["_direct_vector_store_embedding_executor"]
|
||||
assert isinstance(executor, RouterVectorStoreEmbeddingExecutor)
|
||||
assert executor.metadata == {"user_api_key_team_id": "team-a"}
|
||||
assert litellm.Router._vector_store_request_metadata({"metadata": {"user_api_key_team_id": "team-b"}}) == {
|
||||
"user_api_key_team_id": "team-b"
|
||||
}
|
||||
assert litellm.Router._vector_store_request_metadata({}) == {}
|
||||
|
||||
with patch.object( # test-quality-ok: fallback dispatch is the boundary this wrapper delegates to
|
||||
router, "_generic_api_call_with_fallbacks", return_value="routed"
|
||||
) as fallback:
|
||||
assert wrapped(model="vector-alias", vector_store_id="store", query="query") == "routed"
|
||||
assert fallback.call_args.kwargs["model"] == "vector-alias"
|
||||
assert fallback.call_args.kwargs["original_function"] is original
|
||||
|
||||
create_original = MagicMock(return_value="created")
|
||||
wrapped_create = router.factory_function(create_original, call_type="vector_store_create")
|
||||
assert wrapped_create(name="store") == "created"
|
||||
create_original.assert_called_once_with(name="store")
|
||||
with patch.object( # test-quality-ok: fallback dispatch is the boundary this wrapper delegates to
|
||||
router, "_generic_api_call_with_fallbacks", return_value="created-through-router"
|
||||
) as fallback:
|
||||
assert wrapped_create(model="vector-alias", name="store") == "created-through-router"
|
||||
fallback.assert_called_once_with(original_function=create_original, model="vector-alias", name="store")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_store_embedding_executors_preserve_explicit_configuration():
|
||||
response = EmbeddingResponse(data=[{"embedding": [0.1], "index": 0, "object": "embedding"}])
|
||||
sdk_executor = LiteLLMVectorStoreEmbeddingExecutor()
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: isolates SDK dispatch from external embedding providers
|
||||
"litellm.embedding", return_value=response
|
||||
) as embedding,
|
||||
patch( # test-quality-ok: isolates async SDK dispatch from external embedding providers
|
||||
"litellm.aembedding", new=AsyncMock(return_value=response)
|
||||
) as aembedding,
|
||||
):
|
||||
assert sdk_executor.embed("openai/model", "sync", {"api_key": "explicit"}) is response
|
||||
assert await sdk_executor.aembed("openai/model", "async", {"api_key": "explicit"}) is response
|
||||
|
||||
embedding.assert_called_once_with(model="openai/model", input=["sync"], api_key="explicit")
|
||||
aembedding.assert_awaited_once_with(model="openai/model", input=["async"], api_key="explicit")
|
||||
|
||||
mock_router = MagicMock()
|
||||
mock_router.embedding.return_value = response
|
||||
mock_router.aembedding = AsyncMock(return_value=response)
|
||||
router_executor = RouterVectorStoreEmbeddingExecutor(
|
||||
router=mock_router,
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
|
||||
assert router_executor.embed("team-alias", "query", {}) is response
|
||||
mock_router.embedding.assert_called_once_with(
|
||||
model="team-alias",
|
||||
input=["query"],
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: verifies explicit store configuration at the SDK boundary
|
||||
"litellm.embedding", return_value=response
|
||||
) as explicit_embedding,
|
||||
patch( # test-quality-ok: verifies async explicit store configuration at the SDK boundary
|
||||
"litellm.aembedding", new=AsyncMock(return_value=response)
|
||||
) as explicit_aembedding,
|
||||
):
|
||||
assert router_executor.embed("openai/model", "query", {"api_key": "store-key"}) is response
|
||||
assert await router_executor.aembed("openai/model", "query", {"api_key": "store-key"}) is response
|
||||
|
||||
explicit_embedding.assert_not_called()
|
||||
explicit_aembedding.assert_not_awaited()
|
||||
assert mock_router.embedding.call_args.kwargs == {
|
||||
"model": "openai/model",
|
||||
"input": ["query"],
|
||||
"api_key": "store-key",
|
||||
"metadata": {"user_api_key_team_id": "team-a"},
|
||||
}
|
||||
mock_router.aembedding.assert_awaited_once_with(
|
||||
model="openai/model",
|
||||
input=["query"],
|
||||
api_key="store-key",
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -82,10 +177,11 @@ async def test_router_avector_store_search_passes_correct_args():
|
|||
}
|
||||
|
||||
# Call router's avector_store_search
|
||||
result = await router.avector_store_search(
|
||||
await router.avector_store_search(
|
||||
vector_store_id="test_store_id",
|
||||
query="test query",
|
||||
custom_llm_provider="bedrock",
|
||||
metadata={"user_api_key_team_id": "team-a"},
|
||||
)
|
||||
|
||||
# Verify the internal method was called with correct args
|
||||
|
|
@ -96,6 +192,38 @@ async def test_router_avector_store_search_passes_correct_args():
|
|||
assert call_args[1]["vector_store_id"] == "test_store_id"
|
||||
assert call_args[1]["query"] == "test query"
|
||||
assert call_args[1]["custom_llm_provider"] == "bedrock"
|
||||
executor = call_args[1]["_direct_vector_store_embedding_executor"]
|
||||
assert isinstance(executor, RouterVectorStoreEmbeddingExecutor)
|
||||
assert executor.metadata["user_api_key_team_id"] == "team-a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_vector_store_embedding_executor_uses_team_scoped_router_deployment():
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "shared-embedding",
|
||||
"litellm_params": {"model": "openai/text-embedding-3-small", "api_key": "team-a-key"},
|
||||
"model_info": {"team_id": "team-a", "team_public_model_name": "shared-embedding"},
|
||||
},
|
||||
{
|
||||
"model_name": "shared-embedding",
|
||||
"litellm_params": {"model": "openai/text-embedding-3-small", "api_key": "team-b-key"},
|
||||
"model_info": {"team_id": "team-b", "team_public_model_name": "shared-embedding"},
|
||||
},
|
||||
]
|
||||
)
|
||||
executor = RouterVectorStoreEmbeddingExecutor(
|
||||
router=router,
|
||||
metadata={"user_api_key_team_id": "team-b"},
|
||||
)
|
||||
response = EmbeddingResponse(data=[{"embedding": [0.1], "index": 0, "object": "embedding"}])
|
||||
|
||||
with patch("litellm.aembedding", new=AsyncMock(return_value=response)) as mock_aembedding:
|
||||
result = await executor.aembed("shared-embedding", "query", {})
|
||||
|
||||
assert result is response
|
||||
assert mock_aembedding.await_args.kwargs["api_key"] == "team-b-key"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -502,91 +630,30 @@ async def test_update_request_data_with_litellm_managed_vector_store_registry():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_request_data_resolves_embedding_config_at_use_time():
|
||||
"""When the persisted vector store row carries only a
|
||||
``litellm_embedding_model`` reference (the new behaviour after
|
||||
moving the auto-resolve out of write time), the request-handling
|
||||
layer must resolve the embedding config so the downstream embed
|
||||
call still has ``api_key`` / ``api_base`` / ``api_version``. The
|
||||
resolved config lives in this per-request data dict only — never
|
||||
persisted."""
|
||||
mock_vector_store: LiteLLM_ManagedVectorStore = {
|
||||
async def test_managed_vector_store_keeps_embedding_reference_and_explicit_config():
|
||||
explicit_config = {"api_key": "store-specific-key", "api_base": "https://embedding.example"}
|
||||
managed_vector_store: LiteLLM_ManagedVectorStore = {
|
||||
"vector_store_id": "test_store",
|
||||
"custom_llm_provider": "azure_ai",
|
||||
"custom_llm_provider": "valkey",
|
||||
"litellm_params": {
|
||||
"litellm_embedding_model": "azure/text-embedding-3-large",
|
||||
# Note: no litellm_embedding_config persisted
|
||||
"litellm_embedding_model": "team-embedding-alias",
|
||||
"litellm_embedding_config": explicit_config,
|
||||
},
|
||||
}
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = (
|
||||
mock_vector_store
|
||||
)
|
||||
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = managed_vector_store
|
||||
|
||||
resolved = {
|
||||
"api_key": "use-time-resolved-key",
|
||||
"api_base": "https://my-azure.example",
|
||||
"api_version": "2024-09-01",
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(litellm, "vector_store_registry", mock_registry),
|
||||
patch(
|
||||
"litellm.proxy.vector_store_endpoints.endpoints._resolve_embedding_config",
|
||||
new=AsyncMock(return_value=resolved),
|
||||
),
|
||||
):
|
||||
with patch.object(litellm, "vector_store_registry", mock_registry):
|
||||
result = await _update_request_data_with_litellm_managed_vector_store_registry(
|
||||
data={}, vector_store_id="test_store"
|
||||
data={},
|
||||
vector_store_id="test_store",
|
||||
)
|
||||
|
||||
assert result["litellm_embedding_model"] == "azure/text-embedding-3-large"
|
||||
assert result["litellm_embedding_config"] == resolved
|
||||
assert result["litellm_embedding_model"] == "team-embedding-alias"
|
||||
assert result["litellm_embedding_config"] == explicit_config
|
||||
assert managed_vector_store["litellm_params"]["litellm_embedding_config"] == explicit_config
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_request_data_passes_through_legacy_embedding_config():
|
||||
"""A vector store row created by an older proxy version may already
|
||||
carry a fully-resolved ``litellm_embedding_config`` in its persisted
|
||||
``litellm_params`` (the very leak this PR closes). Those legacy rows
|
||||
must still work — the use-time resolver skips re-resolution when
|
||||
the config is already present so the embed call keeps succeeding."""
|
||||
legacy_config = {
|
||||
"api_key": "legacy-cleartext-key",
|
||||
"api_base": "https://legacy-azure.example",
|
||||
"api_version": "2024-01-01",
|
||||
}
|
||||
mock_vector_store: LiteLLM_ManagedVectorStore = {
|
||||
"vector_store_id": "legacy_store",
|
||||
"custom_llm_provider": "azure_ai",
|
||||
"litellm_params": {
|
||||
"litellm_embedding_model": "azure/text-embedding-3-large",
|
||||
"litellm_embedding_config": legacy_config,
|
||||
},
|
||||
}
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = (
|
||||
mock_vector_store
|
||||
)
|
||||
|
||||
resolve_mock = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(litellm, "vector_store_registry", mock_registry),
|
||||
patch(
|
||||
"litellm.proxy.vector_store_endpoints.endpoints._resolve_embedding_config",
|
||||
new=resolve_mock,
|
||||
),
|
||||
):
|
||||
result = await _update_request_data_with_litellm_managed_vector_store_registry(
|
||||
data={}, vector_store_id="legacy_store"
|
||||
)
|
||||
|
||||
assert result["litellm_embedding_config"] == legacy_config
|
||||
resolve_mock.assert_not_awaited()
|
||||
|
||||
|
||||
class TestCheckVectorStorePermission:
|
||||
"""Test suite for check_vector_store_permission function."""
|
||||
|
|
@ -2003,57 +2070,7 @@ async def test_vector_store_update_and_list_synchronization():
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_embedding_config_from_db():
|
||||
"""Test that _resolve_embedding_config_from_db correctly resolves embedding config from database."""
|
||||
mock_prisma_client = MagicMock()
|
||||
|
||||
# Mock database model with litellm_params
|
||||
mock_db_model = MagicMock()
|
||||
mock_db_model.litellm_params = {
|
||||
"api_key": "test-api-key",
|
||||
"api_base": "https://api.openai.com",
|
||||
"api_version": "2024-01-01",
|
||||
}
|
||||
|
||||
mock_prisma_client.db.litellm_proxymodeltable.find_first = AsyncMock(
|
||||
return_value=mock_db_model
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.vector_store_endpoints.management_endpoints.decrypt_value_helper",
|
||||
side_effect=lambda value, key, return_original_value: value,
|
||||
):
|
||||
result = await _resolve_embedding_config_from_db(
|
||||
embedding_model="text-embedding-ada-002", prisma_client=mock_prisma_client
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["api_key"] == "test-api-key"
|
||||
assert result["api_base"] == "https://api.openai.com"
|
||||
assert result["api_version"] == "2024-01-01"
|
||||
mock_prisma_client.db.litellm_proxymodeltable.find_first.assert_called_once_with(
|
||||
where={"model_name": "text-embedding-ada-002"}
|
||||
)
|
||||
|
||||
# Test with empty embedding_model
|
||||
result_empty = await _resolve_embedding_config_from_db(
|
||||
embedding_model="", prisma_client=mock_prisma_client
|
||||
)
|
||||
assert result_empty is None
|
||||
|
||||
# Test with model not found
|
||||
mock_prisma_client.db.litellm_proxymodeltable.find_first = AsyncMock(
|
||||
return_value=None
|
||||
)
|
||||
result_not_found = await _resolve_embedding_config_from_db(
|
||||
embedding_model="non-existent-model", prisma_client=mock_prisma_client
|
||||
)
|
||||
assert result_not_found is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_vector_store_auto_resolves_embedding_config():
|
||||
"""Test that new_vector_store auto-resolves embedding config when embedding_model is provided but config is not."""
|
||||
async def test_new_vector_store_persists_embedding_reference_without_credentials():
|
||||
import json
|
||||
|
||||
from litellm.types.vector_stores import LiteLLM_ManagedVectorStore
|
||||
|
|
@ -2070,14 +2087,6 @@ async def test_new_vector_store_auto_resolves_embedding_config():
|
|||
},
|
||||
}
|
||||
|
||||
# Mock database model lookup for embedding config resolution
|
||||
mock_db_model = MagicMock()
|
||||
mock_db_model.litellm_params = {
|
||||
"api_key": "resolved-api-key",
|
||||
"api_base": "https://api.openai.com",
|
||||
"api_version": "2024-01-01",
|
||||
}
|
||||
|
||||
# Mock user API key
|
||||
mock_user_api_key = MagicMock(spec=UserAPIKeyAuth)
|
||||
mock_user_api_key.user_role = None
|
||||
|
|
@ -2088,10 +2097,6 @@ async def test_new_vector_store_auto_resolves_embedding_config():
|
|||
mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(
|
||||
return_value=None # Vector store doesn't exist yet
|
||||
)
|
||||
mock_prisma_client.db.litellm_proxymodeltable.find_first = AsyncMock(
|
||||
return_value=mock_db_model
|
||||
)
|
||||
|
||||
# Track what was passed to create
|
||||
captured_create_data = {}
|
||||
|
||||
|
|
@ -2112,261 +2117,21 @@ async def test_new_vector_store_auto_resolves_embedding_config():
|
|||
mock_registry = MagicMock()
|
||||
mock_registry.add_vector_store_to_registry = MagicMock()
|
||||
|
||||
# Mock router to return None (so it falls back to DB resolution)
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_deployment_by_model_group_name.return_value = None
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
|
||||
patch("litellm.proxy.proxy_server.llm_router", mock_router),
|
||||
patch(
|
||||
"litellm.proxy.vector_store_endpoints.management_endpoints.decrypt_value_helper",
|
||||
side_effect=lambda value, key, return_original_value: value,
|
||||
),
|
||||
patch.object(litellm, "vector_store_registry", mock_registry),
|
||||
):
|
||||
result = await new_vector_store(
|
||||
vector_store=vector_store_data, user_api_key_dict=mock_user_api_key
|
||||
)
|
||||
result = await new_vector_store(vector_store=vector_store_data, user_api_key_dict=mock_user_api_key)
|
||||
|
||||
assert result["status"] == "success"
|
||||
# Auto-resolve no longer happens at create time — the persisted row
|
||||
# carries only the model reference, never the resolved cleartext
|
||||
# credential. Resolution now happens at request-handling time inside
|
||||
# ``_update_request_data_with_litellm_managed_vector_store_registry``,
|
||||
# where the resolved config lives in per-request memory and is never
|
||||
# written to the database.
|
||||
litellm_params_json = captured_create_data.get("litellm_params")
|
||||
assert litellm_params_json is not None
|
||||
litellm_params_dict = json.loads(litellm_params_json)
|
||||
assert "litellm_embedding_config" not in litellm_params_dict
|
||||
assert litellm_params_dict["litellm_embedding_model"] == "text-embedding-ada-002"
|
||||
|
||||
# The response must also not echo a cleartext credential — even on
|
||||
# the create response, where redaction guards against caller-supplied
|
||||
# cleartext or pre-existing rows that were created by an earlier
|
||||
# proxy version.
|
||||
response_vs = result["vector_store"]
|
||||
assert "resolved-api-key" not in _serialize_litellm_params(
|
||||
response_vs.get("litellm_params")
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_embedding_config_from_router():
|
||||
"""Test that _resolve_embedding_config_from_router correctly extracts credentials from config-defined models."""
|
||||
from litellm.types.router import Deployment, LiteLLM_Params
|
||||
|
||||
# Create a mock router with a model
|
||||
mock_router = MagicMock()
|
||||
|
||||
# Create a mock deployment with litellm_params
|
||||
mock_litellm_params = MagicMock(spec=LiteLLM_Params)
|
||||
mock_litellm_params.api_key = "config-api-key"
|
||||
mock_litellm_params.api_base = "https://config-api-base.com"
|
||||
mock_litellm_params.api_version = "2024-02-01"
|
||||
|
||||
mock_deployment = MagicMock(spec=Deployment)
|
||||
mock_deployment.litellm_params = mock_litellm_params
|
||||
|
||||
mock_router.get_deployment_by_model_group_name.return_value = mock_deployment
|
||||
|
||||
# Test resolution
|
||||
result = _resolve_embedding_config_from_router(
|
||||
embedding_model="text-embedding-ada-002", llm_router=mock_router
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["api_key"] == "config-api-key"
|
||||
assert result["api_base"] == "https://config-api-base.com"
|
||||
assert result["api_version"] == "2024-02-01"
|
||||
|
||||
mock_router.get_deployment_by_model_group_name.assert_called_once_with(
|
||||
model_group_name="text-embedding-ada-002"
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_embedding_config_from_router_with_provider_prefix():
|
||||
"""Test that _resolve_embedding_config_from_router handles provider prefixes like 'azure/model-name'."""
|
||||
from litellm.types.router import Deployment, LiteLLM_Params
|
||||
|
||||
# Create a mock router
|
||||
mock_router = MagicMock()
|
||||
|
||||
# Create a mock deployment
|
||||
mock_litellm_params = MagicMock(spec=LiteLLM_Params)
|
||||
mock_litellm_params.api_key = "azure-api-key"
|
||||
mock_litellm_params.api_base = "https://azure-endpoint.openai.azure.com"
|
||||
mock_litellm_params.api_version = "2024-02-15"
|
||||
|
||||
mock_deployment = MagicMock(spec=Deployment)
|
||||
mock_deployment.litellm_params = mock_litellm_params
|
||||
|
||||
# First call with full name returns None, second call with stripped name returns deployment
|
||||
mock_router.get_deployment_by_model_group_name.side_effect = [None, mock_deployment]
|
||||
|
||||
result = _resolve_embedding_config_from_router(
|
||||
embedding_model="azure/text-embedding-3-large", llm_router=mock_router
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["api_key"] == "azure-api-key"
|
||||
assert result["api_base"] == "https://azure-endpoint.openai.azure.com"
|
||||
assert result["api_version"] == "2024-02-15"
|
||||
|
||||
# Should have tried both the full name and stripped name
|
||||
assert mock_router.get_deployment_by_model_group_name.call_count == 2
|
||||
|
||||
|
||||
def test_resolve_embedding_config_from_router_returns_none_when_not_found():
|
||||
"""Test that _resolve_embedding_config_from_router returns None when model is not in router."""
|
||||
mock_router = MagicMock()
|
||||
mock_router.get_deployment_by_model_group_name.return_value = None
|
||||
|
||||
result = _resolve_embedding_config_from_router(
|
||||
embedding_model="nonexistent-model", llm_router=mock_router
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_resolve_embedding_config_from_router_handles_os_environ():
|
||||
"""Test that _resolve_embedding_config_from_router handles os.environ/ prefixed values."""
|
||||
from litellm.types.router import Deployment, LiteLLM_Params
|
||||
|
||||
mock_router = MagicMock()
|
||||
|
||||
mock_litellm_params = MagicMock(spec=LiteLLM_Params)
|
||||
mock_litellm_params.api_key = "os.environ/OPENAI_API_KEY"
|
||||
mock_litellm_params.api_base = "https://direct-url.com"
|
||||
mock_litellm_params.api_version = None
|
||||
|
||||
mock_deployment = MagicMock(spec=Deployment)
|
||||
mock_deployment.litellm_params = mock_litellm_params
|
||||
|
||||
mock_router.get_deployment_by_model_group_name.return_value = mock_deployment
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.vector_store_endpoints.management_endpoints.get_secret",
|
||||
return_value="resolved-from-env",
|
||||
) as mock_get_secret:
|
||||
result = _resolve_embedding_config_from_router(
|
||||
embedding_model="text-embedding-ada-002", llm_router=mock_router
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["api_key"] == "resolved-from-env"
|
||||
assert result["api_base"] == "https://direct-url.com"
|
||||
assert "api_version" not in result
|
||||
|
||||
mock_get_secret.assert_called_once_with("os.environ/OPENAI_API_KEY")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_embedding_config_tries_router_then_db():
|
||||
"""Test that _resolve_embedding_config tries router first, then falls back to DB."""
|
||||
from litellm.types.router import Deployment, LiteLLM_Params
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_router = MagicMock()
|
||||
|
||||
# Router has the model
|
||||
mock_litellm_params = MagicMock(spec=LiteLLM_Params)
|
||||
mock_litellm_params.api_key = "router-api-key"
|
||||
mock_litellm_params.api_base = "https://router-api-base.com"
|
||||
mock_litellm_params.api_version = None
|
||||
|
||||
mock_deployment = MagicMock(spec=Deployment)
|
||||
mock_deployment.litellm_params = mock_litellm_params
|
||||
|
||||
mock_router.get_deployment_by_model_group_name.return_value = mock_deployment
|
||||
|
||||
# DB should NOT be called since router has the model
|
||||
mock_prisma_client.db.litellm_proxymodeltable.find_first = AsyncMock()
|
||||
|
||||
result = await _resolve_embedding_config(
|
||||
embedding_model="text-embedding-ada-002",
|
||||
prisma_client=mock_prisma_client,
|
||||
llm_router=mock_router,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["api_key"] == "router-api-key"
|
||||
|
||||
# DB should NOT have been called since router found the model
|
||||
mock_prisma_client.db.litellm_proxymodeltable.find_first.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_embedding_config_caches_result():
|
||||
"""The first lookup should hit the router/DB; subsequent lookups for
|
||||
the same model name should return the cached value without touching
|
||||
the router or the database."""
|
||||
from litellm.types.router import Deployment, LiteLLM_Params
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_router = MagicMock()
|
||||
|
||||
mock_litellm_params = MagicMock(spec=LiteLLM_Params)
|
||||
mock_litellm_params.api_key = "router-api-key"
|
||||
mock_litellm_params.api_base = "https://router-api-base.com"
|
||||
mock_litellm_params.api_version = None
|
||||
|
||||
mock_deployment = MagicMock(spec=Deployment)
|
||||
mock_deployment.litellm_params = mock_litellm_params
|
||||
mock_router.get_deployment_by_model_group_name.return_value = mock_deployment
|
||||
|
||||
first = await _resolve_embedding_config(
|
||||
embedding_model="cached-model",
|
||||
prisma_client=mock_prisma_client,
|
||||
llm_router=mock_router,
|
||||
)
|
||||
assert first is not None
|
||||
assert mock_router.get_deployment_by_model_group_name.call_count == 1
|
||||
|
||||
second = await _resolve_embedding_config(
|
||||
embedding_model="cached-model",
|
||||
prisma_client=mock_prisma_client,
|
||||
llm_router=mock_router,
|
||||
)
|
||||
assert second == first
|
||||
# Router (and by extension the DB) was not consulted again.
|
||||
assert mock_router.get_deployment_by_model_group_name.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_embedding_config_falls_back_to_db():
|
||||
"""Test that _resolve_embedding_config falls back to DB when router doesn't have the model."""
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_router = MagicMock()
|
||||
|
||||
# Router doesn't have the model
|
||||
mock_router.get_deployment_by_model_group_name.return_value = None
|
||||
|
||||
# DB has the model
|
||||
mock_db_model = MagicMock()
|
||||
mock_db_model.litellm_params = {
|
||||
"api_key": "db-api-key",
|
||||
"api_base": "https://db-api-base.com",
|
||||
}
|
||||
mock_prisma_client.db.litellm_proxymodeltable.find_first = AsyncMock(
|
||||
return_value=mock_db_model
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.vector_store_endpoints.management_endpoints.decrypt_value_helper",
|
||||
side_effect=lambda value, key, return_original_value: value,
|
||||
):
|
||||
result = await _resolve_embedding_config(
|
||||
embedding_model="text-embedding-ada-002",
|
||||
prisma_client=mock_prisma_client,
|
||||
llm_router=mock_router,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["api_key"] == "db-api-key"
|
||||
|
||||
# DB should have been called since router didn't find the model
|
||||
mock_prisma_client.db.litellm_proxymodeltable.find_first.assert_called()
|
||||
assert "api_key" not in _serialize_litellm_params(response_vs.get("litellm_params"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -2425,9 +2190,7 @@ async def test_new_vector_store_auto_resolves_from_router():
|
|||
}
|
||||
return mock_created_vector_store
|
||||
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(
|
||||
side_effect=mock_create
|
||||
)
|
||||
mock_prisma_client.db.litellm_managedvectorstorestable.create = AsyncMock(side_effect=mock_create)
|
||||
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.add_vector_store_to_registry = MagicMock()
|
||||
|
|
|
|||
|
|
@ -1928,6 +1928,19 @@ class TestToolTransformation:
|
|||
assert result_tool["function"]["parameters"]["type"] == "object"
|
||||
assert "properties" in result_tool["function"]["parameters"]
|
||||
|
||||
def test_transform_function_tools_parameters_keep_client_key_order(self):
|
||||
tools = [
|
||||
{"type": "function", "name": "a", "parameters": {"properties": {"arg": {"type": "string"}}, "required": ["arg"]}},
|
||||
{"type": "function", "name": "b", "parameters": {"type": "object", "properties": {}}},
|
||||
]
|
||||
|
||||
result_tools, _ = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(
|
||||
tools=tools
|
||||
)
|
||||
|
||||
assert list(result_tools[0]["function"]["parameters"]) == ["properties", "required", "type"]
|
||||
assert list(result_tools[1]["function"]["parameters"]) == ["type", "properties"]
|
||||
|
||||
def test_transform_function_tools_empty_parameters(self):
|
||||
"""Test that empty parameters get 'type': 'object' added"""
|
||||
function_tool = {
|
||||
|
|
|
|||
|
|
@ -614,6 +614,27 @@ async def test_run_async_fallback_forwards_attempted_model_groups_to_nested_call
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_async_fallback_can_target_the_requested_group_when_a_pre_router_replaced_it():
|
||||
"""The requested group was never called when a pre-router selected a tier, so a
|
||||
tier fallback may legitimately target that originally requested group."""
|
||||
router = RecordingRouter()
|
||||
|
||||
await run_async_fallback(
|
||||
litellm_router=router,
|
||||
fallback_model_group=["requested-model"],
|
||||
original_model_group="requested-model",
|
||||
original_exception=RuntimeError("selected tier failed"),
|
||||
max_fallbacks=3,
|
||||
fallback_depth=0,
|
||||
model="requested-model",
|
||||
metadata={"pre_routing_selected_model": "selected-tier"},
|
||||
)
|
||||
|
||||
assert router.received_kwargs["model"] == "requested-model"
|
||||
assert router.received_kwargs["attempted_targets"].keys == frozenset({"selected-tier", "requested-model"})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"entry",
|
||||
|
|
@ -1199,6 +1220,28 @@ class TestOrderedFallbackLookupGroups:
|
|||
assert fallback_lookup_groups({}, "smart-router") == ("smart-router",)
|
||||
assert fallback_lookup_groups({}, None) == ()
|
||||
|
||||
def test_session_remap_keeps_the_bound_router_between_tier_and_requested_group(self):
|
||||
from litellm.router_utils.fallback_event_handlers import (
|
||||
PRE_ROUTING_SELECTED_MODEL_KEY,
|
||||
fallback_lookup_groups,
|
||||
)
|
||||
|
||||
kwargs = {
|
||||
"litellm_metadata": {
|
||||
PRE_ROUTING_SELECTED_MODEL_KEY: "tier1",
|
||||
"model_group": "smart-router",
|
||||
}
|
||||
}
|
||||
|
||||
assert fallback_lookup_groups(kwargs, "requested-model") == (
|
||||
"tier1",
|
||||
"smart-router",
|
||||
"requested-model",
|
||||
)
|
||||
assert fallback_lookup_groups({"metadata": {"model_group": []}}, "requested-model") == (
|
||||
"requested-model",
|
||||
)
|
||||
|
||||
def test_first_resolving_group_wins_and_generic_idx_survives_a_miss(self):
|
||||
from litellm.router_utils.fallback_event_handlers import (
|
||||
get_fallback_model_group_for_lookup_groups,
|
||||
|
|
|
|||
|
|
@ -8579,6 +8579,398 @@ class TestConsumedRequestTagsStamp:
|
|||
assert CONSUMED_REQUEST_TAGS_METADATA_KEY not in request_kwargs["metadata"]
|
||||
|
||||
|
||||
class TestClaudeCodeSubagentSessionRouterBinding:
|
||||
class _RewriteStrategy:
|
||||
def __init__(self, routed_model: str = "cheap-model") -> None:
|
||||
self.routed_model = routed_model
|
||||
|
||||
async def async_pre_routing_hook(
|
||||
self, model, request_kwargs, messages=None, input=None, specific_deployment=False
|
||||
):
|
||||
from litellm.types.router import PreRoutingHookResponse
|
||||
|
||||
return PreRoutingHookResponse(
|
||||
model=self.routed_model,
|
||||
messages=messages,
|
||||
routing_decision={
|
||||
"router_model_name": "smart-router",
|
||||
"router_type": "complexity",
|
||||
"routed_model": self.routed_model,
|
||||
"cause": "heuristic_scorer",
|
||||
},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _router(
|
||||
cls,
|
||||
cheap_response: str = "cheap response",
|
||||
fallbacks: list[dict[str, list[str]]] | None = None,
|
||||
) -> "litellm.Router":
|
||||
from litellm.types.router import TaggedPreRoutingStrategy
|
||||
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "cheap-model",
|
||||
"litellm_params": {"model": "openai/gpt-4o-mini", "mock_response": cheap_response},
|
||||
},
|
||||
{
|
||||
"model_name": "expensive-model",
|
||||
"litellm_params": {"model": "openai/gpt-4o", "mock_response": "expensive response"},
|
||||
},
|
||||
],
|
||||
fallbacks=fallbacks,
|
||||
num_retries=0,
|
||||
)
|
||||
router.complexity_routers = {
|
||||
"smart-router": (TaggedPreRoutingStrategy(tags=(), strategy=cls._RewriteStrategy()),),
|
||||
"premium-router": (TaggedPreRoutingStrategy(tags=(), strategy=cls._RewriteStrategy("expensive-model")),),
|
||||
}
|
||||
return router
|
||||
|
||||
@staticmethod
|
||||
def _request_kwargs(
|
||||
*,
|
||||
key_hash: str = "key-hash-a",
|
||||
app: str = "cli",
|
||||
agent_id: str | None = None,
|
||||
fallback_depth: int | None = None,
|
||||
) -> dict:
|
||||
headers = {
|
||||
"X-Claude-Code-Session-Id": "session-1234",
|
||||
"x-app": app,
|
||||
**({"x-claude-code-agent-id": agent_id} if agent_id is not None else {}),
|
||||
}
|
||||
return {
|
||||
"metadata": {"user_api_key_hash": key_hash},
|
||||
"proxy_server_request": {"headers": headers},
|
||||
**({"fallback_depth": fallback_depth} if fallback_depth is not None else {}),
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_concrete_model_uses_the_main_sessions_router(self):
|
||||
router = self._router()
|
||||
|
||||
await router.acompletion(
|
||||
model="smart-router",
|
||||
messages=[{"role": "user", "content": "main turn"}],
|
||||
**self._request_kwargs(),
|
||||
)
|
||||
subagent_kwargs = self._request_kwargs(agent_id="agent-1234")
|
||||
|
||||
response = await router.acompletion(
|
||||
model="expensive-model",
|
||||
messages=[{"role": "user", "content": "subagent turn"}],
|
||||
**subagent_kwargs,
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "cheap response"
|
||||
assert subagent_kwargs["metadata"]["model_group"] == "smart-router"
|
||||
assert subagent_kwargs["metadata"]["routing_decision"]["router_model_name"] == "smart-router"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_main_thread_side_calls_to_a_plain_model_keep_the_session_router(self):
|
||||
router = self._router()
|
||||
|
||||
await router.async_pre_routing_hook(model="smart-router", request_kwargs=self._request_kwargs())
|
||||
await router.async_pre_routing_hook(model="expensive-model", request_kwargs=self._request_kwargs())
|
||||
|
||||
response = await router.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(agent_id="agent-1234"),
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.model == "cheap-model"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_cleanup_failure_does_not_reject_a_subagent_request(self):
|
||||
from litellm.caching.caching import RedisCache
|
||||
|
||||
router = self._router()
|
||||
del router.complexity_routers["smart-router"]
|
||||
redis_cache = MagicMock(spec=RedisCache)
|
||||
redis_cache.async_get_cache = AsyncMock(return_value="smart-router")
|
||||
redis_cache.async_delete_cache = AsyncMock(side_effect=ConnectionError("redis unavailable"))
|
||||
router._update_redis_cache(cache=redis_cache)
|
||||
|
||||
response = await router.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(agent_id="agent-1234"),
|
||||
)
|
||||
|
||||
assert response is None
|
||||
redis_cache.async_delete_cache.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_read_failure_does_not_reject_a_subagent_request(self):
|
||||
from litellm.caching.caching import RedisCache
|
||||
|
||||
router = self._router()
|
||||
request_kwargs = self._request_kwargs(agent_id="agent-1234")
|
||||
cache_key = router._claude_code_session_router_cache_key(request_kwargs)
|
||||
assert cache_key is not None
|
||||
await router._claude_code_session_router_cache.in_memory_cache.async_set_cache(
|
||||
cache_key,
|
||||
"smart-router",
|
||||
)
|
||||
redis_cache = MagicMock(spec=RedisCache)
|
||||
redis_cache.async_get_cache = AsyncMock(side_effect=Exception("Redis circuit breaker is open"))
|
||||
router._update_redis_cache(cache=redis_cache)
|
||||
|
||||
response = await router.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=request_kwargs,
|
||||
)
|
||||
|
||||
assert response is None
|
||||
assert "model_group" not in request_kwargs["metadata"]
|
||||
redis_cache.async_get_cache.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_write_failures_do_not_reject_main_or_subagent_requests(self):
|
||||
from litellm.caching.caching import RedisCache
|
||||
|
||||
router = self._router()
|
||||
redis_cache = MagicMock(spec=RedisCache)
|
||||
redis_cache.async_get_cache = AsyncMock(return_value="smart-router")
|
||||
redis_cache.async_set_cache = AsyncMock(side_effect=Exception("redis unavailable"))
|
||||
router._update_redis_cache(cache=redis_cache)
|
||||
|
||||
main_response = await router.async_pre_routing_hook(
|
||||
model="smart-router",
|
||||
request_kwargs=self._request_kwargs(),
|
||||
)
|
||||
subagent_response = await router.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(agent_id="agent-1234"),
|
||||
)
|
||||
|
||||
assert main_response is not None
|
||||
assert main_response.model == "cheap-model"
|
||||
assert subagent_response is not None
|
||||
assert subagent_response.model == "cheap-model"
|
||||
assert redis_cache.async_set_cache.await_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagents_follow_the_main_threads_latest_router_across_workers(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from litellm.caching.caching import RedisCache
|
||||
|
||||
shared_binding = SimpleNamespace(value=None)
|
||||
shared_redis = MagicMock(spec=RedisCache)
|
||||
shared_redis.async_get_cache = AsyncMock(side_effect=lambda key, **_: shared_binding.value)
|
||||
shared_redis.async_set_cache = AsyncMock(
|
||||
side_effect=lambda key, value, **_: setattr(shared_binding, "value", value)
|
||||
)
|
||||
main_worker, subagent_worker = self._router(), self._router()
|
||||
main_worker._update_redis_cache(cache=shared_redis)
|
||||
subagent_worker._update_redis_cache(cache=shared_redis)
|
||||
|
||||
await main_worker.async_pre_routing_hook(model="smart-router", request_kwargs=self._request_kwargs())
|
||||
first = await subagent_worker.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(agent_id="agent-1234"),
|
||||
)
|
||||
await main_worker.async_pre_routing_hook(model="premium-router", request_kwargs=self._request_kwargs())
|
||||
second = await subagent_worker.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(agent_id="agent-1234"),
|
||||
)
|
||||
|
||||
assert first is not None
|
||||
assert first.model == "cheap-model"
|
||||
assert second is not None
|
||||
assert second.model == "expensive-model"
|
||||
assert shared_binding.value == "premium-router"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_pre_routing_strategies_means_no_session_cache_traffic(self):
|
||||
from litellm.caching.caching import RedisCache
|
||||
|
||||
router = self._router()
|
||||
router.complexity_routers.clear()
|
||||
redis_cache = MagicMock(spec=RedisCache)
|
||||
redis_cache.async_get_cache = AsyncMock(return_value=None)
|
||||
redis_cache.async_set_cache = AsyncMock()
|
||||
redis_cache.async_delete_cache = AsyncMock()
|
||||
router._update_redis_cache(cache=redis_cache)
|
||||
|
||||
for request_kwargs in (self._request_kwargs(), self._request_kwargs(agent_id="agent-1234")):
|
||||
response = await router.async_pre_routing_hook(model="expensive-model", request_kwargs=request_kwargs)
|
||||
assert response is None
|
||||
|
||||
redis_cache.async_get_cache.assert_not_awaited()
|
||||
redis_cache.async_set_cache.assert_not_awaited()
|
||||
redis_cache.async_delete_cache.assert_not_awaited()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_bindings_do_not_evict_router_rate_limit_state(self):
|
||||
router = self._router()
|
||||
assert router._update_usage(deployment_id="deployment-id", parent_otel_span=None) == 1
|
||||
|
||||
for session_index in range(201):
|
||||
request_kwargs = self._request_kwargs()
|
||||
request_kwargs["proxy_server_request"]["headers"]["X-Claude-Code-Session-Id"] = (
|
||||
f"session-{session_index:04d}"
|
||||
)
|
||||
await router.async_pre_routing_hook(model="smart-router", request_kwargs=request_kwargs)
|
||||
|
||||
assert router._update_usage(deployment_id="deployment-id", parent_otel_span=None) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_background_and_fallback_requests_do_not_clear_the_session_router(self):
|
||||
router = self._router()
|
||||
|
||||
await router.async_pre_routing_hook(model="smart-router", request_kwargs=self._request_kwargs())
|
||||
await router.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(app="cli-bg"),
|
||||
)
|
||||
await router.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(fallback_depth=1),
|
||||
)
|
||||
|
||||
response = await router.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(agent_id="agent-1234"),
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.model == "cheap-model"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_fallback_does_not_reapply_the_session_router(self):
|
||||
router = self._router()
|
||||
|
||||
await router.async_pre_routing_hook(model="smart-router", request_kwargs=self._request_kwargs())
|
||||
|
||||
response = await router.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(agent_id="agent-1234", fallback_depth=1),
|
||||
)
|
||||
|
||||
assert response is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_can_fallback_to_its_original_requested_model(self):
|
||||
router = self._router(
|
||||
cheap_response="litellm.RateLimitError",
|
||||
fallbacks=[{"cheap-model": ["expensive-model"]}],
|
||||
)
|
||||
|
||||
await router.acompletion(
|
||||
model="smart-router",
|
||||
messages=[{"role": "user", "content": "main turn"}],
|
||||
**self._request_kwargs(),
|
||||
)
|
||||
subagent_kwargs = self._request_kwargs(agent_id="agent-1234")
|
||||
|
||||
response = await router.acompletion(
|
||||
model="expensive-model",
|
||||
messages=[{"role": "user", "content": "subagent turn"}],
|
||||
**subagent_kwargs,
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "expensive response"
|
||||
assert subagent_kwargs["metadata"]["routing_decision"]["routed_model"] == "cheap-model"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_can_use_the_bound_router_name_fallback(self):
|
||||
router = self._router(
|
||||
cheap_response="litellm.RateLimitError",
|
||||
fallbacks=[{"smart-router": ["expensive-model"]}],
|
||||
)
|
||||
|
||||
await router.acompletion(
|
||||
model="smart-router",
|
||||
messages=[{"role": "user", "content": "main turn"}],
|
||||
**self._request_kwargs(),
|
||||
)
|
||||
|
||||
response = await router.acompletion(
|
||||
model="expensive-model",
|
||||
messages=[{"role": "user", "content": "subagent turn"}],
|
||||
**self._request_kwargs(agent_id="agent-1234"),
|
||||
)
|
||||
|
||||
assert response.choices[0].message.content == "expensive response"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_anthropic_subagent_four_fallback_hops_use_each_current_model_chain(self):
|
||||
from litellm.types.router import TaggedPreRoutingStrategy
|
||||
|
||||
failing_groups = ("cheap-model", "fallback-1", "fallback-2", "fallback-3")
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
*(
|
||||
{
|
||||
"model_name": group,
|
||||
"litellm_params": {
|
||||
"model": "anthropic/claude-3-haiku-20240307",
|
||||
"mock_response": "litellm.RateLimitError",
|
||||
},
|
||||
}
|
||||
for group in failing_groups
|
||||
),
|
||||
{
|
||||
"model_name": "requested-model",
|
||||
"litellm_params": {
|
||||
"model": "anthropic/claude-3-haiku-20240307",
|
||||
"mock_response": "requested response",
|
||||
},
|
||||
},
|
||||
{
|
||||
"model_name": "fallback-4",
|
||||
"litellm_params": {
|
||||
"model": "anthropic/claude-3-haiku-20240307",
|
||||
"mock_response": "fourth fallback response",
|
||||
},
|
||||
},
|
||||
],
|
||||
fallbacks=[
|
||||
{"smart-router": ["fallback-1"]},
|
||||
{"fallback-1": ["fallback-2"]},
|
||||
{"fallback-2": ["fallback-3"]},
|
||||
{"fallback-3": ["fallback-4"]},
|
||||
],
|
||||
num_retries=0,
|
||||
max_fallbacks=4,
|
||||
)
|
||||
router.complexity_routers = {
|
||||
"smart-router": (TaggedPreRoutingStrategy(tags=(), strategy=self._RewriteStrategy()),)
|
||||
}
|
||||
main_kwargs = self._request_kwargs()
|
||||
main_kwargs["litellm_metadata"] = main_kwargs.pop("metadata")
|
||||
await router.async_pre_routing_hook(model="smart-router", request_kwargs=main_kwargs)
|
||||
subagent_kwargs = self._request_kwargs(agent_id="agent-1234")
|
||||
subagent_kwargs["litellm_metadata"] = subagent_kwargs.pop("metadata")
|
||||
|
||||
response = await router.aanthropic_messages(
|
||||
model="requested-model",
|
||||
messages=[{"role": "user", "content": "subagent turn"}],
|
||||
max_tokens=64,
|
||||
**subagent_kwargs,
|
||||
)
|
||||
|
||||
assert response["content"][0]["text"] == "fourth fallback response"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_router_binding_is_scoped_to_the_authenticated_key(self):
|
||||
router = self._router()
|
||||
|
||||
await router.async_pre_routing_hook(model="smart-router", request_kwargs=self._request_kwargs())
|
||||
|
||||
response = await router.async_pre_routing_hook(
|
||||
model="expensive-model",
|
||||
request_kwargs=self._request_kwargs(key_hash="key-hash-b", agent_id="agent-1234"),
|
||||
)
|
||||
|
||||
assert response is None
|
||||
|
||||
|
||||
class TestAutoRouterMaxInputCharsWiring:
|
||||
"""`auto_router_max_input_chars` on the deployment has to reach the AutoRouter that embeds prompts.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,19 @@
|
|||
import pytest
|
||||
import litellm
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm.llms.azure_ai.vector_stores.transformation import AzureAIVectorStoreConfig
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
from litellm.vector_stores import (
|
||||
asearch as vector_store_asearch,
|
||||
)
|
||||
from litellm.vector_stores import (
|
||||
search as vector_store_search,
|
||||
asearch as vector_store_asearch,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -30,10 +39,108 @@ async def test_basic_search_vector_store(sync_mode):
|
|||
if sync_mode:
|
||||
response = vector_store_search(query=default_query, **base_request_args)
|
||||
else:
|
||||
response = await vector_store_asearch(
|
||||
query=default_query, **base_request_args
|
||||
)
|
||||
response = await vector_store_asearch(query=default_query, **base_request_args)
|
||||
except litellm.InternalServerError:
|
||||
pytest.skip("Skipping test due to litellm.InternalServerError")
|
||||
|
||||
print("litellm response=", json.dumps(response, indent=4, default=str))
|
||||
|
||||
|
||||
class RecordingEmbeddingExecutor:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.calls = []
|
||||
|
||||
def embed(self, model, query, configuration):
|
||||
self.calls.append((model, query, dict(configuration)))
|
||||
return self.response
|
||||
|
||||
async def aembed(self, model, query, configuration):
|
||||
self.calls.append((model, query, dict(configuration)))
|
||||
return self.response
|
||||
|
||||
|
||||
ALIAS_QUERY_VECTOR = [0.5, -0.25, 0.125]
|
||||
ALIAS_EMBEDDING_RESPONSE = EmbeddingResponse(
|
||||
data=[{"embedding": ALIAS_QUERY_VECTOR, "index": 0, "object": "embedding"}]
|
||||
)
|
||||
STORE_EMBEDDINGS_URL = "https://embedding.example/v1/embeddings"
|
||||
|
||||
|
||||
def _transform_kwargs(executor):
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.model_call_details = {}
|
||||
return {
|
||||
"vector_store_id": "my-vector-index",
|
||||
"query": "what is azure search?",
|
||||
"vector_store_search_optional_params": {"top_k": 2},
|
||||
"api_base": "https://azure-kb-search.search.windows.net",
|
||||
"litellm_logging_obj": logging_obj,
|
||||
"litellm_params": {
|
||||
"litellm_embedding_model": "multilingual-e5-large",
|
||||
"azure_search_vector_field": "embedding",
|
||||
},
|
||||
"embedding_executor": executor,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transform_uses_injected_executor_without_embedding_config(respx_mock: respx.MockRouter):
|
||||
executor = RecordingEmbeddingExecutor(ALIAS_EMBEDDING_RESPONSE)
|
||||
config = AzureAIVectorStoreConfig()
|
||||
transform_kwargs = _transform_kwargs(executor)
|
||||
|
||||
url, sync_body = config.transform_search_vector_store_request(**transform_kwargs)
|
||||
_, async_body = await config.atransform_search_vector_store_request(**transform_kwargs)
|
||||
|
||||
assert respx_mock.calls.call_count == 0
|
||||
assert executor.calls == [("multilingual-e5-large", "what is azure search?", {})] * 2
|
||||
assert (
|
||||
url == "https://azure-kb-search.search.windows.net/indexes/my-vector-index/docs/search?api-version=2024-07-01"
|
||||
)
|
||||
assert sync_body == async_body
|
||||
assert sync_body["vectorQueries"] == [
|
||||
{"vector": ALIAS_QUERY_VECTOR, "fields": "embedding", "kind": "vector", "k": 2}
|
||||
]
|
||||
assert sync_body["top"] == 2
|
||||
logging_details = transform_kwargs["litellm_logging_obj"].model_call_details
|
||||
assert logging_details["embedding_model"] == "multilingual-e5-large"
|
||||
assert logging_details["top_k"] == 2
|
||||
|
||||
|
||||
def test_transform_falls_back_to_sdk_embedding_without_executor(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
embedding_route = respx_mock.post(STORE_EMBEDDINGS_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"object": "list",
|
||||
"data": [{"object": "embedding", "index": 0, "embedding": ALIAS_QUERY_VECTOR}],
|
||||
"model": "text-embedding-3-small",
|
||||
"usage": {"prompt_tokens": 2, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
)
|
||||
transform_kwargs = _transform_kwargs(None)
|
||||
transform_kwargs["litellm_params"] = {
|
||||
"litellm_embedding_model": "openai/text-embedding-3-small",
|
||||
"litellm_embedding_config": {"api_base": "https://embedding.example/v1", "api_key": "store-key"},
|
||||
}
|
||||
|
||||
_, body = AzureAIVectorStoreConfig().transform_search_vector_store_request(**transform_kwargs)
|
||||
|
||||
embedding_request = embedding_route.calls.last.request
|
||||
assert embedding_request.headers["authorization"] == "Bearer store-key"
|
||||
assert json.loads(embedding_request.read())["input"] == ["what is azure search?"]
|
||||
assert body["vectorQueries"][0]["vector"] == ALIAS_QUERY_VECTOR
|
||||
assert body["vectorQueries"][0]["fields"] == "contentVector"
|
||||
|
||||
|
||||
def test_transform_requires_embedding_model():
|
||||
transform_kwargs = _transform_kwargs(RecordingEmbeddingExecutor(ALIAS_EMBEDDING_RESPONSE))
|
||||
transform_kwargs["litellm_params"] = {"litellm_embedding_config": {"api_key": "store-key"}}
|
||||
|
||||
with pytest.raises(ValueError, match="litellm_embedding_model is required"):
|
||||
AzureAIVectorStoreConfig().transform_search_vector_store_request(**transform_kwargs)
|
||||
|
|
|
|||
|
|
@ -3,16 +3,19 @@ Tests for Milvus Vector Store
|
|||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
import litellm
|
||||
from litellm import Router
|
||||
from litellm.llms.milvus.vector_stores.transformation import MilvusVectorStoreConfig
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
from litellm.vector_stores import asearch as vector_store_asearch
|
||||
from litellm.vector_stores import search as vector_store_search
|
||||
|
||||
|
||||
# Mock response from actual Milvus API
|
||||
MOCK_MILVUS_SEARCH_RESPONSE = {
|
||||
"code": 0,
|
||||
|
|
@ -98,7 +101,7 @@ class TestMilvusVectorStore:
|
|||
mock_response.json.return_value = MOCK_MILVUS_SEARCH_RESPONSE
|
||||
mock_response.text = json.dumps(MOCK_MILVUS_SEARCH_RESPONSE)
|
||||
|
||||
with patch("litellm.embedding") as mock_embedding:
|
||||
with patch("litellm.aembedding", new_callable=AsyncMock) as mock_embedding:
|
||||
mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE
|
||||
|
||||
with patch(
|
||||
|
|
@ -147,16 +150,10 @@ class TestMilvusVectorStore:
|
|||
else:
|
||||
# Fallback: check for json kwarg or in args
|
||||
request_data = call_args.kwargs.get("json")
|
||||
if (
|
||||
request_data is None
|
||||
and len(call_args.args) > 0
|
||||
and isinstance(call_args.args[0], dict)
|
||||
):
|
||||
if request_data is None and len(call_args.args) > 0 and isinstance(call_args.args[0], dict):
|
||||
request_data = call_args.args[0]
|
||||
|
||||
assert (
|
||||
request_data is not None
|
||||
), f"Could not extract request data. Call args: {call_args}"
|
||||
assert request_data is not None, f"Could not extract request data. Call args: {call_args}"
|
||||
print("Request data:", json.dumps(request_data, indent=2, default=str))
|
||||
|
||||
# Validate request structure
|
||||
|
|
@ -213,9 +210,7 @@ class TestMilvusVectorStore:
|
|||
with patch("litellm.embedding") as mock_embedding:
|
||||
mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
|
||||
) as mock_post:
|
||||
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
# Make the search request
|
||||
|
|
@ -252,16 +247,10 @@ class TestMilvusVectorStore:
|
|||
else:
|
||||
# Fallback: check for json kwarg or in args
|
||||
request_data = call_args.kwargs.get("json")
|
||||
if (
|
||||
request_data is None
|
||||
and len(call_args.args) > 0
|
||||
and isinstance(call_args.args[0], dict)
|
||||
):
|
||||
if request_data is None and len(call_args.args) > 0 and isinstance(call_args.args[0], dict):
|
||||
request_data = call_args.args[0]
|
||||
|
||||
assert (
|
||||
request_data is not None
|
||||
), f"Could not extract request data. Call args: {call_args}"
|
||||
assert request_data is not None, f"Could not extract request data. Call args: {call_args}"
|
||||
|
||||
# Validate request structure
|
||||
assert "collectionName" in request_data
|
||||
|
|
@ -316,11 +305,7 @@ class TestMilvusVectorStore:
|
|||
if request_data_str:
|
||||
return json.loads(request_data_str)
|
||||
request_data = call_args.kwargs.get("json")
|
||||
if (
|
||||
request_data is None
|
||||
and len(call_args.args) > 0
|
||||
and isinstance(call_args.args[0], dict)
|
||||
):
|
||||
if request_data is None and len(call_args.args) > 0 and isinstance(call_args.args[0], dict):
|
||||
request_data = call_args.args[0]
|
||||
return request_data
|
||||
|
||||
|
|
@ -334,9 +319,7 @@ class TestMilvusVectorStore:
|
|||
with patch("litellm.embedding") as mock_embedding:
|
||||
mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
|
||||
) as mock_post:
|
||||
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
vector_store_search(
|
||||
|
|
@ -375,9 +358,7 @@ class TestMilvusVectorStore:
|
|||
with patch("litellm.embedding") as mock_embedding:
|
||||
mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
|
||||
) as mock_post:
|
||||
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
vector_store_search(
|
||||
|
|
@ -413,9 +394,7 @@ class TestMilvusVectorStore:
|
|||
with patch("litellm.embedding") as mock_embedding:
|
||||
mock_embedding.return_value = MOCK_EMBEDDING_RESPONSE
|
||||
|
||||
with patch(
|
||||
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
|
||||
) as mock_post:
|
||||
with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post:
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
vector_store_search(
|
||||
|
|
@ -492,3 +471,247 @@ if __name__ == "__main__":
|
|||
test.test_basic_search_with_mock_sync()
|
||||
|
||||
print("\n✅ All mock tests passed!")
|
||||
|
||||
|
||||
class RecordingEmbeddingExecutor:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.calls = []
|
||||
|
||||
def embed(self, model, query, configuration):
|
||||
self.calls.append((model, query, dict(configuration)))
|
||||
return self.response
|
||||
|
||||
async def aembed(self, model, query, configuration):
|
||||
self.calls.append((model, query, dict(configuration)))
|
||||
return self.response
|
||||
|
||||
|
||||
ALIAS_QUERY_VECTOR = [0.5, -0.25, 0.125]
|
||||
ALIAS_EMBEDDING_RESPONSE = EmbeddingResponse(
|
||||
data=[{"embedding": ALIAS_QUERY_VECTOR, "index": 0, "object": "embedding"}]
|
||||
)
|
||||
OPENAI_EMBEDDINGS_URL = "https://api.openai.com/v1/embeddings"
|
||||
MILVUS_SEARCH_URL = "https://milvus.example/v2/vectordb/entities/search"
|
||||
ALIAS_SEARCH_KWARGS = {
|
||||
"query": "what is machine learning?",
|
||||
"vector_store_id": "book_2",
|
||||
"custom_llm_provider": "milvus",
|
||||
"api_base": "https://milvus.example",
|
||||
"api_key": "mock_milvus_api_key",
|
||||
"litellm_embedding_model": "multilingual-e5-large",
|
||||
"milvus_text_field": "book_intro_text",
|
||||
}
|
||||
|
||||
|
||||
def _alias_router():
|
||||
return Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "multilingual-e5-large",
|
||||
"litellm_params": {
|
||||
"model": "openai/text-embedding-3-small",
|
||||
"api_key": "deployment-key",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _mock_embedding_route(respx_mock: respx.MockRouter) -> respx.Route:
|
||||
return respx_mock.post(OPENAI_EMBEDDINGS_URL).mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"object": "list",
|
||||
"data": [{"object": "embedding", "index": 0, "embedding": ALIAS_QUERY_VECTOR}],
|
||||
"model": "text-embedding-3-small",
|
||||
"usage": {"prompt_tokens": 2, "total_tokens": 2},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _mock_search_route(respx_mock: respx.MockRouter) -> respx.Route:
|
||||
return respx_mock.post(MILVUS_SEARCH_URL).mock(return_value=httpx.Response(200, json=MOCK_MILVUS_SEARCH_RESPONSE))
|
||||
|
||||
|
||||
def _assert_alias_resolved(embedding_route: respx.Route, search_route: respx.Route, response):
|
||||
embedding_request = embedding_route.calls.last.request
|
||||
assert embedding_request.headers["authorization"] == "Bearer deployment-key"
|
||||
embedding_body = json.loads(embedding_request.read())
|
||||
assert embedding_body["model"] == "text-embedding-3-small"
|
||||
assert embedding_body["input"] == ["what is machine learning?"]
|
||||
search_request = search_route.calls.last.request
|
||||
assert search_request.headers["authorization"] == "Bearer mock_milvus_api_key"
|
||||
assert json.loads(search_request.read())["data"] == [ALIAS_QUERY_VECTOR]
|
||||
assert len(response["data"]) == len(MOCK_MILVUS_SEARCH_RESPONSE["data"])
|
||||
assert response["data"][0]["content"][0]["text"] == MOCK_MILVUS_SEARCH_RESPONSE["data"][0]["book_intro_text"]
|
||||
|
||||
|
||||
def test_router_search_resolves_bare_embedding_alias_sync(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
embedding_route = _mock_embedding_route(respx_mock)
|
||||
search_route = _mock_search_route(respx_mock)
|
||||
|
||||
response = _alias_router().vector_store_search(**ALIAS_SEARCH_KWARGS)
|
||||
|
||||
_assert_alias_resolved(embedding_route, search_route, response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_router_search_resolves_bare_embedding_alias_async(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
embedding_route = _mock_embedding_route(respx_mock)
|
||||
search_route = _mock_search_route(respx_mock)
|
||||
|
||||
response = await _alias_router().avector_store_search(**ALIAS_SEARCH_KWARGS)
|
||||
|
||||
_assert_alias_resolved(embedding_route, search_route, response)
|
||||
|
||||
|
||||
def test_sdk_search_with_router_kwarg_resolves_bare_embedding_alias_sync(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
embedding_route = _mock_embedding_route(respx_mock)
|
||||
search_route = _mock_search_route(respx_mock)
|
||||
|
||||
response = litellm.vector_stores.search(router=_alias_router(), **ALIAS_SEARCH_KWARGS)
|
||||
|
||||
_assert_alias_resolved(embedding_route, search_route, response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_search_with_router_kwarg_resolves_bare_embedding_alias_async(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
embedding_route = _mock_embedding_route(respx_mock)
|
||||
search_route = _mock_search_route(respx_mock)
|
||||
|
||||
response = await litellm.vector_stores.asearch(router=_alias_router(), **ALIAS_SEARCH_KWARGS)
|
||||
|
||||
_assert_alias_resolved(embedding_route, search_route, response)
|
||||
|
||||
|
||||
def _team_alias_router():
|
||||
return Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "team-a-embedder",
|
||||
"litellm_params": {
|
||||
"model": "openai/text-embedding-3-small",
|
||||
"api_key": "deployment-key",
|
||||
},
|
||||
"model_info": {"team_id": "team-a", "team_public_model_name": "multilingual-e5-large"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_search_with_router_kwarg_resolves_team_alias_from_request_metadata(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
embedding_route = _mock_embedding_route(respx_mock)
|
||||
search_route = _mock_search_route(respx_mock)
|
||||
|
||||
response = await litellm.vector_stores.asearch(
|
||||
router=_team_alias_router(), metadata={"user_api_key_team_id": "team-a"}, **ALIAS_SEARCH_KWARGS
|
||||
)
|
||||
|
||||
_assert_alias_resolved(embedding_route, search_route, response)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sdk_search_with_router_kwarg_rejects_team_alias_without_team_metadata(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setattr(litellm, "disable_aiohttp_transport", True)
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
embedding_route = _mock_embedding_route(respx_mock)
|
||||
_mock_search_route(respx_mock)
|
||||
|
||||
with pytest.raises(litellm.APIConnectionError):
|
||||
await litellm.vector_stores.asearch(router=_team_alias_router(), **ALIAS_SEARCH_KWARGS)
|
||||
|
||||
assert embedding_route.call_count == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transform_uses_injected_executor_without_embedding_config(respx_mock: respx.MockRouter):
|
||||
executor = RecordingEmbeddingExecutor(ALIAS_EMBEDDING_RESPONSE)
|
||||
config = MilvusVectorStoreConfig()
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.model_call_details = {}
|
||||
transform_kwargs = {
|
||||
"vector_store_id": "book_2",
|
||||
"query": ["what is", "milvus?"],
|
||||
"vector_store_search_optional_params": {"limit": 3},
|
||||
"api_base": "https://milvus.example",
|
||||
"litellm_logging_obj": logging_obj,
|
||||
"litellm_params": {"litellm_embedding_model": "multilingual-e5-large", "milvus_db_name": "docs"},
|
||||
"embedding_executor": executor,
|
||||
}
|
||||
|
||||
url, sync_body = config.transform_search_vector_store_request(**transform_kwargs)
|
||||
_, async_body = await config.atransform_search_vector_store_request(**transform_kwargs)
|
||||
|
||||
assert respx_mock.calls.call_count == 0
|
||||
assert executor.calls == [("multilingual-e5-large", "what is milvus?", {})] * 2
|
||||
assert url == MILVUS_SEARCH_URL
|
||||
assert sync_body == async_body
|
||||
assert sync_body == {
|
||||
"collectionName": "book_2",
|
||||
"data": [ALIAS_QUERY_VECTOR],
|
||||
"annsField": "book_intro_vector",
|
||||
"limit": 3,
|
||||
"dbName": "docs",
|
||||
}
|
||||
assert logging_obj.model_call_details["input"] == "what is milvus?"
|
||||
assert logging_obj.model_call_details["embedding_model"] == "multilingual-e5-large"
|
||||
|
||||
|
||||
def test_transform_falls_back_to_sdk_embedding_without_executor_or_config(
|
||||
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "env-key")
|
||||
embedding_route = _mock_embedding_route(respx_mock)
|
||||
logging_obj = MagicMock()
|
||||
logging_obj.model_call_details = {}
|
||||
|
||||
_, body = MilvusVectorStoreConfig().transform_search_vector_store_request(
|
||||
vector_store_id="book_2",
|
||||
query="q",
|
||||
vector_store_search_optional_params={},
|
||||
api_base="https://milvus.example",
|
||||
litellm_logging_obj=logging_obj,
|
||||
litellm_params={"litellm_embedding_model": "openai/text-embedding-3-small"},
|
||||
)
|
||||
|
||||
embedding_request = embedding_route.calls.last.request
|
||||
assert embedding_request.headers["authorization"] == "Bearer env-key"
|
||||
assert json.loads(embedding_request.read())["input"] == ["q"]
|
||||
assert body["data"] == [ALIAS_QUERY_VECTOR]
|
||||
|
||||
|
||||
def test_transform_requires_embedding_model():
|
||||
with pytest.raises(ValueError, match="litellm_embedding_model is required"):
|
||||
MilvusVectorStoreConfig().transform_search_vector_store_request(
|
||||
vector_store_id="book_2",
|
||||
query="q",
|
||||
vector_store_search_optional_params={},
|
||||
api_base="https://milvus.example",
|
||||
litellm_logging_obj=MagicMock(),
|
||||
litellm_params={"litellm_embedding_config": {"api_key": "store-key"}},
|
||||
embedding_executor=RecordingEmbeddingExecutor(ALIAS_EMBEDDING_RESPONSE),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"LIT001": {
|
||||
"limit": 22358
|
||||
"limit": 22334
|
||||
},
|
||||
"LIT002": {
|
||||
"limit": 26771
|
||||
"limit": 26763
|
||||
},
|
||||
"LIT003": {
|
||||
"limit": 261
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
"limit": 0
|
||||
},
|
||||
"LIT006": {
|
||||
"limit": 1039
|
||||
"limit": 1038
|
||||
},
|
||||
"LIT007": {
|
||||
"limit": 0
|
||||
|
|
@ -27,12 +27,12 @@
|
|||
"limit": 0
|
||||
},
|
||||
"LIT010": {
|
||||
"limit": 16485
|
||||
"limit": 16480
|
||||
},
|
||||
"LIT011": {
|
||||
"limit": 5521
|
||||
"limit": 5520
|
||||
},
|
||||
"LIT012": {
|
||||
"limit": 4492
|
||||
"limit": 4489
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue