fix(vector-stores): surface retrieval failures to the API caller - #39516
mateo-berri merged 10 commits into
Conversation
A vector store search that fails is swallowed by the pre-call hook, so the request goes to the model with an un-augmented prompt and the caller gets a 200 answering from the model's own knowledge with no way to tell the knowledge base was skipped. Failed searches now ride the same channel their successes already use: a vector_store_search_failures entry on provider_specific_fields naming the store id, provider, and error. That is additive and always on. For callers who would rather fail than answer ungrounded, litellm_settings vector_store_search_failure_mode: error raises VectorStoreSearchError (400) instead; the default stays annotate, today's permissive behavior. The hook's outer catch-all also now names the requested vector store ids in its log line, and only wraps the augmentation itself, so the fail-closed raise is not swallowed by it.
Greptile SummaryThis PR makes vector-store retrieval failures visible to API callers while preserving permissive default behavior.
Confidence Score: 5/5The PR appears safe to merge; no new actionable issues or outstanding previous findings remain. The invalid-mode failure was corrected by validating configuration and falling back to annotation, the request-state concern was withdrawn after confirming the established metadata channel, and the remaining typing thread was manually resolved. No changes were made after the previous review, and the current diff has no confirmed rule violations or merge-blocking behavior.
|
| Filename | Overview |
|---|---|
| litellm/integrations/vector_store_integrations/vector_store_pre_call_hook.py | Aggregates search outcomes, propagates failure metadata, supports fail-closed behavior, and safely handles invalid failure-mode configuration. |
| litellm/exceptions.py | Adds a typed bad-request exception containing structured vector-store failures and an SDK-facing error code. |
| litellm/types/vector_stores.py | Defines the supported failure modes and immutable structured failure metadata. |
| litellm/init.py | Exports the new exception and establishes annotation as the backward-compatible default mode. |
| tests/test_litellm/integrations/vector_store_integrations/test_vector_store_pre_call_hook.py | Adds focused mocked regression tests without weakening existing coverage or introducing network dependencies. |
Reviews (6): Last reviewed commit: "fix(vector_stores): default the search-c..." | Re-trigger Greptile
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
| verbose_logger.exception("Error in VectorStorePreCallHook: %s", e) | ||
| # Return original parameters on error | ||
| return model, messages, non_default_params | ||
| verbose_logger.debug( |
There was a problem hiding this comment.
Low: Backend exception details exposed to callers
str(search_error) is returned in normal and streaming completion responses and included in the opt-in 400 message. An authenticated caller can use failing searches to disclose details from provider SDKs and direct clients, such as internal Valkey/Milvus addresses or backend error text; log the original exception server-side, but expose a stable sanitized message or error code here.
| verbose_logger.debug( | |
| error="Vector store search failed", |
There was a problem hiding this comment.
Provider error text already reaches callers via the direct vector store search endpoint, and litellm has no redaction knob. Hiding it defeats the ticket: nobody could tell which store broke.
There was a problem hiding this comment.
Thanks for the detail. I can't automatically confirm this is safe to dismiss, so I'm leaving the thread open for a maintainer to make the call.
PR overviewThis pull request changes vector-store retrieval error handling so search failures are surfaced to API callers in both normal and streaming completion responses. One low-impact information-disclosure issue remains open: authenticated callers who trigger failed searches may receive raw backend exception details, potentially revealing internal service addresses or provider error text. No issues have yet been addressed, so caller-facing errors should be sanitized while full details remain in server-side logs. Open issues (1)
Fixed/addressed: 0 · PR risk: 4/10 |
…_injection' into litellm_vector_store_surface_retrieval_failure
…recognized litellm_settings keys are set on the litellm module with no allowlist, so a typo in vector_store_search_failure_mode reached assert_never and turned every vector-store request into a 500. Validate the configured value and fall back to the permissive default with a warning naming the supported modes.
|
bugbot run |
…face_retrieval_failure
basedpyright counts a named capture after an exhaustive match under reportUnnecessaryComparison, which pushed the merged tree one over the budget; the wildcard case with assert_never on the bound subject is the shape the rest of the codebase uses
…itellm_vector_store_surface_retrieval_failure
|
bugbot run |
…itellm_vector_store_surface_retrieval_failure
…LIT002 stays within budget
|
bugbot run |
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit c4d09a3. Configure here.
5c82182
into
litellm_internal_staging
TLDR
Problem this solves:
How it solves it:
provider_specific_fields, next tosearch_results/v1/responsescarries the same list as a top-level response fieldvector_store_search_failure_mode: errorfails closed insteadUser Flow
Before: a developer whose support bot grounds answers in a knowledge base gets confident, wrong answers after that knowledge base breaks, and nothing in the response says so
"vector_store_ids": ["their-store"]and a question only the knowledge base can answer"I don't have access to that runbook", written from the model's own knowledgeprovider_specific_fieldson the message and find only{"refusal": null}: no store id, no error, nothing naming the knowledge basex-litellm-*response headers and none of them mentions a vector store eitherAfter: the same request comes back naming the store that failed, and an operator who would rather fail than answer ungrounded can turn the failure into a 400
"vector_store_ids": ["their-store"]provider_specific_fieldson the message now also carriesvector_store_search_failures, a list naming the store id, its provider, and the provider's error textvector_store_search_failuresarrives as a top-level field on the responsevector_store_search_failure_mode: errorunderlitellm_settingsand restarts the proxy"type": "invalid_request_error"and a message naming every store that failed and why, on both endpointssearch_resultsas before, and no failures fieldDesign: why a response field plus an opt-in error
Three signals were on the table, and the answer is different for the two things a caller might want.
For "tell me it happened", the response field is right. The hook already owns exactly that lane: successful searches ride
provider_specific_fields.search_resultstoday, so failures ridingprovider_specific_fields.vector_store_search_failuresneed no new concept and no new plumbing. It is purely additive: same status code, same content, same existing fields, so it ships on by default without a knob.Both endpoints the hook actually runs on carry it. On chat completions it sits under
provider_specific_fieldson the message and on the streaming delta, next tosearch_results./v1/responseshas no such container, so it rides the response object itself as a top-levelvector_store_search_failures, whichResponsesAPIResponsealready allows and already serializes.search_resultsis deliberately not added there: it would put whole retrieved documents on a surface that has never carried them, which is a bigger change than this fix needs.A header was rejected. The hook also runs on the pure SDK path (
litellm.acompletion), where there are no response headers at all, so a header would leave SDK callers exactly as blind as they are today. It also cannot carry per-store structured data without inventing an encoding that each surface would have to re-implement.For "don't answer without the knowledge base", only an error works, and that is a real behavior change, so it is opt-in:
litellm_settings.vector_store_search_failure_modedefaults toannotate(today's behavior) and can be set toerror. Failing closed by default would turn one bad knowledge base into failed requests for everyone who has one configured. The error is aBadRequestErrorsubclass, so the caller gets HTTP 400 with"type": "invalid_request_error", matching what OpenAI returns for a bad vector store id, and 400s are never retried and never cool a deployment down.litellm_settingskeys are set on thelitellmmodule with no allowlist, so a typo likeerorrwould otherwise reach the exhaustive match: an unrecognized value falls back toannotateand logs a warning naming the supported modes, so a misconfiguration degrades to today's behavior instead of failing every vector-store request.Relevant issues
Linear ticket
Resolves LIT-6809
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Real OpenAI
gpt-5.6for chat andtext-embedding-3-smallfor the query embeddings, real AWS S3 Vectors for the knowledge base, real spend on every leg.Shared setup, run once against each proxy:
config_error.yamlis the same file plus:config_typo.yamlis the same file withvector_store_search_failure_mode: erorr, the misconfiguration case.The knowledge base is a made-up "Project Halcyon" runbook whose only distinctive fact is that the Reykjavik shard freeze hold must be exactly 900 seconds, so a grounded answer proves retrieval ran and an ungrounded one proves it did not.
Before (0e537d2, this PR's merge base against
litellm_vector_store_hook_router_injection)Re-run on the
/live-pr-riskrig atlitellm_internal_stagingdf3b8a6 (two uvicorn workers, its own Postgres): every case below answers the same way there, so what ships without this PR is what these cases show.Case 1: healthy store, chat completions
curl -s -w '\nHTTP %{http_code}\n' $PROXY/v1/chat/completions -H "Authorization: Bearer sk-lit6809" -H 'Content-Type: application/json' -d '{"model":"gpt-5.6","vector_store_ids":["lit6750-qa-vectors:litellm-index-ac0bfb1a"],"messages":[{"role":"user","content":"'"$Q"'"}]}'HTTP 200, grounded:"content":"The Reykjavik shard freeze must use a hold duration of exactly 900 seconds."withprovider_specific_fieldscarryingsearch_resultsCase 2: broken store, chat completions, default settings
"vector_store_ids":["lit6750-qa-vectors:lit6809-index-never-created"]HTTP 200, ungrounded:"content":"I don’t have access to the Project Halcyon runbook; please provide the relevant excerpt.""provider_specific_fields":{"refusal":null}: nothing names the store, the provider, or the errorx-litellm-*headers are present and normal (x-litellm-response-cost: 0.0016920000000000001,x-litellm-attempted-retries: 0), andgrep -c -i vector_storeover every response header returns0Case 3: broken store, streaming chat completions, default settings
"stream":true, piped throughgrep -E 'vector_store_search_failures|"error"'(no chunk names a vector store failure)Case 4: broken store, chat completions,
vector_store_search_failure_mode: error--config config_error.yaml; the proxy log confirmssetting litellm.vector_store_search_failure_mode=errorHTTP 200, ungrounded:"content":"I don’t have access to the Project Halcyon runbook, so I can’t verify the Reykjavik shard freeze hold duration.". The setting does not exist yet, so asking to fail closed changes nothingCase 5: broken store, /v1/responses,
vector_store_search_failure_mode: errorcurl -s -w '\nHTTP %{http_code}\n' $PROXY/v1/responses -H "Authorization: Bearer sk-lit6809" -H 'Content-Type: application/json' -d '{"model":"gpt-5.6","vector_store_ids":["lit6750-qa-vectors:lit6809-index-never-created"],"input":"'"$Q"'"}'HTTP 200, ungrounded:"text":"I don’t have access to the Project Halcyon runbook to verify the Reykjavik shard freeze hold duration."Case 6: broken store, /v1/responses, default settings
HTTP 200, ungrounded:"text":"I don’t have access to the Project Halcyon runbook to verify the exact hold duration."grep -c vector_store_search_failuresandgrep -c -i vector_storeon the response body both return0: nothing in the body names the storeCase 7: healthy store, /v1/responses, default settings
"vector_store_ids":["lit6750-qa-vectors:litellm-index-ac0bfb1a"]HTTP 200, grounded:The Reykjavik shard freeze must use a hold duration of exactly 900 seconds., so retrieval does run on this surfacegrep -c search_resultson the response body returns0:/v1/responsesalready carries neither the results nor, after this PR, the failuresAfter (c4d09a3: the PR merged onto staging df3b8a6, plus 376f74c, which rebinds the failure mode before the
matchso basedpyright stops counting theassert_neverarm, and the tip commit itself, which swaps two[]defaults for()in a debug log so LIT002 stays under the ceiling staging ratcheted down)Same rig, head side, two uvicorn workers and its own Postgres,
litellm.__file__asserted inside the PR worktree before boot. Every case below was re-run at this tip.Case 1: healthy store, chat completions
HTTP 200, grounded:"content":"The Reykjavik shard freeze must use a hold duration of exactly 900 seconds."withsearch_resultsunchangedCase 2: broken store, chat completions, default settings
HTTP 200, still ungrounded, and now the message carries the failure:Case 3: broken store, streaming chat completions, default settings
"stream":truedata: {"id":"chatcmpl-EKbSuLXqaPbMK1FbBwWXpsERJJpuo","created":1788579285,"model":"gpt-5.6","object":"chat.completion.chunk","choices":[{"finish_reason":"stop","index":0,"delta":{"provider_specific_fields":{"vector_store_search_failures":[{"vector_store_id":"lit6750-qa-vectors:lit6809-index-never-created","custom_llm_provider":"s3_vectors","error":"litellm.NotFoundError: S3_vectorsException - {\"message\":\"The specified index could not be found\"}"}]}}}],"service_tier":"default","obfuscation":"3c3Rg851bQj"}Case 4: broken store, chat completions,
vector_store_search_failure_mode: error--config config_error.yamlHTTP 400:{"error":{"message":"litellm.BadRequestError: The request could not be grounded in every configured vector store. 1 vector store search(es) failed: lit6750-qa-vectors:lit6809-index-never-created: litellm.NotFoundError: S3_vectorsException - {\"message\":\"The specified index could not be found\"}. Received Model Group=gpt-5.6\nAvailable Model Group Fallbacks=None","type":"invalid_request_error","param":null,"code":"400"}}HTTP 200with the grounded answer, so a healthy store is untouched by the knob"stream":trueon the same broken-store command is alsoHTTP 400with the same body, before any SSE event opens["...litellm-index-ac0bfb1a","...lit6809-index-never-created"]) isHTTP 400too:errormeans every configured store must answer, which is the semantics the setting name promisesfailurerow withspend = 0and onetext-embedding-3-smallrow for the query embedding: the model call never happens, so nothing is billed for the answer that was not servedCase 5: broken store, /v1/responses,
vector_store_search_failure_mode: errorHTTP 400with the same body, so the fail-closed signal is not chat-completions-only"stream":trueon/v1/responses, and for a model that reaches/v1/responsesthrough the chat-completions bridge (claude-sonnet-5, real Anthropic):HTTP 400,Received Model Group=claude-sonnet-5Case 6: broken store, /v1/responses, default settings
HTTP 200, ungrounded ("text":"I don’t have access to the Project Halcyon runbook to verify the Reykjavik shard freeze hold duration."), and the response object itself now names the failure:HTTP 200, grounded ("The Reykjavik shard freeze must use a hold duration of exactly 900 seconds."), and novector_store_search_failureskey at all, so a healthy store leaves this surface byte-for-byte as it wasclaude-sonnet-5) carries the identical top-levelvector_store_search_failures, non-streaming; on the stream it rides the finalresponse.completedevent underresponse.provider_specific_fields:[{"vector_store_id": "lit6750-qa-vectors:lit6809-index-never-created", "custom_llm_provider": "s3_vectors", "error": "litellm.NotFoundError: S3_vectorsException - {\"message\":\"The specified index could not be found\"}"}]Case 7: broken store, chat completions, misspelled
vector_store_search_failure_mode: erorr--config config_typo.yaml, whose only difference is the typoHTTP 200with the annotation on"I don’t have access to the Project Halcyon runbook, so I can’t determine the required hold duration.", so a typo degrades to the permissive default instead of failing every vector-store request:litellm_settingshas no allowlist, so the same typo reacheslitellm.vector_store_search_failure_modeat the merge base too, where the setting simply does not exist and Case 2's un-augmentedHTTP 200is what comes back (re-run on the base side of the rig with the sameconfig_typo.yaml:HTTP 200,provider_specific_fieldsis{"refusal": null}, and its log has no such warning)Case 8: everything else the hook reaches, default settings
"stream":true: 19 chunks on both sides, grounded,search_resultson the delta, no failures key, so the streaming happy path is byte-for-byte unchanged"vector_store_ids":["...litellm-index-ac0bfb1a","...lit6809-index-never-created"]):HTTP 200, grounded on the healthy store (search_results: 1), andvector_store_search_failuresnames the broken one, so one dead store no longer hides behind a live onePOST /key/generate, a broken-store chat call with that key (annotated on the head, bare on the base),GET /key/info,POST /key/delete, then the same call returnsHTTP 401, so the annotation rides the request without touching authPOST /v1/messageswith the broken store:HTTP 200and no failures on either side, streaming or not, because that surface never runs the vector store hook today (see Caveats)acompletion(oraresponses) row, oneasearchrow, oneaembeddingrowType
🐛 Bug Fix
Caveats (if any)
Low
Streaming
/v1/responseson a native OpenAI model carries the failure only undererrormodegpt-5.6: 35 SSE events,grep -c vector_store_search_failuresreturns0/v1/responsesthrough the chat-completions bridge (claude-sonnet-5here) does carry it, on theresponse.completedevent underresponse.provider_specific_fields, because the bridge builds that event from the annotated chat responselitellm/responses/streaming_iterator.pyyields theresponse.completedevent to the client and only then runs the post-call hook, and what it hands the hook is the stream event rather than aResponsesAPIResponse, so by the time anything could annotate it the caller already has it/v1/responsesand both chat-completions modes are unaffected, anderrormode raises before the stream opens, so a caller who cannot tolerate an ungrounded answer is covered on every surfaceA
background: trueresponse carries the failures on the POST, not on the later GET"status":"queued"already carryingvector_store_search_failures, so the caller is told at request timeGET /v1/responses/{id}rebuilds the response from the provider's stored copy, which never held a LiteLLM annotation, so the field is absent there/v1/responsescarries the failures but still notsearch_resultsThe machine-readable
codereaches the SDK caller but not the proxy callerlitellm.acompletionraisesVectorStoreSearchErrorwithe.code == "vector_store_search_failed"and the same code ine.bodyProxyException, whoseto_dict()emits onlymessage,type,paramandcode, and it puts the status there, so a proxy caller sees"code": "400"# NOTE: DO NOT MODIFY THISinlitellm/proxy/_types.py, so changing it would touch every error the proxy returnsvector_store_idThe error message repeats the provider's own text back to the caller
search_resultson a healthy call/v1/messagesignoresvector_store_idsentirely todayUnder
errormode, router fallbacks retry the search before giving upfallbacks: [{gpt-5.6: [gpt-5.6-fallback]}]: the 400 still comes back, its message carries the fallback chain (Available Model Group Fallbacks=['gpt-5.6-fallback'], thenError doing the fallback:with the same text), and the spend log shows twotext-embedding-3-smallrows per request instead of oneOne red check at this tip is a fleet-wide staging failure, tracked elsewhere
ci/circleci: proxy_store_model_in_db_testsfailstests/store_model_in_db_tests/test_openai_error_handling.py::test_chat_completion_bad_model_with_spend_logs(model_groupis empty on the failure row) and the last five scheduled staging pipelines fail the same single test; tracked as LIT-6949, whose fix test(store_model_in_db): assert the 400 contract in the unknown-model spend log test #39842 landed on staging as b77b7f1 at 03:29Z, 27 minutes after this tip's merge of staging, so the merge commit picks it up on landing; not a required check, and nothing in this diff touches how a failure row gets itsmodel_groupproxy-infra / Run tests (Python 3.10)failure on the previous tip (LIT-6947) is gone: fix(proxy): strip every TypedDict qualifier before numeric form-field detection #39780 landed on staging as 77e27b1 and this tip carries itThe new setting is not in the docs site yet
Final Attestation
Note
Medium Risk
Changes pre-call hook behavior and response shape for all vector-store-grounded requests; opt-in
errormode can turn previously successful calls into HTTP 400s, but defaultannotateis additive only.Overview
When a configured vector store search fails, callers no longer get a silent HTTP 200 with no signal that grounding was skipped.
The pre-call hook now records per-store failures (
vector_store_id, provider, error text) alongside successfulsearch_results. By default (vector_store_search_failure_mode: annotate), behavior stays permissive: the LLM still runs, but failures appear asvector_store_search_failureson chat completionprovider_specific_fields(and streaming deltas) and as a top-level field on/v1/responses. Settingvector_store_search_failure_mode: errorinlitellm_settingsraises the newVectorStoreSearchError(HTTP 400,invalid_request_error) so the request fails closed when any configured store cannot be searched.Invalid failure-mode values fall back to
annotatewith a warning. Hook error logging now includes the requestedvector_store_ids. Context injection is refactored to aggregate outcomes across multiple stores without dropping failed ones silently.Reviewed by Cursor Bugbot for commit c4d09a3. Bugbot is set up for automated code reviews on this repo. Configure here.