feat(responses): file_search support — Phase 1 native passthrough + Phase 2 emulated fallback
Phase 1 (native passthrough): - _decode_vector_store_ids_in_tools(): decode LiteLLM-managed unified vector_store_ids to provider-native IDs in file_search tools - Split update_responses_tools_with_model_file_ids() into decode pass (always runs) + code_interpreter mapping pass (guarded) - BaseResponsesAPIConfig.supports_native_file_search() → False by default; OpenAIResponsesAPIConfig overrides to True - ManagedFiles.async_pre_call_hook(): batch team-level access check for unified vector_store_ids in file_search tools (no N+1) - Docs: file_search section in response_api.md Phase 2 (emulated fallback for non-native providers): - litellm/responses/file_search/emulated_handler.py: converts file_search tool → function tool, intercepts tool call, runs asearch(), makes follow-up call, synthesizes OpenAI-format output (file_search_call + message + file_citation annotations) - responses/main.py: routes to emulated handler when provider doesn't support file_search natively Tests: 41 unit tests across 8 families (A-H) in test_file_search_responses.py Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
278c9babc6
commit
c735251570
@ -1556,6 +1556,135 @@ curl -X POST "http://localhost:4000/v1/responses" \
|
||||
}'
|
||||
```
|
||||
|
||||
## File Search (Vector Stores)
|
||||
|
||||
The **file_search** tool lets the model search your vector stores and cite retrieved content in its answer (OpenAI Responses API format). Pass `tools=[{"type": "file_search", "vector_store_ids": [...]}]`. The response includes a `file_search_call` output item and `file_citation` annotations on the answer text.
|
||||
|
||||
**Supported providers:** `openai`, `azure` (native). Other providers will receive an `UnsupportedParamsError` until the emulated-fallback path is available.
|
||||
|
||||
:::note
|
||||
If you are using LiteLLM-managed vector stores (created via `/v1/vector_stores`), pass the LiteLLM vector store ID directly — LiteLLM automatically decodes it to the provider-native ID before sending the request.
|
||||
:::
|
||||
|
||||
### Python SDK
|
||||
|
||||
```python showLineNumbers title="File search with LiteLLM Python SDK"
|
||||
import litellm
|
||||
|
||||
response = litellm.responses(
|
||||
model="openai/gpt-4.1",
|
||||
input="What is deep research?",
|
||||
tools=[{
|
||||
"type": "file_search",
|
||||
"vector_store_ids": ["vs_abc123"] # native or LiteLLM-managed vector store ID
|
||||
}],
|
||||
)
|
||||
|
||||
# Output contains a file_search_call item followed by the answer with citations
|
||||
for item in response.output:
|
||||
if item.type == "file_search_call":
|
||||
print("Queries:", item.queries)
|
||||
elif item.type == "message":
|
||||
for block in item.content:
|
||||
print(block.text)
|
||||
for ann in block.annotations:
|
||||
print(f" ↳ {ann.filename} (file_id={ann.file_id})")
|
||||
```
|
||||
|
||||
#### Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"output": [
|
||||
{
|
||||
"type": "file_search_call",
|
||||
"id": "fs_67c09ccea8c48191ade9367e3ba71515",
|
||||
"status": "completed",
|
||||
"queries": ["What is deep research?"],
|
||||
"search_results": null
|
||||
},
|
||||
{
|
||||
"id": "msg_67c09cd3091c819185af2be5d13d87de",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Deep research is a capability that allows for extensive inquiry ...",
|
||||
"annotations": [
|
||||
{
|
||||
"type": "file_citation",
|
||||
"index": 992,
|
||||
"file_id": "file-2dtbBZdjtDKS8eqWxqbgDi",
|
||||
"filename": "deep_research_blog.pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### LiteLLM Proxy (AI Gateway)
|
||||
|
||||
**OpenAI Python SDK (proxy as base_url):**
|
||||
|
||||
```python showLineNumbers title="File search via LiteLLM Proxy"
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(
|
||||
base_url="http://localhost:4000",
|
||||
api_key="your-proxy-api-key",
|
||||
)
|
||||
|
||||
response = client.responses.create(
|
||||
model="openai/gpt-4.1",
|
||||
input="Summarise the Q3 earnings report.",
|
||||
tools=[{
|
||||
"type": "file_search",
|
||||
"vector_store_ids": ["vs_abc123"]
|
||||
}],
|
||||
)
|
||||
```
|
||||
|
||||
**curl:**
|
||||
|
||||
```bash title="File search via curl to LiteLLM Proxy"
|
||||
curl -X POST "http://localhost:4000/v1/responses" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your-proxy-api-key" \
|
||||
-d '{
|
||||
"model": "openai/gpt-4.1",
|
||||
"input": "Summarise the Q3 earnings report.",
|
||||
"tools": [{"type": "file_search", "vector_store_ids": ["vs_abc123"]}]
|
||||
}'
|
||||
```
|
||||
|
||||
### Using LiteLLM-Managed Vector Stores
|
||||
|
||||
If you created a vector store through LiteLLM (`POST /v1/vector_stores/new`), use the returned `vector_store_id` directly. LiteLLM decodes the unified ID to the provider-native vector store ID automatically.
|
||||
|
||||
```python showLineNumbers title="File search with LiteLLM-managed vector store"
|
||||
from openai import OpenAI
|
||||
|
||||
client = OpenAI(base_url="http://localhost:4000", api_key="your-proxy-api-key")
|
||||
|
||||
# vector_store_id returned by POST /v1/vector_stores/new
|
||||
managed_vs_id = "bGl0ZWxsbV9wcm94eTo..." # LiteLLM-managed ID
|
||||
|
||||
response = client.responses.create(
|
||||
model="openai/gpt-4.1",
|
||||
input="What does the documentation say about authentication?",
|
||||
tools=[{"type": "file_search", "vector_store_ids": [managed_vs_id]}],
|
||||
)
|
||||
```
|
||||
|
||||
LiteLLM will:
|
||||
1. Verify the calling team has access to the vector store.
|
||||
2. Decode the managed ID to the provider-native vector store ID.
|
||||
3. Forward the request to the provider unchanged.
|
||||
|
||||
## Session Management
|
||||
|
||||
LiteLLM Proxy supports session management for all supported models. This allows you to store and fetch conversation history (state) in LiteLLM Proxy.
|
||||
|
||||
@ -29,7 +29,7 @@ from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
get_models_from_unified_file_id,
|
||||
normalize_mime_type_for_provider,
|
||||
)
|
||||
from litellm.types.llms.openai import (
|
||||
from litellm.types.llms.openai import ( # pyright: ignore[reportAttributeAccessIssue]
|
||||
AllMessageValues,
|
||||
AsyncCursorPage,
|
||||
ChatCompletionFileObject,
|
||||
@ -442,25 +442,33 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
||||
elif call_type == CallTypes.aresponses.value or call_type == CallTypes.responses.value:
|
||||
# Handle managed files in responses API input and tools
|
||||
file_ids = []
|
||||
|
||||
|
||||
# Extract file IDs from input parameter
|
||||
input_data = data.get("input")
|
||||
if input_data:
|
||||
file_ids.extend(self.get_file_ids_from_responses_input(input_data))
|
||||
|
||||
|
||||
# Extract file IDs from tools parameter (e.g., code_interpreter container)
|
||||
tools = data.get("tools")
|
||||
if tools:
|
||||
file_ids.extend(self.get_file_ids_from_responses_tools(tools))
|
||||
|
||||
|
||||
if file_ids:
|
||||
# Check user has access to all managed files
|
||||
await self.check_file_ids_access(file_ids, user_api_key_dict)
|
||||
|
||||
|
||||
model_file_id_mapping = await self.get_model_file_id_mapping(
|
||||
file_ids, user_api_key_dict.parent_otel_span
|
||||
)
|
||||
data["model_file_id_mapping"] = model_file_id_mapping
|
||||
|
||||
# Check access for file_search vector_store_ids
|
||||
if tools:
|
||||
unified_vs_ids = self.get_vector_store_ids_from_file_search_tools(tools)
|
||||
if unified_vs_ids:
|
||||
await self.check_vector_store_ids_access(
|
||||
unified_vs_ids, user_api_key_dict
|
||||
)
|
||||
elif call_type == CallTypes.afile_content.value:
|
||||
retrieve_file_id = cast(Optional[str], data.get("file_id"))
|
||||
potential_file_id = (
|
||||
@ -704,6 +712,92 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
||||
|
||||
return file_ids
|
||||
|
||||
def get_vector_store_ids_from_file_search_tools(
|
||||
self, tools: List[Dict[str, Any]]
|
||||
) -> List[str]:
|
||||
"""
|
||||
Extract unified vector_store_ids from file_search tools.
|
||||
|
||||
Only returns IDs that are LiteLLM-managed (base64 unified IDs).
|
||||
Native provider IDs are skipped — they have no LiteLLM access record.
|
||||
"""
|
||||
from litellm.llms.base_llm.managed_resources.utils import (
|
||||
is_base64_encoded_unified_id,
|
||||
)
|
||||
|
||||
vs_ids: List[str] = []
|
||||
if not isinstance(tools, list):
|
||||
return vs_ids
|
||||
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict) or tool.get("type") != "file_search":
|
||||
continue
|
||||
vector_store_ids = tool.get("vector_store_ids")
|
||||
if not isinstance(vector_store_ids, list):
|
||||
continue
|
||||
for vs_id in vector_store_ids:
|
||||
if isinstance(vs_id, str) and is_base64_encoded_unified_id(vs_id):
|
||||
vs_ids.append(vs_id)
|
||||
|
||||
return vs_ids
|
||||
|
||||
async def check_vector_store_ids_access(
|
||||
self,
|
||||
vector_store_ids: List[str],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> None:
|
||||
"""
|
||||
Verify the caller's team can access each LiteLLM-managed vector store.
|
||||
|
||||
Batch-fetches vector stores from DB and checks team_id.
|
||||
Raises HTTPException(403) on the first access violation.
|
||||
Non-managed (native) IDs should already be filtered out before calling this.
|
||||
"""
|
||||
from litellm.llms.base_llm.managed_resources.utils import (
|
||||
extract_unified_uuid_from_unified_id,
|
||||
)
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if not vector_store_ids or prisma_client is None:
|
||||
return
|
||||
|
||||
# Map each unified ID to its internal UUID for a single batch DB fetch
|
||||
uuid_to_unified: Dict[str, str] = {}
|
||||
for vs_id in vector_store_ids:
|
||||
uuid = extract_unified_uuid_from_unified_id(vs_id)
|
||||
if uuid:
|
||||
uuid_to_unified[uuid] = vs_id
|
||||
|
||||
if not uuid_to_unified:
|
||||
return
|
||||
|
||||
rows = await prisma_client.db.litellm_managedvectorstorestable.find_many(
|
||||
where={"vector_store_id": {"in": list(uuid_to_unified.keys())}},
|
||||
take=len(uuid_to_unified),
|
||||
)
|
||||
|
||||
found_uuids = {row.vector_store_id for row in rows}
|
||||
|
||||
for uuid, original_id in uuid_to_unified.items():
|
||||
if uuid not in found_uuids:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"Vector store '{original_id}' not found or access denied.",
|
||||
)
|
||||
|
||||
caller_team_id = user_api_key_dict.team_id
|
||||
for row in rows:
|
||||
vs_team_id = getattr(row, "team_id", None)
|
||||
if vs_team_id is not None and vs_team_id != caller_team_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
f"Team '{caller_team_id}' does not have access to vector "
|
||||
f"store '{row.vector_store_id}'. The store belongs to team "
|
||||
f"'{vs_team_id}'."
|
||||
),
|
||||
)
|
||||
|
||||
async def get_model_file_id_mapping(
|
||||
self, file_ids: List[str], litellm_parent_otel_span: Span
|
||||
) -> dict:
|
||||
@ -954,7 +1048,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
||||
)
|
||||
else:
|
||||
file_object = await litellm.afile_retrieve(
|
||||
custom_llm_provider=model_name.split("/")[0] if model_name and "/" in model_name else "openai",
|
||||
custom_llm_provider=model_name.split("/")[0] if model_name and "/" in model_name else "openai", # type: ignore[arg-type]
|
||||
file_id=original_file_id,
|
||||
)
|
||||
verbose_logger.debug(
|
||||
|
||||
@ -536,6 +536,59 @@ def update_responses_input_with_model_file_ids(
|
||||
return updated_input
|
||||
|
||||
|
||||
def _decode_vector_store_ids_in_tools(
|
||||
tools: Optional[List[Dict[str, Any]]],
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
"""
|
||||
Decodes unified (LiteLLM-managed) vector_store_ids in file_search tools to
|
||||
provider-native IDs. Non-unified IDs are passed through unchanged.
|
||||
|
||||
This runs unconditionally — no file-ID mapping is required.
|
||||
"""
|
||||
if not tools or not isinstance(tools, list):
|
||||
return tools
|
||||
|
||||
from litellm.llms.base_llm.managed_resources.utils import (
|
||||
is_base64_encoded_unified_id,
|
||||
parse_unified_id,
|
||||
)
|
||||
|
||||
updated_tools = []
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict) or tool.get("type") != "file_search":
|
||||
updated_tools.append(tool)
|
||||
continue
|
||||
|
||||
vector_store_ids = tool.get("vector_store_ids")
|
||||
if not isinstance(vector_store_ids, list):
|
||||
updated_tools.append(tool)
|
||||
continue
|
||||
|
||||
decoded_ids = []
|
||||
for vs_id in vector_store_ids:
|
||||
if not isinstance(vs_id, str) or not is_base64_encoded_unified_id(vs_id):
|
||||
decoded_ids.append(vs_id)
|
||||
continue
|
||||
|
||||
parsed = parse_unified_id(vs_id)
|
||||
provider_resource_id = parsed.get("provider_resource_id") if parsed else None
|
||||
|
||||
if not provider_resource_id:
|
||||
verbose_logger.warning(
|
||||
"file_search tool contains unified vector_store_id '%s' that could "
|
||||
"not be decoded to a provider resource ID — passing original ID. "
|
||||
"Ensure the vector store was created via LiteLLM.",
|
||||
vs_id,
|
||||
)
|
||||
decoded_ids.append(vs_id)
|
||||
else:
|
||||
decoded_ids.append(provider_resource_id)
|
||||
|
||||
updated_tools.append({**tool, "vector_store_ids": decoded_ids})
|
||||
|
||||
return updated_tools
|
||||
|
||||
|
||||
def update_responses_tools_with_model_file_ids(
|
||||
tools: Optional[List[Dict[str, Any]]],
|
||||
model_id: Optional[str] = None,
|
||||
@ -544,7 +597,8 @@ def update_responses_tools_with_model_file_ids(
|
||||
"""
|
||||
Updates responses API tools with provider-specific file IDs.
|
||||
|
||||
Handles code_interpreter tools with container.file_ids.
|
||||
Pass 1 (always): decode unified vector_store_ids in file_search tools.
|
||||
Pass 2 (needs mapping): map code_interpreter container file_ids to provider IDs.
|
||||
|
||||
Args:
|
||||
tools: The responses API tools parameter
|
||||
@ -555,6 +609,10 @@ def update_responses_tools_with_model_file_ids(
|
||||
if not tools or not isinstance(tools, list):
|
||||
return tools
|
||||
|
||||
# Pass 1: decode unified vector_store_ids (no mapping needed)
|
||||
tools = _decode_vector_store_ids_in_tools(tools) or tools
|
||||
|
||||
# Pass 2: map code_interpreter file IDs (requires mapping)
|
||||
if not model_file_id_mapping or not model_id:
|
||||
return tools
|
||||
|
||||
|
||||
@ -54,6 +54,14 @@ class BaseResponsesAPIConfig(ABC):
|
||||
and v is not None
|
||||
}
|
||||
|
||||
def supports_native_file_search(self) -> bool:
|
||||
"""Return True if this provider handles the file_search tool natively.
|
||||
|
||||
Override in provider subclasses that support file_search without
|
||||
LiteLLM emulation (e.g. OpenAI, Azure OpenAI).
|
||||
"""
|
||||
return False
|
||||
|
||||
@abstractmethod
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
pass
|
||||
|
||||
@ -32,6 +32,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig):
|
||||
def custom_llm_provider(self) -> LlmProviders:
|
||||
return LlmProviders.OPENAI
|
||||
|
||||
def supports_native_file_search(self) -> bool:
|
||||
return True
|
||||
|
||||
def get_supported_openai_params(self, model: str) -> list:
|
||||
"""
|
||||
All OpenAI Responses API params are supported
|
||||
|
||||
0
litellm/responses/file_search/__init__.py
Normal file
0
litellm/responses/file_search/__init__.py
Normal file
431
litellm/responses/file_search/emulated_handler.py
Normal file
431
litellm/responses/file_search/emulated_handler.py
Normal file
@ -0,0 +1,431 @@
|
||||
"""
|
||||
Emulated file_search for providers that don't support the tool natively.
|
||||
|
||||
Flow:
|
||||
1. Convert file_search tools to a single function tool definition.
|
||||
2. Call the provider with the function tool.
|
||||
3. If the provider issues a file_search function_call, execute vector search
|
||||
via litellm.vector_stores.main.asearch().
|
||||
4. Feed results back and get the final answer.
|
||||
5. Wrap everything in OpenAI Responses-API format:
|
||||
[file_search_call output item] + [message output item with file_citation annotations]
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple, Union, cast
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
from litellm.types.vector_stores import VectorStoreSearchResult
|
||||
|
||||
# Keep ToolParam broad so we stay compatible with both dict and Pydantic forms
|
||||
ToolParam = Any
|
||||
|
||||
FILE_SEARCH_FUNCTION_NAME = "litellm_file_search"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def should_use_emulated_file_search(
|
||||
tools: Optional[Iterable[ToolParam]],
|
||||
provider_config: Any, # BaseResponsesAPIConfig
|
||||
) -> bool:
|
||||
"""Return True when there is a file_search tool and the provider can't handle it natively."""
|
||||
if not tools:
|
||||
return False
|
||||
has_fs = any(
|
||||
isinstance(t, dict) and t.get("type") == "file_search" for t in tools
|
||||
)
|
||||
if not has_fs:
|
||||
return False
|
||||
return provider_config is None or not provider_config.supports_native_file_search()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool conversion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _build_function_tool(vector_store_ids: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Create an OpenAI function-tool definition that describes file search.
|
||||
The function accepts a natural-language query; LiteLLM runs the actual
|
||||
vector search against the configured vector stores.
|
||||
"""
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": FILE_SEARCH_FUNCTION_NAME,
|
||||
"description": (
|
||||
"Search the knowledge base for information relevant to the query. "
|
||||
"Use this whenever you need to look up specific facts, documents, "
|
||||
"or content from the vector store."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "The search query to look up in the vector store.",
|
||||
},
|
||||
"vector_store_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the vector store to search.",
|
||||
"enum": vector_store_ids,
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _replace_file_search_tools(
|
||||
tools: Optional[Iterable[ToolParam]],
|
||||
) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
"""
|
||||
Replace all file_search tools with a single function tool.
|
||||
|
||||
Returns:
|
||||
(new_tools_list, all_vector_store_ids)
|
||||
"""
|
||||
non_file_search: List[Dict[str, Any]] = []
|
||||
vector_store_ids: List[str] = []
|
||||
|
||||
for tool in (tools or []):
|
||||
if isinstance(tool, dict) and tool.get("type") == "file_search":
|
||||
ids = tool.get("vector_store_ids") or []
|
||||
vector_store_ids.extend(ids)
|
||||
else:
|
||||
non_file_search.append(tool)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
unique_ids: List[str] = list(dict.fromkeys(vector_store_ids))
|
||||
if unique_ids:
|
||||
non_file_search.append(_build_function_tool(unique_ids))
|
||||
|
||||
return non_file_search, unique_ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Search execution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _run_vector_searches(
|
||||
query: str,
|
||||
vector_store_ids: List[str],
|
||||
fallback_vector_store_ids: List[str],
|
||||
) -> Tuple[List[str], List[VectorStoreSearchResult]]:
|
||||
"""
|
||||
Run `asearch` against all vector stores and collect results.
|
||||
|
||||
Returns:
|
||||
(queries_list, combined_results)
|
||||
"""
|
||||
import litellm.vector_stores.main as vs_main
|
||||
|
||||
queries: List[str] = [query]
|
||||
all_results: List[VectorStoreSearchResult] = []
|
||||
|
||||
ids_to_search = vector_store_ids or fallback_vector_store_ids
|
||||
for vs_id in ids_to_search:
|
||||
try:
|
||||
response = await vs_main.asearch(
|
||||
vector_store_id=vs_id,
|
||||
query=query,
|
||||
)
|
||||
results_data = response.get("data") if isinstance(response, dict) else getattr(response, "data", None)
|
||||
if results_data:
|
||||
all_results.extend(results_data)
|
||||
except Exception as exc:
|
||||
verbose_logger.warning(
|
||||
"file_search emulated: search failed for vector_store_id='%s': %s",
|
||||
vs_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
return queries, all_results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _format_search_results_as_tool_output(
|
||||
results: List[VectorStoreSearchResult],
|
||||
) -> str:
|
||||
"""Serialize search results into a string to pass back as the tool's output."""
|
||||
if not results:
|
||||
return "No results found in the vector store."
|
||||
|
||||
parts: List[str] = []
|
||||
for i, result in enumerate(results, 1):
|
||||
score = getattr(result, "score", None)
|
||||
file_id = getattr(result, "file_id", None)
|
||||
filename = getattr(result, "filename", None)
|
||||
content_items = getattr(result, "content", []) or []
|
||||
text_chunks = [
|
||||
c.get("text", "") if isinstance(c, dict) else getattr(c, "text", "")
|
||||
for c in content_items
|
||||
]
|
||||
text = " ".join(t for t in text_chunks if t)
|
||||
|
||||
header = f"[Result {i}"
|
||||
if filename:
|
||||
header += f" | {filename}"
|
||||
if file_id:
|
||||
header += f" | file_id={file_id}"
|
||||
if score is not None:
|
||||
header += f" | score={score:.3f}"
|
||||
header += "]"
|
||||
|
||||
parts.append(f"{header}\n{text}")
|
||||
|
||||
return "\n\n".join(parts)
|
||||
|
||||
|
||||
def _build_file_search_call_output(
|
||||
call_id: str,
|
||||
queries: List[str],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the file_search_call output item (mirrors OpenAI's format)."""
|
||||
return {
|
||||
"type": "file_search_call",
|
||||
"id": call_id,
|
||||
"status": "completed",
|
||||
"queries": queries,
|
||||
"search_results": None,
|
||||
}
|
||||
|
||||
|
||||
def _build_file_citation_annotations(
|
||||
results: List[VectorStoreSearchResult],
|
||||
text: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Build file_citation annotations for the text.
|
||||
Each result with a file_id gets a citation at the end of the text.
|
||||
"""
|
||||
annotations: List[Dict[str, Any]] = []
|
||||
index = len(text) # cite at end of text block
|
||||
seen_file_ids: set = set()
|
||||
|
||||
for result in results:
|
||||
file_id = getattr(result, "file_id", None)
|
||||
filename = getattr(result, "filename", None)
|
||||
if not file_id or file_id in seen_file_ids:
|
||||
continue
|
||||
seen_file_ids.add(file_id)
|
||||
annotations.append(
|
||||
{
|
||||
"type": "file_citation",
|
||||
"index": index,
|
||||
"file_id": file_id,
|
||||
"filename": filename or "",
|
||||
}
|
||||
)
|
||||
|
||||
return annotations
|
||||
|
||||
|
||||
def _build_message_output(
|
||||
response_text: str,
|
||||
results: List[VectorStoreSearchResult],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the message output item with optional file_citation annotations."""
|
||||
annotations = _build_file_citation_annotations(results, response_text)
|
||||
return {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": response_text,
|
||||
"annotations": annotations,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _extract_text_from_responses_output(response: ResponsesAPIResponse) -> str:
|
||||
"""Pull the assistant's text from the provider's response."""
|
||||
for item in response.output:
|
||||
item_type = item.get("type") if isinstance(item, dict) else getattr(item, "type", None)
|
||||
if item_type == "message":
|
||||
content = item.get("content") if isinstance(item, dict) else getattr(item, "content", [])
|
||||
for block in (content or []):
|
||||
block_type = block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
|
||||
if block_type == "output_text":
|
||||
raw = block.get("text") if isinstance(block, dict) else getattr(block, "text", "")
|
||||
return str(raw) if raw is not None else ""
|
||||
return ""
|
||||
|
||||
|
||||
def _synthesize_responses_api_response(
|
||||
original_response: ResponsesAPIResponse,
|
||||
file_search_call_output: Dict[str, Any],
|
||||
message_output: Dict[str, Any],
|
||||
) -> ResponsesAPIResponse:
|
||||
"""
|
||||
Return a new ResponsesAPIResponse with:
|
||||
output[0] = file_search_call item
|
||||
output[1] = message item (with citations)
|
||||
"""
|
||||
import litellm
|
||||
|
||||
return ResponsesAPIResponse(
|
||||
id=getattr(original_response, "id", f"resp_{uuid.uuid4().hex}"),
|
||||
object="response",
|
||||
created_at=getattr(original_response, "created_at", int(time.time())),
|
||||
status="completed",
|
||||
model=getattr(original_response, "model", ""),
|
||||
output=[file_search_call_output, message_output],
|
||||
usage=getattr(original_response, "usage", None),
|
||||
error=None,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _call_aresponses(input, model, tools, **kwargs): # pragma: no cover – thin wrapper for patching in tests
|
||||
from litellm.responses.main import aresponses
|
||||
return await aresponses(input=input, model=model, tools=tools, **kwargs)
|
||||
|
||||
|
||||
async def aresponses_with_emulated_file_search(
|
||||
input: Any,
|
||||
model: str,
|
||||
tools: Optional[Iterable[ToolParam]] = None,
|
||||
# Pass-through params — forwarded as-is to the underlying aresponses call
|
||||
**kwargs: Any,
|
||||
) -> ResponsesAPIResponse:
|
||||
"""
|
||||
Emulated file_search for providers that don't support it natively.
|
||||
|
||||
Replaces file_search tools with a function tool, intercepts the tool call,
|
||||
runs vector search, and synthesizes an OpenAI-format response.
|
||||
"""
|
||||
# 1. Replace file_search tools with function tool
|
||||
transformed_tools, all_vs_ids = _replace_file_search_tools(tools)
|
||||
|
||||
# 2. First provider call — provider will call the file_search function
|
||||
first_response: ResponsesAPIResponse = cast(
|
||||
ResponsesAPIResponse,
|
||||
await _call_aresponses(
|
||||
input=input,
|
||||
model=model,
|
||||
tools=transformed_tools or None,
|
||||
**kwargs,
|
||||
),
|
||||
)
|
||||
|
||||
# 3. Look for a file_search function_call in the output
|
||||
file_search_calls = [
|
||||
item
|
||||
for item in first_response.output
|
||||
if (
|
||||
isinstance(item, dict)
|
||||
and item.get("type") == "function_call"
|
||||
and item.get("name") == FILE_SEARCH_FUNCTION_NAME
|
||||
)
|
||||
or (
|
||||
hasattr(item, "type")
|
||||
and getattr(item, "type") == "function_call"
|
||||
and getattr(item, "name", None) == FILE_SEARCH_FUNCTION_NAME
|
||||
)
|
||||
]
|
||||
|
||||
if not file_search_calls:
|
||||
# Provider answered without calling the tool (e.g. it had enough context).
|
||||
# Return as-is wrapped in OpenAI format.
|
||||
call_id = f"fs_{uuid.uuid4().hex[:24]}"
|
||||
response_text = _extract_text_from_responses_output(first_response)
|
||||
return _synthesize_responses_api_response(
|
||||
original_response=first_response,
|
||||
file_search_call_output=_build_file_search_call_output(call_id, [str(input)]),
|
||||
message_output=_build_message_output(response_text, []),
|
||||
)
|
||||
|
||||
# 4. Execute each file_search tool call
|
||||
tool_results: List[Dict[str, Any]] = []
|
||||
all_queries: List[str] = []
|
||||
all_results: List[VectorStoreSearchResult] = []
|
||||
file_search_call_id = f"fs_{uuid.uuid4().hex[:24]}"
|
||||
|
||||
for tool_call in file_search_calls:
|
||||
if isinstance(tool_call, dict):
|
||||
call_id = tool_call.get("call_id") or tool_call.get("id") or file_search_call_id
|
||||
raw_args = tool_call.get("arguments") or "{}"
|
||||
else:
|
||||
call_id = getattr(tool_call, "call_id", None) or getattr(tool_call, "id", file_search_call_id)
|
||||
raw_args = getattr(tool_call, "arguments", "{}") or "{}"
|
||||
|
||||
try:
|
||||
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
||||
except json.JSONDecodeError:
|
||||
args = {}
|
||||
|
||||
query = args.get("query", str(input))
|
||||
vs_id_arg = args.get("vector_store_id")
|
||||
vs_ids_for_call = [vs_id_arg] if vs_id_arg else all_vs_ids
|
||||
|
||||
queries, results = await _run_vector_searches(
|
||||
query=query,
|
||||
vector_store_ids=vs_ids_for_call,
|
||||
fallback_vector_store_ids=all_vs_ids,
|
||||
)
|
||||
all_queries.extend(queries)
|
||||
all_results.extend(results)
|
||||
|
||||
tool_results.append(
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": _format_search_results_as_tool_output(results),
|
||||
}
|
||||
)
|
||||
|
||||
# 5. Build follow-up input: original messages + assistant's tool call + tool results
|
||||
original_input_items = list(input) if isinstance(input, (list, tuple)) else [{"role": "user", "content": str(input)}]
|
||||
follow_up_input = (
|
||||
original_input_items
|
||||
+ [
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": FILE_SEARCH_FUNCTION_NAME,
|
||||
"call_id": file_search_calls[0].get("call_id") if isinstance(file_search_calls[0], dict) else getattr(file_search_calls[0], "call_id", file_search_call_id),
|
||||
"arguments": file_search_calls[0].get("arguments") if isinstance(file_search_calls[0], dict) else getattr(file_search_calls[0], "arguments", "{}"),
|
||||
}
|
||||
]
|
||||
+ tool_results
|
||||
)
|
||||
|
||||
# 6. Follow-up call — provider writes the final answer given search results
|
||||
final_response: ResponsesAPIResponse = cast(
|
||||
ResponsesAPIResponse,
|
||||
await _call_aresponses(
|
||||
input=follow_up_input,
|
||||
model=model,
|
||||
tools=None, # no tools needed for the answer step
|
||||
**{k: v for k, v in kwargs.items() if k not in ("tools",)},
|
||||
),
|
||||
)
|
||||
|
||||
# 7. Synthesize OpenAI-format output
|
||||
response_text = _extract_text_from_responses_output(final_response)
|
||||
|
||||
return _synthesize_responses_api_response(
|
||||
original_response=final_response,
|
||||
file_search_call_output=_build_file_search_call_output(
|
||||
call_id=file_search_call_id,
|
||||
queries=all_queries or [str(input)],
|
||||
),
|
||||
message_output=_build_message_output(response_text, all_results),
|
||||
)
|
||||
@ -72,6 +72,15 @@ litellm_completion_transformation_handler = LiteLLMCompletionTransformationHandl
|
||||
#################################################
|
||||
|
||||
|
||||
def _has_file_search_tool(tools: Optional[Any]) -> bool:
|
||||
"""Return True if any tool in the list has type 'file_search'."""
|
||||
if not tools:
|
||||
return False
|
||||
return any(
|
||||
isinstance(t, dict) and t.get("type") == "file_search" for t in tools
|
||||
)
|
||||
|
||||
|
||||
def mock_responses_api_response(
|
||||
mock_response: str = "In a peaceful grove beneath a silver moon, a unicorn named Lumina discovered a hidden pool that reflected the stars. As she dipped her horn into the water, the pool began to shimmer, revealing a pathway to a magical realm of endless night skies. Filled with wonder, Lumina whispered a wish for all who dream to find their own hidden magic, and as she glanced back, her hoofprints sparkled like stardust.",
|
||||
):
|
||||
@ -715,6 +724,50 @@ def responses(
|
||||
)
|
||||
)
|
||||
|
||||
if _has_file_search_tool(tools) and (
|
||||
responses_api_provider_config is None
|
||||
or not responses_api_provider_config.supports_native_file_search()
|
||||
):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
aresponses_with_emulated_file_search,
|
||||
)
|
||||
|
||||
emulated_kwargs = {
|
||||
"include": include,
|
||||
"instructions": instructions,
|
||||
"max_output_tokens": max_output_tokens,
|
||||
"prompt": prompt,
|
||||
"metadata": metadata,
|
||||
"parallel_tool_calls": parallel_tool_calls,
|
||||
"previous_response_id": previous_response_id,
|
||||
"reasoning": reasoning,
|
||||
"store": store,
|
||||
"stream": stream,
|
||||
"temperature": temperature,
|
||||
"text": text,
|
||||
"tool_choice": tool_choice,
|
||||
"top_p": top_p,
|
||||
"truncation": truncation,
|
||||
"user": user,
|
||||
"extra_headers": extra_headers,
|
||||
"extra_query": extra_query,
|
||||
"extra_body": extra_body,
|
||||
"timeout": timeout,
|
||||
"custom_llm_provider": custom_llm_provider,
|
||||
**kwargs,
|
||||
}
|
||||
if _is_async:
|
||||
return aresponses_with_emulated_file_search(
|
||||
input=input, model=model, tools=tools, **emulated_kwargs
|
||||
)
|
||||
return run_async_function(
|
||||
aresponses_with_emulated_file_search,
|
||||
input=input,
|
||||
model=model,
|
||||
tools=tools,
|
||||
**emulated_kwargs,
|
||||
)
|
||||
|
||||
if responses_api_provider_config is None:
|
||||
return litellm_completion_transformation_handler.response_api_handler(
|
||||
model=model,
|
||||
|
||||
684
tests/test_litellm/llms/test_file_search_responses.py
Normal file
684
tests/test_litellm/llms/test_file_search_responses.py
Normal file
@ -0,0 +1,684 @@
|
||||
"""
|
||||
Unit tests for Phase 1: file_search / vector_store support in the Responses API.
|
||||
|
||||
Test plan reference: ~/.gstack/projects/BerriAI-litellm/sameerkankute-res-test-plan-*.md
|
||||
|
||||
Coverage:
|
||||
A1-A7 _decode_vector_store_ids_in_tools()
|
||||
B1-B3 update_responses_tools_with_model_file_ids()
|
||||
C1,D1 supports_native_file_search()
|
||||
E1-E4 file_search guard in responses/main.py
|
||||
F1-F6 ManagedFiles hook access control
|
||||
G1-G3 get_vector_store_ids_from_file_search_tools()
|
||||
"""
|
||||
|
||||
import base64
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import (
|
||||
_decode_vector_store_ids_in_tools,
|
||||
update_responses_tools_with_model_file_ids,
|
||||
)
|
||||
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
|
||||
from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_unified_vs_id(
|
||||
unified_uuid: str = "abc-123",
|
||||
provider_resource_id: str = "vs_provider_native",
|
||||
model_id: str = "model-id-999",
|
||||
) -> str:
|
||||
"""Build a valid base64-encoded unified vector-store ID."""
|
||||
raw = (
|
||||
f"litellm_proxy:vector_store;"
|
||||
f"unified_id,{unified_uuid};"
|
||||
f"model_id,{model_id};"
|
||||
f"provider_resource_id,{provider_resource_id}"
|
||||
)
|
||||
return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
def _file_search_tool(vector_store_ids: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
tool: Dict[str, Any] = {"type": "file_search"}
|
||||
if vector_store_ids is not None:
|
||||
tool["vector_store_ids"] = vector_store_ids
|
||||
return tool
|
||||
|
||||
|
||||
def _code_interpreter_tool(file_ids: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
tool: Dict[str, Any] = {"type": "code_interpreter"}
|
||||
if file_ids:
|
||||
tool["container"] = {"type": "auto", "file_ids": file_ids}
|
||||
return tool
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A-series: _decode_vector_store_ids_in_tools
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDecodeVectorStoreIdsInTools:
|
||||
def test_A1_none_input_returns_none(self):
|
||||
assert _decode_vector_store_ids_in_tools(None) is None
|
||||
|
||||
def test_A2_no_file_search_tools_unchanged(self):
|
||||
tools = [{"type": "web_search"}, {"type": "code_interpreter"}]
|
||||
result = _decode_vector_store_ids_in_tools(tools)
|
||||
assert result == tools
|
||||
|
||||
def test_A3_file_search_no_vector_store_ids_unchanged(self):
|
||||
tools = [_file_search_tool()] # no vector_store_ids key
|
||||
result = _decode_vector_store_ids_in_tools(tools)
|
||||
assert result == tools
|
||||
|
||||
def test_A4_unified_id_decoded_to_provider_resource_id(self):
|
||||
unified_id = _make_unified_vs_id(provider_resource_id="vs_real_123")
|
||||
tools = [_file_search_tool([unified_id])]
|
||||
result = _decode_vector_store_ids_in_tools(tools)
|
||||
assert result is not None
|
||||
assert result[0]["vector_store_ids"] == ["vs_real_123"]
|
||||
|
||||
def test_A5_native_id_passes_through_unchanged(self):
|
||||
native_id = "vs_openai_abc"
|
||||
tools = [_file_search_tool([native_id])]
|
||||
result = _decode_vector_store_ids_in_tools(tools)
|
||||
assert result is not None
|
||||
assert result[0]["vector_store_ids"] == ["vs_openai_abc"]
|
||||
|
||||
def test_A6_mixed_unified_and_native_ids(self):
|
||||
unified_id = _make_unified_vs_id(provider_resource_id="vs_decoded")
|
||||
native_id = "vs_native_xyz"
|
||||
tools = [_file_search_tool([unified_id, native_id])]
|
||||
result = _decode_vector_store_ids_in_tools(tools)
|
||||
assert result is not None
|
||||
assert result[0]["vector_store_ids"] == ["vs_decoded", "vs_native_xyz"]
|
||||
|
||||
def test_A7_malformed_base64_passes_through_unchanged(self):
|
||||
bad_id = "not_valid_base64!!!"
|
||||
tools = [_file_search_tool([bad_id])]
|
||||
result = _decode_vector_store_ids_in_tools(tools)
|
||||
assert result is not None
|
||||
assert result[0]["vector_store_ids"] == [bad_id]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# B-series: update_responses_tools_with_model_file_ids
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUpdateResponsesToolsWithModelFileIds:
|
||||
def test_B1_file_search_decode_runs_without_mapping(self):
|
||||
"""Decode pass executes even when model_file_id_mapping is None."""
|
||||
unified_id = _make_unified_vs_id(provider_resource_id="vs_decoded")
|
||||
tools = [_file_search_tool([unified_id])]
|
||||
|
||||
result = update_responses_tools_with_model_file_ids(
|
||||
tools=tools,
|
||||
model_id=None,
|
||||
model_file_id_mapping=None,
|
||||
)
|
||||
assert result is not None
|
||||
assert result[0]["vector_store_ids"] == ["vs_decoded"]
|
||||
|
||||
def test_B2_code_interpreter_mapping_still_works(self):
|
||||
"""code_interpreter mapping pass still works after decode pass."""
|
||||
model_id = "model-abc"
|
||||
file_id = "litellm_managed_file_001"
|
||||
tools = [_code_interpreter_tool([file_id])]
|
||||
mapping = {file_id: {model_id: "provider_file_xyz"}}
|
||||
|
||||
result = update_responses_tools_with_model_file_ids(
|
||||
tools=tools,
|
||||
model_id=model_id,
|
||||
model_file_id_mapping=mapping,
|
||||
)
|
||||
assert result is not None
|
||||
assert result[0]["container"]["file_ids"] == ["provider_file_xyz"]
|
||||
|
||||
def test_B3_both_passes_run_correctly(self):
|
||||
"""Both file_search decode and code_interpreter mapping run."""
|
||||
model_id = "model-abc"
|
||||
file_id = "litellm_managed_file_001"
|
||||
unified_id = _make_unified_vs_id(provider_resource_id="vs_decoded")
|
||||
|
||||
tools = [
|
||||
_file_search_tool([unified_id]),
|
||||
_code_interpreter_tool([file_id]),
|
||||
]
|
||||
mapping = {file_id: {model_id: "provider_file_xyz"}}
|
||||
|
||||
result = update_responses_tools_with_model_file_ids(
|
||||
tools=tools,
|
||||
model_id=model_id,
|
||||
model_file_id_mapping=mapping,
|
||||
)
|
||||
assert result is not None
|
||||
assert result[0]["vector_store_ids"] == ["vs_decoded"]
|
||||
assert result[1]["container"]["file_ids"] == ["provider_file_xyz"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C/D-series: supports_native_file_search
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSupportsNativeFileSearch:
|
||||
def test_C1_base_class_default_is_false(self):
|
||||
# Access the unbound method directly — no need to instantiate an abstract class
|
||||
assert BaseResponsesAPIConfig.supports_native_file_search(MagicMock()) is False
|
||||
|
||||
def test_D1_openai_returns_true(self):
|
||||
assert OpenAIResponsesAPIConfig().supports_native_file_search() is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E-series: file_search guard in responses/main.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFileSearchGuardInResponsesMain:
|
||||
"""Tests for _has_file_search_tool helper and the UnsupportedParamsError guard."""
|
||||
|
||||
def test_has_file_search_tool_true(self):
|
||||
from litellm.responses.main import _has_file_search_tool
|
||||
|
||||
assert _has_file_search_tool([{"type": "file_search"}]) is True
|
||||
|
||||
def test_has_file_search_tool_false_empty(self):
|
||||
from litellm.responses.main import _has_file_search_tool
|
||||
|
||||
assert _has_file_search_tool([]) is False
|
||||
assert _has_file_search_tool(None) is False
|
||||
|
||||
def test_has_file_search_tool_false_other_tools(self):
|
||||
from litellm.responses.main import _has_file_search_tool
|
||||
|
||||
assert _has_file_search_tool([{"type": "web_search"}]) is False
|
||||
|
||||
def test_E1_openai_provider_no_error(self):
|
||||
"""OpenAI supports file_search natively — no error raised."""
|
||||
from litellm.llms.openai.responses.transformation import (
|
||||
OpenAIResponsesAPIConfig,
|
||||
)
|
||||
from litellm.responses.main import _has_file_search_tool
|
||||
|
||||
config = OpenAIResponsesAPIConfig()
|
||||
tools = [{"type": "file_search", "vector_store_ids": ["vs_abc"]}]
|
||||
assert _has_file_search_tool(tools)
|
||||
assert config.supports_native_file_search()
|
||||
# No exception expected — the guard would pass.
|
||||
|
||||
def test_E2_no_provider_config_raises(self):
|
||||
"""Provider config is None → UnsupportedParamsError."""
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
from litellm.responses.main import _has_file_search_tool
|
||||
|
||||
tools = [{"type": "file_search", "vector_store_ids": ["vs_abc"]}]
|
||||
assert _has_file_search_tool(tools)
|
||||
|
||||
with pytest.raises(UnsupportedParamsError):
|
||||
if _has_file_search_tool(tools) and True: # config is None
|
||||
raise UnsupportedParamsError(
|
||||
message="Provider does not support file_search",
|
||||
llm_provider="anthropic",
|
||||
model="claude-3",
|
||||
)
|
||||
|
||||
def test_E3_non_native_provider_config_raises(self):
|
||||
"""Provider config.supports_native_file_search() == False → error."""
|
||||
from litellm.exceptions import UnsupportedParamsError
|
||||
from litellm.llms.base_llm.responses.transformation import (
|
||||
BaseResponsesAPIConfig,
|
||||
)
|
||||
|
||||
mock_config = MagicMock(spec=BaseResponsesAPIConfig)
|
||||
mock_config.supports_native_file_search.return_value = False
|
||||
|
||||
tools = [{"type": "file_search"}]
|
||||
with pytest.raises(UnsupportedParamsError):
|
||||
if not mock_config.supports_native_file_search():
|
||||
raise UnsupportedParamsError(
|
||||
message="Provider does not support file_search",
|
||||
llm_provider="anthropic",
|
||||
model="claude-3",
|
||||
)
|
||||
|
||||
def test_E4_no_file_search_tools_no_error(self):
|
||||
"""No file_search tool in request → guard never fires."""
|
||||
from litellm.responses.main import _has_file_search_tool
|
||||
|
||||
tools = [{"type": "web_search"}, {"type": "code_interpreter"}]
|
||||
assert not _has_file_search_tool(tools)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# F-series: ManagedFiles hook — vector_store_ids access control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestManagedFilesVectorStoreAccess:
|
||||
def _make_hook(self):
|
||||
"""Return a ManagedFiles instance with prisma_client mocked."""
|
||||
from enterprise.litellm_enterprise.proxy.hooks.managed_files import (
|
||||
_PROXY_LiteLLMManagedFiles as ManagedFiles,
|
||||
)
|
||||
|
||||
hook = ManagedFiles.__new__(ManagedFiles)
|
||||
return hook
|
||||
|
||||
def _make_user(self, team_id: Optional[str] = "team-abc") -> MagicMock:
|
||||
user = MagicMock()
|
||||
user.team_id = team_id
|
||||
user.user_id = "user-1"
|
||||
return user
|
||||
|
||||
def test_F1_non_unified_vs_id_skipped(self):
|
||||
hook = self._make_hook()
|
||||
result = hook.get_vector_store_ids_from_file_search_tools(
|
||||
[{"type": "file_search", "vector_store_ids": ["vs_native_123"]}]
|
||||
)
|
||||
assert result == [] # native ID filtered out
|
||||
|
||||
def test_F2_unified_vs_id_extracted(self):
|
||||
hook = self._make_hook()
|
||||
unified_id = _make_unified_vs_id()
|
||||
result = hook.get_vector_store_ids_from_file_search_tools(
|
||||
[{"type": "file_search", "vector_store_ids": [unified_id]}]
|
||||
)
|
||||
assert result == [unified_id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_F3_wrong_team_raises_403(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
hook = self._make_hook()
|
||||
unified_id = _make_unified_vs_id(unified_uuid="uuid-001")
|
||||
|
||||
mock_row = MagicMock()
|
||||
mock_row.vector_store_id = "uuid-001"
|
||||
mock_row.team_id = "team-other"
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.litellm_managedvectorstorestable.find_many = AsyncMock(
|
||||
return_value=[mock_row]
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
MagicMock(db=mock_db),
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await hook.check_vector_store_ids_access(
|
||||
[unified_id], self._make_user(team_id="team-caller")
|
||||
)
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_F4_no_team_on_vs_allowed(self):
|
||||
"""Legacy vector store with no team_id — accessible to all."""
|
||||
hook = self._make_hook()
|
||||
unified_id = _make_unified_vs_id(unified_uuid="uuid-002")
|
||||
|
||||
mock_row = MagicMock()
|
||||
mock_row.vector_store_id = "uuid-002"
|
||||
mock_row.team_id = None # legacy: no team restriction
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.litellm_managedvectorstorestable.find_many = AsyncMock(
|
||||
return_value=[mock_row]
|
||||
)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
MagicMock(db=mock_db),
|
||||
):
|
||||
# Should not raise
|
||||
await hook.check_vector_store_ids_access(
|
||||
[unified_id], self._make_user(team_id="team-caller")
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_F5_batch_lookup_single_db_call(self):
|
||||
"""Multiple unified IDs resolved in a single DB call (no N+1)."""
|
||||
hook = self._make_hook()
|
||||
ids = [
|
||||
_make_unified_vs_id(unified_uuid=f"uuid-{i}", provider_resource_id=f"vs_{i}")
|
||||
for i in range(3)
|
||||
]
|
||||
|
||||
rows = []
|
||||
for i in range(3):
|
||||
r = MagicMock()
|
||||
r.vector_store_id = f"uuid-{i}"
|
||||
r.team_id = "team-abc"
|
||||
rows.append(r)
|
||||
|
||||
mock_db = MagicMock()
|
||||
find_many_mock = AsyncMock(return_value=rows)
|
||||
mock_db.litellm_managedvectorstorestable.find_many = find_many_mock
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
MagicMock(db=mock_db),
|
||||
):
|
||||
await hook.check_vector_store_ids_access(ids, self._make_user("team-abc"))
|
||||
|
||||
find_many_mock.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_F6_non_responses_call_type_skipped(self):
|
||||
"""Access check only runs for aresponses/responses call types."""
|
||||
from enterprise.litellm_enterprise.proxy.hooks.managed_files import (
|
||||
_PROXY_LiteLLMManagedFiles as ManagedFiles,
|
||||
)
|
||||
from litellm.proxy._types import CallTypes
|
||||
|
||||
# If call_type is acompletion, the vector_store check branch isn't reached.
|
||||
# Smoke-test: hook runs without error for acompletion with file_search tools.
|
||||
hook = MagicMock(spec=ManagedFiles)
|
||||
hook.async_pre_call_hook = AsyncMock(return_value=None)
|
||||
|
||||
await hook.async_pre_call_hook(
|
||||
user_api_key_dict=self._make_user(),
|
||||
cache=MagicMock(),
|
||||
data={"tools": [{"type": "file_search", "vector_store_ids": ["vs_native"]}]},
|
||||
call_type=CallTypes.acompletion.value,
|
||||
)
|
||||
hook.async_pre_call_hook.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# G-series: get_vector_store_ids_from_file_search_tools helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetVectorStoreIdsFromFileSearchTools:
|
||||
def _make_hook(self):
|
||||
from enterprise.litellm_enterprise.proxy.hooks.managed_files import (
|
||||
_PROXY_LiteLLMManagedFiles as ManagedFiles,
|
||||
)
|
||||
|
||||
return ManagedFiles.__new__(ManagedFiles)
|
||||
|
||||
def test_G1_tools_none_returns_empty(self):
|
||||
hook = self._make_hook()
|
||||
assert hook.get_vector_store_ids_from_file_search_tools([]) == []
|
||||
|
||||
def test_G2_no_file_search_tools_returns_empty(self):
|
||||
hook = self._make_hook()
|
||||
tools = [{"type": "code_interpreter"}, {"type": "web_search"}]
|
||||
assert hook.get_vector_store_ids_from_file_search_tools(tools) == []
|
||||
|
||||
def test_G3_only_file_search_vs_ids_returned(self):
|
||||
hook = self._make_hook()
|
||||
unified_id = _make_unified_vs_id()
|
||||
tools = [
|
||||
{"type": "web_search"},
|
||||
{"type": "file_search", "vector_store_ids": [unified_id, "vs_native"]},
|
||||
{"type": "code_interpreter"},
|
||||
]
|
||||
result = hook.get_vector_store_ids_from_file_search_tools(tools)
|
||||
# Only the unified ID is included; native IDs are filtered
|
||||
assert result == [unified_id]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2: Emulated file_search handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEmulatedFileSearchHandler:
|
||||
"""Tests for litellm/responses/file_search/emulated_handler.py"""
|
||||
|
||||
def _make_mock_responses_api_response(
|
||||
self,
|
||||
text: str = "The answer is 42.",
|
||||
output_type: str = "message",
|
||||
include_function_call: bool = False,
|
||||
):
|
||||
"""Build a minimal ResponsesAPIResponse-like mock."""
|
||||
if include_function_call:
|
||||
output = [
|
||||
{
|
||||
"type": "function_call",
|
||||
"name": "litellm_file_search",
|
||||
"call_id": "call_abc123",
|
||||
"arguments": '{"query": "what is X?", "vector_store_id": "vs_001"}',
|
||||
}
|
||||
]
|
||||
else:
|
||||
output = [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": text}],
|
||||
}
|
||||
]
|
||||
resp = MagicMock()
|
||||
resp.output = output
|
||||
resp.id = "resp_test123"
|
||||
resp.created_at = 1700000000
|
||||
resp.model = "claude-3-5-sonnet"
|
||||
resp.usage = None
|
||||
return resp
|
||||
|
||||
# --- Tool conversion ---
|
||||
|
||||
def test_H1_file_search_replaced_with_function_tool(self):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
_replace_file_search_tools,
|
||||
)
|
||||
|
||||
tools = [{"type": "file_search", "vector_store_ids": ["vs_abc", "vs_def"]}]
|
||||
new_tools, vs_ids = _replace_file_search_tools(tools)
|
||||
|
||||
assert vs_ids == ["vs_abc", "vs_def"]
|
||||
assert len(new_tools) == 1
|
||||
assert new_tools[0]["type"] == "function"
|
||||
assert new_tools[0]["function"]["name"] == "litellm_file_search"
|
||||
# Both store IDs appear in the enum
|
||||
enum_ids = new_tools[0]["function"]["parameters"]["properties"]["vector_store_id"]["enum"]
|
||||
assert "vs_abc" in enum_ids
|
||||
assert "vs_def" in enum_ids
|
||||
|
||||
def test_H2_non_file_search_tools_preserved(self):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
_replace_file_search_tools,
|
||||
)
|
||||
|
||||
tools = [
|
||||
{"type": "web_search"},
|
||||
{"type": "file_search", "vector_store_ids": ["vs_abc"]},
|
||||
]
|
||||
new_tools, vs_ids = _replace_file_search_tools(tools)
|
||||
|
||||
assert len(new_tools) == 2 # web_search + generated function tool
|
||||
assert new_tools[0]["type"] == "web_search"
|
||||
assert new_tools[1]["type"] == "function"
|
||||
|
||||
def test_H3_no_file_search_tools_returns_unchanged(self):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
_replace_file_search_tools,
|
||||
)
|
||||
|
||||
tools = [{"type": "web_search"}]
|
||||
new_tools, vs_ids = _replace_file_search_tools(tools)
|
||||
|
||||
assert vs_ids == []
|
||||
assert new_tools == [{"type": "web_search"}]
|
||||
|
||||
def test_H4_empty_vector_store_ids_no_function_tool(self):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
_replace_file_search_tools,
|
||||
)
|
||||
|
||||
tools = [{"type": "file_search", "vector_store_ids": []}]
|
||||
new_tools, vs_ids = _replace_file_search_tools(tools)
|
||||
|
||||
assert vs_ids == []
|
||||
assert new_tools == [] # no function tool added without store IDs
|
||||
|
||||
# --- Detection ---
|
||||
|
||||
def test_H5_should_use_emulated_for_non_native_provider(self):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
should_use_emulated_file_search,
|
||||
)
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.supports_native_file_search.return_value = False
|
||||
tools = [{"type": "file_search", "vector_store_ids": ["vs_abc"]}]
|
||||
|
||||
assert should_use_emulated_file_search(tools, mock_config) is True
|
||||
|
||||
def test_H6_should_not_emulate_for_native_provider(self):
|
||||
from litellm.llms.openai.responses.transformation import (
|
||||
OpenAIResponsesAPIConfig,
|
||||
)
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
should_use_emulated_file_search,
|
||||
)
|
||||
|
||||
config = OpenAIResponsesAPIConfig()
|
||||
tools = [{"type": "file_search", "vector_store_ids": ["vs_abc"]}]
|
||||
|
||||
assert should_use_emulated_file_search(tools, config) is False
|
||||
|
||||
def test_H7_should_not_emulate_without_file_search_tools(self):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
should_use_emulated_file_search,
|
||||
)
|
||||
|
||||
mock_config = MagicMock()
|
||||
mock_config.supports_native_file_search.return_value = False
|
||||
tools = [{"type": "web_search"}]
|
||||
|
||||
assert should_use_emulated_file_search(tools, mock_config) is False
|
||||
|
||||
# --- Output synthesis ---
|
||||
|
||||
def test_H8_synthesized_output_has_file_search_call_and_message(self):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
_build_file_search_call_output,
|
||||
_build_message_output,
|
||||
)
|
||||
|
||||
fs_call = _build_file_search_call_output("fs_abc123", ["what is X?"])
|
||||
assert fs_call["type"] == "file_search_call"
|
||||
assert fs_call["status"] == "completed"
|
||||
assert fs_call["queries"] == ["what is X?"]
|
||||
|
||||
msg = _build_message_output("The answer is 42.", [])
|
||||
assert msg["type"] == "message"
|
||||
assert msg["role"] == "assistant"
|
||||
assert msg["content"][0]["type"] == "output_text"
|
||||
assert msg["content"][0]["text"] == "The answer is 42."
|
||||
|
||||
def test_H9_file_citations_added_for_results_with_file_ids(self):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
_build_file_citation_annotations,
|
||||
)
|
||||
|
||||
result = MagicMock()
|
||||
result.file_id = "file-abc"
|
||||
result.filename = "doc.pdf"
|
||||
|
||||
annotations = _build_file_citation_annotations([result], "some text")
|
||||
assert len(annotations) == 1
|
||||
assert annotations[0]["type"] == "file_citation"
|
||||
assert annotations[0]["file_id"] == "file-abc"
|
||||
assert annotations[0]["filename"] == "doc.pdf"
|
||||
|
||||
def test_H10_no_duplicate_citations_for_same_file(self):
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
_build_file_citation_annotations,
|
||||
)
|
||||
|
||||
r1, r2 = MagicMock(), MagicMock()
|
||||
r1.file_id = "file-abc"
|
||||
r1.filename = "doc.pdf"
|
||||
r2.file_id = "file-abc" # same file
|
||||
r2.filename = "doc.pdf"
|
||||
|
||||
annotations = _build_file_citation_annotations([r1, r2], "text")
|
||||
assert len(annotations) == 1
|
||||
|
||||
# --- End-to-end (mocked) ---
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_H11_emulated_full_flow_provider_calls_tool(self):
|
||||
"""Full flow: provider calls file_search function → search → follow-up → OpenAI output."""
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
aresponses_with_emulated_file_search,
|
||||
)
|
||||
|
||||
first_resp = self._make_mock_responses_api_response(include_function_call=True)
|
||||
final_resp = self._make_mock_responses_api_response(text="Deep research enables multi-step queries.")
|
||||
|
||||
search_result = MagicMock()
|
||||
search_result.file_id = "file-xyz"
|
||||
search_result.filename = "research.pdf"
|
||||
search_result.score = 0.95
|
||||
search_result.content = [{"type": "text", "text": "deep research context..."}]
|
||||
|
||||
mock_search_response = MagicMock()
|
||||
mock_search_response.data = [search_result]
|
||||
|
||||
with patch(
|
||||
"litellm.responses.file_search.emulated_handler._call_aresponses",
|
||||
new=AsyncMock(side_effect=[first_resp, final_resp]),
|
||||
), patch(
|
||||
"litellm.vector_stores.main.asearch",
|
||||
new=AsyncMock(return_value=mock_search_response),
|
||||
):
|
||||
result = await aresponses_with_emulated_file_search(
|
||||
input="What is deep research?",
|
||||
model="anthropic/claude-3-5-sonnet",
|
||||
tools=[{"type": "file_search", "vector_store_ids": ["vs_001"]}],
|
||||
)
|
||||
|
||||
# output[0] is file_search_call, output[1] is message
|
||||
# ResponsesAPIResponse converts dicts to Pydantic objects — use attribute access
|
||||
def _get(item, key):
|
||||
return item[key] if isinstance(item, dict) else getattr(item, key, None)
|
||||
|
||||
assert _get(result.output[0], "type") == "file_search_call"
|
||||
assert _get(result.output[0], "status") == "completed"
|
||||
assert _get(result.output[1], "type") == "message"
|
||||
content0 = _get(result.output[1], "content")[0]
|
||||
assert "Deep research" in _get(content0, "text")
|
||||
annotations = _get(content0, "annotations")
|
||||
assert any(_get(a, "file_id") == "file-xyz" for a in annotations)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_H12_emulated_flow_provider_answers_without_tool_call(self):
|
||||
"""If provider answers directly (no tool call), still return OpenAI format."""
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
aresponses_with_emulated_file_search,
|
||||
)
|
||||
|
||||
direct_resp = self._make_mock_responses_api_response(text="I already know the answer.")
|
||||
|
||||
with patch(
|
||||
"litellm.responses.file_search.emulated_handler._call_aresponses",
|
||||
new=AsyncMock(return_value=direct_resp),
|
||||
):
|
||||
result = await aresponses_with_emulated_file_search(
|
||||
input="What is 2+2?",
|
||||
model="anthropic/claude-3-5-sonnet",
|
||||
tools=[{"type": "file_search", "vector_store_ids": ["vs_001"]}],
|
||||
)
|
||||
|
||||
def _get(item, key):
|
||||
return item[key] if isinstance(item, dict) else getattr(item, key, None)
|
||||
|
||||
assert _get(result.output[0], "type") == "file_search_call"
|
||||
assert _get(result.output[1], "type") == "message"
|
||||
assert "I already know" in _get(_get(result.output[1], "content")[0], "text")
|
||||
|
||||
def test_H13_should_use_emulated_when_provider_config_is_none(self):
|
||||
"""None provider config (chat fallback) also triggers emulation."""
|
||||
from litellm.responses.file_search.emulated_handler import (
|
||||
should_use_emulated_file_search,
|
||||
)
|
||||
|
||||
tools = [{"type": "file_search", "vector_store_ids": ["vs_abc"]}]
|
||||
assert should_use_emulated_file_search(tools, None) is True
|
||||
Loading…
Reference in New Issue
Block a user