fa45d8aa5f
- health_checklist.json: 192.168.1.122→node122
- ocr_client.py: docstring IP→node122
- docs/market-data-requirements.md: IP→node122
- 所有API调用通过ProxyHandler({})绕过系统代理
Privoxy对node122:18003返回500,直连正常
463 lines
17 KiB
Python
463 lines
17 KiB
Python
import json
|
|
from typing import Any, Dict, List, Optional, Set, Tuple
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from litellm._logging import verbose_proxy_logger
|
|
from litellm.caching.in_memory_cache import InMemoryCache
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.common_utils.resource_ownership import (
|
|
get_primary_resource_owner_scope,
|
|
get_resource_owner_scopes,
|
|
is_proxy_admin,
|
|
user_can_access_resource_owner,
|
|
)
|
|
from litellm.repositories.table_repositories import ManagedObjectRepository
|
|
from litellm.responses.utils import ResponsesAPIRequestUtils
|
|
|
|
CONTAINER_OBJECT_PURPOSE = "container"
|
|
|
|
# 60s LRU/TTL cache absorbs every container access check before it reaches
|
|
# Prisma. ``_NEGATIVE_OWNER_SENTINEL`` lets us cache a true "untracked"
|
|
# answer so repeated misses also avoid the DB — ``InMemoryCache`` returns
|
|
# ``None`` indistinguishably for "miss" and "cached as None".
|
|
_NEGATIVE_OWNER_SENTINEL = "__litellm_container_no_owner__"
|
|
_CONTAINER_OWNER_CACHE = InMemoryCache(max_size_in_memory=10000, default_ttl=60)
|
|
|
|
# Caches the stored ``unified_object_id`` (the encoded container ID
|
|
# captured at create time) so ``get_container_forwarding_params`` can
|
|
# recover the deployment ``model_id`` for native upstream IDs without
|
|
# re-hitting Prisma on every retrieve/delete.
|
|
_NEGATIVE_STORED_ID_SENTINEL = "__litellm_container_no_stored_id__"
|
|
_CONTAINER_STORED_ID_CACHE = InMemoryCache(max_size_in_memory=10000, default_ttl=60)
|
|
|
|
# Per-caller-scope cache for ``GET /v1/containers`` list filtering. Without
|
|
# this, every list call issues a fresh ``find_many`` against
|
|
# ``litellm_managedobjecttable``. The cache key is the sorted owner-scope
|
|
# tuple — different keys for the same user share the same allow-set, but
|
|
# different users with different scopes get disjoint cache entries.
|
|
_ALLOWED_CONTAINER_IDS_CACHE = InMemoryCache(max_size_in_memory=2048, default_ttl=60)
|
|
|
|
|
|
def _allowed_container_ids_cache_key(owner_scopes: List[str]) -> str:
|
|
"""JSON-encode the sorted scope list — using a separator like ``|``
|
|
would collide for any tenant whose user_id / team_id / org_id /
|
|
api_key happens to contain the separator. JSON quoting escapes
|
|
every separator that matters."""
|
|
return json.dumps(sorted(owner_scopes))
|
|
|
|
|
|
def _container_model_object_id(
|
|
original_container_id: str, custom_llm_provider: str
|
|
) -> str:
|
|
return f"{CONTAINER_OBJECT_PURPOSE}:{custom_llm_provider}:{original_container_id}"
|
|
|
|
|
|
def decode_container_id_for_ownership(
|
|
container_id: str, custom_llm_provider: str
|
|
) -> Tuple[str, str]:
|
|
decoded = ResponsesAPIRequestUtils._decode_container_id(container_id)
|
|
original_container_id = decoded.get("response_id", container_id)
|
|
decoded_provider = decoded.get("custom_llm_provider")
|
|
if decoded_provider and custom_llm_provider == "openai":
|
|
custom_llm_provider = decoded_provider
|
|
return original_container_id, custom_llm_provider
|
|
|
|
|
|
async def get_container_forwarding_params(
|
|
container_id: str, original_container_id: str, custom_llm_provider: str
|
|
) -> Dict[str, str]:
|
|
params = {
|
|
"container_id": original_container_id,
|
|
"custom_llm_provider": custom_llm_provider,
|
|
}
|
|
decoded = ResponsesAPIRequestUtils._decode_container_id(container_id)
|
|
model_id = decoded.get("model_id")
|
|
if not (isinstance(model_id, str) and model_id):
|
|
# Native upstream IDs (e.g. Azure ``cntr_<hex>``) carry no LiteLLM
|
|
# routing payload, so decoding the user-supplied id yields no
|
|
# ``model_id``. Recover it from the encoded ``unified_object_id``
|
|
# captured on the ownership row at create time — when the router
|
|
# selected a specific deployment that ID embeds the model_id.
|
|
stored_id = await _get_stored_container_id(
|
|
original_container_id, custom_llm_provider
|
|
)
|
|
if stored_id and stored_id != container_id:
|
|
stored_decoded = ResponsesAPIRequestUtils._decode_container_id(stored_id)
|
|
stored_model_id = stored_decoded.get("model_id")
|
|
if isinstance(stored_model_id, str) and stored_model_id:
|
|
model_id = stored_model_id
|
|
if isinstance(model_id, str) and model_id:
|
|
params["model_id"] = model_id
|
|
return params
|
|
|
|
|
|
def _get_response_id(response: Any) -> Optional[str]:
|
|
if response is None:
|
|
return None
|
|
if isinstance(response, dict):
|
|
value = response.get("id")
|
|
else:
|
|
value = getattr(response, "id", None)
|
|
return value if isinstance(value, str) else None
|
|
|
|
|
|
def _dump_response(response: Any) -> Dict[str, Any]:
|
|
if isinstance(response, dict):
|
|
return dict(response)
|
|
if hasattr(response, "model_dump"):
|
|
return response.model_dump()
|
|
if hasattr(response, "dict"):
|
|
return response.dict()
|
|
return {"id": _get_response_id(response)}
|
|
|
|
|
|
async def _get_prisma_client():
|
|
from litellm.proxy.proxy_server import prisma_client
|
|
|
|
return prisma_client
|
|
|
|
|
|
def _custom_llm_provider_from_responses_response(
|
|
response: Any,
|
|
default: str = "openai",
|
|
) -> str:
|
|
hidden_params: Dict[str, Any] = {}
|
|
if isinstance(response, dict):
|
|
hidden_params = response.get("_hidden_params") or {}
|
|
else:
|
|
hidden_params = getattr(response, "_hidden_params", None) or {}
|
|
|
|
provider = hidden_params.get("custom_llm_provider")
|
|
if isinstance(provider, str) and provider:
|
|
return provider
|
|
return default
|
|
|
|
|
|
async def record_container_owners_from_responses_response(
|
|
response: Any,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
custom_llm_provider: Optional[str] = None,
|
|
) -> None:
|
|
"""Track containers created implicitly by code interpreter in /v1/responses."""
|
|
container_ids = (
|
|
ResponsesAPIRequestUtils.collect_container_ids_from_responses_response(response)
|
|
)
|
|
if not container_ids:
|
|
return
|
|
|
|
resolved_provider = (
|
|
custom_llm_provider or _custom_llm_provider_from_responses_response(response)
|
|
)
|
|
|
|
for container_id in container_ids:
|
|
try:
|
|
await record_container_owner(
|
|
response={"id": container_id, "object": "container"},
|
|
user_api_key_dict=user_api_key_dict,
|
|
custom_llm_provider=resolved_provider,
|
|
)
|
|
except Exception as e:
|
|
# Per-container errors (including ``HTTPException`` from
|
|
# conflicting/forbidden ownership rows) must not abort the
|
|
# batch — other containers in the same response should still
|
|
# get recorded so their follow-up file API calls don't 403.
|
|
verbose_proxy_logger.exception(
|
|
"Failed to record container ownership from responses output "
|
|
"for container_id=%s: %s",
|
|
container_id,
|
|
e,
|
|
)
|
|
|
|
|
|
async def record_container_owner(
|
|
response: Any,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
custom_llm_provider: str,
|
|
) -> Any:
|
|
container_id = _get_response_id(response)
|
|
if container_id is None:
|
|
verbose_proxy_logger.warning(
|
|
"Skipping container ownership tracking because provider response has no id"
|
|
)
|
|
return response
|
|
owner = get_primary_resource_owner_scope(user_api_key_dict)
|
|
if owner is None:
|
|
# Admins with identity (the common path: master-key auth populates
|
|
# ``user_id`` + ``api_key``) flow through the normal record path
|
|
# below so admin-created containers are still tracked. Truly
|
|
# identity-less admins (no user_id / team_id / org_id / api_key /
|
|
# token) can't be uniquely stamped on the row — stamping a
|
|
# placeholder would collapse every such caller into a shared
|
|
# owner, the cross-tenant primitive we explicitly avoid.
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Unable to record container ownership: caller has no identity scope.",
|
|
)
|
|
|
|
original_container_id, resolved_provider = decode_container_id_for_ownership(
|
|
container_id, custom_llm_provider
|
|
)
|
|
model_object_id = _container_model_object_id(
|
|
original_container_id, resolved_provider
|
|
)
|
|
file_object = _dump_response(response)
|
|
file_object["custom_llm_provider"] = resolved_provider
|
|
file_object["provider_container_id"] = original_container_id
|
|
# Prisma Python requires Json fields to be serialized as a JSON string.
|
|
file_object_json: str = json.dumps(file_object)
|
|
|
|
prisma_client = await _get_prisma_client()
|
|
if prisma_client is None:
|
|
verbose_proxy_logger.warning(
|
|
"Skipping container ownership tracking because prisma_client is None"
|
|
)
|
|
return response
|
|
|
|
table = ManagedObjectRepository(prisma_client).table
|
|
existing = await table.find_unique(where={"model_object_id": model_object_id})
|
|
if existing is not None:
|
|
if getattr(existing, "file_purpose", None) != CONTAINER_OBJECT_PURPOSE:
|
|
raise HTTPException(status_code=500, detail="Unable to track container")
|
|
if not user_can_access_resource_owner(
|
|
getattr(existing, "created_by", None), user_api_key_dict
|
|
):
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
await table.update(
|
|
where={"model_object_id": model_object_id},
|
|
data={
|
|
"unified_object_id": container_id,
|
|
"file_object": file_object_json,
|
|
"updated_by": owner,
|
|
},
|
|
)
|
|
else:
|
|
await table.create(
|
|
data={
|
|
"unified_object_id": container_id,
|
|
"model_object_id": model_object_id,
|
|
"file_object": file_object_json,
|
|
"file_purpose": CONTAINER_OBJECT_PURPOSE,
|
|
"created_by": owner,
|
|
"updated_by": owner,
|
|
}
|
|
)
|
|
|
|
_CONTAINER_OWNER_CACHE.set_cache(model_object_id, owner)
|
|
_CONTAINER_STORED_ID_CACHE.set_cache(model_object_id, container_id)
|
|
# Drop the caller's own list-cache entry so the just-created container
|
|
# shows up on their next ``GET /v1/containers``. Other callers with
|
|
# disjoint scope tuples have their own entries; intersecting-scope
|
|
# tuples self-correct on the 60s TTL.
|
|
caller_scopes = get_resource_owner_scopes(user_api_key_dict)
|
|
if caller_scopes:
|
|
_ALLOWED_CONTAINER_IDS_CACHE.delete_cache(
|
|
_allowed_container_ids_cache_key(caller_scopes)
|
|
)
|
|
return response
|
|
|
|
|
|
async def _get_container_owner(
|
|
original_container_id: str, custom_llm_provider: str
|
|
) -> Optional[str]:
|
|
model_object_id = _container_model_object_id(
|
|
original_container_id, custom_llm_provider
|
|
)
|
|
|
|
cached = _CONTAINER_OWNER_CACHE.get_cache(model_object_id)
|
|
if cached == _NEGATIVE_OWNER_SENTINEL:
|
|
return None
|
|
if cached is not None:
|
|
return cached
|
|
|
|
prisma_client = await _get_prisma_client()
|
|
if prisma_client is None:
|
|
return None
|
|
|
|
row = await ManagedObjectRepository(prisma_client).table.find_first(
|
|
where={
|
|
"model_object_id": model_object_id,
|
|
"file_purpose": CONTAINER_OBJECT_PURPOSE,
|
|
}
|
|
)
|
|
owner = getattr(row, "created_by", None) if row is not None else None
|
|
_CONTAINER_OWNER_CACHE.set_cache(
|
|
model_object_id, owner if owner is not None else _NEGATIVE_OWNER_SENTINEL
|
|
)
|
|
stored_id = getattr(row, "unified_object_id", None) if row is not None else None
|
|
_CONTAINER_STORED_ID_CACHE.set_cache(
|
|
model_object_id,
|
|
(
|
|
stored_id
|
|
if isinstance(stored_id, str) and stored_id
|
|
else _NEGATIVE_STORED_ID_SENTINEL
|
|
),
|
|
)
|
|
return owner
|
|
|
|
|
|
async def _get_stored_container_id(
|
|
original_container_id: str, custom_llm_provider: str
|
|
) -> Optional[str]:
|
|
"""Return the ``unified_object_id`` stored at create time, if any.
|
|
|
|
Used by :func:`get_container_forwarding_params` to recover the
|
|
deployment ``model_id`` for native upstream container IDs: the stored
|
|
value is the encoded form produced by ``encode_container_id_in_response``
|
|
when the router selected a specific deployment.
|
|
"""
|
|
model_object_id = _container_model_object_id(
|
|
original_container_id, custom_llm_provider
|
|
)
|
|
|
|
cached = _CONTAINER_STORED_ID_CACHE.get_cache(model_object_id)
|
|
if cached == _NEGATIVE_STORED_ID_SENTINEL:
|
|
return None
|
|
if isinstance(cached, str) and cached:
|
|
return cached
|
|
|
|
prisma_client = await _get_prisma_client()
|
|
if prisma_client is None:
|
|
return None
|
|
|
|
row = await ManagedObjectRepository(prisma_client).table.find_first(
|
|
where={
|
|
"model_object_id": model_object_id,
|
|
"file_purpose": CONTAINER_OBJECT_PURPOSE,
|
|
}
|
|
)
|
|
stored_id = getattr(row, "unified_object_id", None) if row is not None else None
|
|
_CONTAINER_STORED_ID_CACHE.set_cache(
|
|
model_object_id,
|
|
(
|
|
stored_id
|
|
if isinstance(stored_id, str) and stored_id
|
|
else _NEGATIVE_STORED_ID_SENTINEL
|
|
),
|
|
)
|
|
return stored_id if isinstance(stored_id, str) and stored_id else None
|
|
|
|
|
|
async def assert_user_can_access_container(
|
|
container_id: str,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
custom_llm_provider: str,
|
|
) -> Tuple[str, str]:
|
|
original_container_id, resolved_provider = decode_container_id_for_ownership(
|
|
container_id, custom_llm_provider
|
|
)
|
|
|
|
if is_proxy_admin(user_api_key_dict):
|
|
return original_container_id, resolved_provider
|
|
|
|
# Untracked rows (no ownership) are admin-only. Pre-isolation rows
|
|
# that pre-date this enforcement need an admin to either re-create
|
|
# via the now-tracked flow or assign ``created_by`` on the row.
|
|
owner = await _get_container_owner(original_container_id, resolved_provider)
|
|
if not user_can_access_resource_owner(owner, user_api_key_dict):
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
|
|
return original_container_id, resolved_provider
|
|
|
|
|
|
def _get_container_list_data(response: Any) -> Optional[List[Any]]:
|
|
if response is None:
|
|
return None
|
|
if isinstance(response, dict):
|
|
data = response.get("data")
|
|
else:
|
|
data = getattr(response, "data", None)
|
|
return data if isinstance(data, list) else None
|
|
|
|
|
|
def _set_container_list_data(
|
|
response: Any, data: List[Any], removed_filtered_items: bool = False
|
|
) -> Any:
|
|
if isinstance(response, dict):
|
|
response["data"] = data
|
|
if data:
|
|
response["first_id"] = _get_response_id(data[0])
|
|
response["last_id"] = _get_response_id(data[-1])
|
|
else:
|
|
response["first_id"] = None
|
|
response["last_id"] = None
|
|
response["has_more"] = False
|
|
if removed_filtered_items:
|
|
response["has_more"] = False
|
|
return response
|
|
|
|
response.data = data
|
|
response.first_id = _get_response_id(data[0]) if data else None
|
|
response.last_id = _get_response_id(data[-1]) if data else None
|
|
if not data and hasattr(response, "has_more"):
|
|
response.has_more = False
|
|
if removed_filtered_items and hasattr(response, "has_more"):
|
|
response.has_more = False
|
|
return response
|
|
|
|
|
|
async def _get_allowed_container_ids(
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
) -> Set[str]:
|
|
owner_scopes = get_resource_owner_scopes(user_api_key_dict)
|
|
if not owner_scopes:
|
|
return set()
|
|
|
|
cache_key = _allowed_container_ids_cache_key(owner_scopes)
|
|
cached = _ALLOWED_CONTAINER_IDS_CACHE.get_cache(cache_key)
|
|
if cached is not None:
|
|
return set(cached)
|
|
|
|
prisma_client = await _get_prisma_client()
|
|
if prisma_client is None:
|
|
return set()
|
|
|
|
rows = await ManagedObjectRepository(prisma_client).table.find_many(
|
|
where={
|
|
"file_purpose": CONTAINER_OBJECT_PURPOSE,
|
|
"created_by": {"in": owner_scopes},
|
|
}
|
|
)
|
|
allowed_ids = {
|
|
row.model_object_id
|
|
for row in rows
|
|
if getattr(row, "model_object_id", None) is not None
|
|
}
|
|
# ``InMemoryCache.get_cache`` attempts ``json.loads`` on the stored
|
|
# value; passing a set would round-trip through that path
|
|
# unnecessarily. Store as a list and rehydrate above.
|
|
_ALLOWED_CONTAINER_IDS_CACHE.set_cache(cache_key, list(allowed_ids))
|
|
return allowed_ids
|
|
|
|
|
|
async def filter_container_list_response(
|
|
response: Any,
|
|
user_api_key_dict: UserAPIKeyAuth,
|
|
custom_llm_provider: str,
|
|
) -> Any:
|
|
if is_proxy_admin(user_api_key_dict):
|
|
return response
|
|
|
|
data = _get_container_list_data(response)
|
|
if data is None:
|
|
return response
|
|
|
|
allowed_container_ids = await _get_allowed_container_ids(user_api_key_dict)
|
|
filtered: List[Any] = []
|
|
for item in data:
|
|
container_id = _get_response_id(item)
|
|
if container_id is None:
|
|
continue
|
|
original_container_id, resolved_provider = decode_container_id_for_ownership(
|
|
container_id, custom_llm_provider
|
|
)
|
|
if (
|
|
_container_model_object_id(original_container_id, resolved_provider)
|
|
in allowed_container_ids
|
|
):
|
|
filtered.append(item)
|
|
|
|
return _set_container_list_data(
|
|
response, filtered, removed_filtered_items=len(filtered) != len(data)
|
|
)
|