Repository navigation
fix(decisions)!: refuse safety_identifier on providers that cannot take it unless drop_params drops it - #44955
Conversation
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
kerry seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
|
Merging this PR will not alter performance
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
2fc120b to
5079ae2
Compare
6e1e349 to
ec950e0
Compare
7d2d464 to
309da70
Compare
309da70 to
3b94243
Compare
3b94243 to
6908e4a
Compare
…t the provider too
|
bugbot run |
A malformed safety_identifier now follows the drop_params convention in both body shapes: it answers 400 without drop_params and is dropped before the provider call with it. A string identifier on OpenAI stays on the wire either way.
|
bugbot run |
… under drop_params The route checked the whole body before routing, so a non-string safety_identifier answered 400 even when the deployment or the body set drop_params. The route now leaves that field to the Decisions call, which knows every drop_params source
|
bugbot run |
# Conflicts: # litellm/decisions/main.py
# Conflicts: # tests/unit/decisions/test_main.py
…lue and raise it in _prepare_call
|
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 1008005. Configure here.
TLDR
Problem this solves:
safety_identifiersilently on System One providersdrop_paramsis onHow it solves it:
inputon/v1/decisionsandstateon/v1/systemonedrop_paramsfrom settings, the deployment, the body or the SDK drops it insteaddrop_paramsIntentional product change: a decisions request with
safety_identifierto a System One provider withoutdrop_paramschanges from 200 with the identifier dropped silently to 400, on/v1/decisions,/v1/systemoneand the SDK.drop_params: truebrings the 200 back with the identifier droppedBreaking change
Anyone who sends
safety_identifierto a System One deployment (Perplexity, TypeSafe, OpenRouter, StrandsDecider, Cloudflare, hosted vLLM, Databricks, or Microsoft-Decision-1 on Azure AI) withoutdrop_paramsis affected. Those requests on/v1/decisions,/v1/systemoneandlitellm.decisions()now get 400 instead of 200. Setdrop_params: trueon the deployment, underlitellm_settings, in the request body, or onlitellm.drop_paramsto keep getting 200 with the identifier dropped. OpenAI deployments are not affectedUser Flow
Before: a developer sends
safety_identifierwith a decisions request to a TypeSafe deployment, gets 200, and the identifier never leaves the gatewaytypesafe/jev-1.13.0tomodel_listwithmodel_info.mode: evaluationand start the proxyinput, aquestionsarray and"safety_identifier": "user-123", and get 200stateand aquestionsmap also gets 200openai/gpt-6-lunadeployment, OpenAI gets the identifier from the/v1/decisionsbody but not from the/v1/systemoneoneAfter: both requests get a 400 naming the param, and
drop_params: truebrings the 200 backtypesafe/jev-1.13.0tomodel_listwithmodel_info.mode: evaluationand start the proxyinput, aquestionsarray and"safety_identifier": "user-123", and get 400typesafe does not support parameters: ['safety_identifier'] ... To drop these, set litellm.drop_params=Truestateand aquestionsmap gets the same 400drop_params: trueon the deployment, underlitellm_settings, or in the body, both requests get 200 with the identifier droppedopenai/gpt-6-lunadeployment, OpenAI gets the identifier from both bodiesLinear ticket
Resolves LIT-9282
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<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)Screenshots / Proof of Fix
Last updated:
10080051a5, the PR's tip. Every case below ran at10080051a5against the merge base2fcc780498, and the head proxy's source tree was checked against the commit's tree before the runLive A/B with the same config and the same requests in the same order, on two proxies booted from the merge base and the tip. Each proxy runs two uvicorn workers on its own Postgres database. A forwarding recorder sits in front of the real TypeSafe, OpenAI, Azure AI (Microsoft-Decision-1) and Databricks APIs on each leg, so every provider call below is real and its wire body is compared base against head.
$PROXYis the leg's port and$KEYits master keyShared setup
config.yamlbody.json, the OpenAI shape for/v1/decisions{ "model": "typesafe/jev-1.13.0", "input": "Ticket (billing): The export job hangs at 99% and never finishes", "questions": [ {"type": "predicate", "name": "defect", "instructions": "Is this a defect?"}, {"type": "choice", "name": "severity", "instructions": "How severe is it?", "choices": [{"value": "low", "description": "Cosmetic"}, {"value": "high", "description": "Blocks the user"}]}, {"type": "score", "name": "confidence", "instructions": "How confident are you?", "levels": [{"label": "unsure", "description": "Not sure"}, {"label": "sure", "description": "Certain"}]} ] }jev_body.json, the System One shape for/v1/systemone{ "model": "typesafe-strict", "state": "Ticket (billing): The export job hangs at 99% and never finishes", "questions": { "defect": {"type": "noul", "instructions": "Is this a defect?"}, "severity": {"type": "choice", "instructions": "How severe is it?", "criteria": {"low": "Cosmetic", "high": "Blocks the user"}}, "confidence": {"type": "score", "instructions": "How confident are you?", "criteria": ["Not sure", "Certain"]} } }Every decisions call below is one of these two curls, with the model and the
jqedit each step namesBefore (2fcc780)
TypeSafe with safety_identifier on /v1/decisions
typesafe-strictwith.safety_identifier="end-user-7"{"model":"jev-1.13.0","answers":[{"type":"predicate","name":"defect","probability":0.87},{"type":"choice","name":"severity","choice":"high",...},{"type":"score","name":"confidence","score":0.47,...}],"usage":{"input_tokens":373,...}}.safety_identifier="", with a 5 KB identifier, and through the/decisionsalias all return HTTP 200 withanswerssafety_identifierTypeSafe with safety_identifier on /v1/systemone
typesafe-strictwith.safety_identifier="end-user-7"on/v1/systemone, then the same through the/systemonealias{"model":"jev-1.13.0","answers":{"defect":{"type":"noul","noul":0.87},"severity":{"type":"choice","choice":"high",...},"confidence":{"type":"score","score":0.47,...}},"usage":{"input_tokens":368,"output_tokens":62}}drop_params on the body, the deployment and litellm_settings
typesafe-strictwith.safety_identifier="end-user-7" | .drop_params=truereturns HTTP 200 withanswerson both routestypesafe-droppingwith.safety_identifier="end-user-7"returns HTTP 200 withanswerson both routeslitellm_settingswithdrop_params: truewas not run on this leg, which already answers 200 with nodrop_paramsat allNon-string safety_identifier
typesafe-strictwith.safety_identifier=7or.safety_identifier=["end-user-7"]on/v1/decisionsreturns HTTP 400.safety_identifier=7ontypesafe-dropping, and ontypesafe-strictwith.drop_params=true, return the same HTTP 400 on/v1/decisions.safety_identifier=7on/v1/systemonereturns HTTP 200 withanswersontypesafe-strict,typesafe-dropping,openai-strictandazure-strict, with the identifier dropped every timeOpenAI with safety_identifier
openai-strictwith.safety_identifier="end-user-7"on/v1/decisions, with and without.drop_params=true{"model":"gpt-6-luna","answers":[{"type":"predicate","name":"defect","probability":0.84},{"type":"choice","name":"severity","choice":"high",...},{"type":"score","name":"confidence","score":0.18,...}],...}, and both OpenAI wire bodies carry"safety_identifier":"end-user-7"/v1/systemonereturns HTTP 200, and OpenAI's wire body has nosafety_identifierAzure AI and Databricks with safety_identifier
azure-strictwith.safety_identifier="end-user-7"on/v1/decisionsand on/v1/systemone{"model":"microsoft-decision-1","answers":[{"type":"predicate","name":"defect","probability":0.982...},{"type":"choice","name":"severity","choice":"high",...},{"type":"score","name":"confidence","score":0.798...,...}],...}databricks-strictwith the same identifier on both routes returns HTTP 200 both times,{"model":"databricks-openjev-qwen35-4b","answers":[{"type":"predicate","name":"defect","probability":0.651...},...],...}drop_paramsonazure-strict(both routes) and ondatabricks-strict(/v1/decisions), and theazure-droppinganddatabricks-droppingdeployments on both routes, return HTTP 200/providers/microsoft/v1/systemoneand Databricks's logged 6 to/serving-endpoints/databricks-openjev-qwen35-4b/invocations, and none of them carriessafety_identifierPython SDK
litellm.decisions(model="typesafe/jev-1.13.0", questions=..., api_key=key, api_base=wire)withinput=(OpenAI shape) orstate=(System One shape),safety_identifier, and thedrop_paramssource each line namesSpend rows
curl -sS $PROXY/spend/logs -H "Authorization: Bearer $KEY"12 s after the last request0.000015666and 7 at0.000015456), 8 for Azure AI (4 at0.000003822and 4 at0.000004032), 6 for Databricks at0.0(the cost map has no price for this serving endpoint) and 5 forgpt-6-lunatypesafe-strict. The deleted-key 401 row was not written yet when the logs were readUnchanged surfaces
GET /healthreturns 200 with all eight deployments healthy, and/health/livelinessand/health/readinessreturn 200POST /v1/systemonereturns HTTP 4002 validation errors for DecisionsRequestBody state Field required [type=missing, input_value={'model': 'typesafe-stric...entifier': 'end-user-7'}, input_type=dict] ...Authorizationheader returns HTTP 401Authentication Error, No api key passed in.POST /v1/chat/completionsongpt-5-minireturns 200, streaming returnschunks=3 done=1, andPOST /v1/messagesreturns 200POST /key/generatescoped totypesafe-droppingreturns 200, a decisions request withsafety_identifieron that key returns 200,GET /key/infoandPOST /key/deletereturn 200, and the deleted key gets 401After (1008005)
TypeSafe with safety_identifier on /v1/decisions
typesafe-strictwith.safety_identifier="end-user-7"{"error":{"message":"litellm.UnsupportedParamsError: typesafe does not support parameters: ['safety_identifier'], for model=typesafe/jev-1.13.0. To drop these, set `litellm.drop_params=True` or for proxy:\n\n`litellm_settings:\n drop_params: true`\n\n\nLiteLLM: model group 'typesafe-strict' failed with the error above. No fallback was attempted.","type":"invalid_request_error","param":null,"code":"400"}}.safety_identifier="", with a 5 KB identifier, and through the/decisionsalias all return the same HTTP 400litellm_settingsrestart), and none of them carriessafety_identifier. The refused requests never reached TypeSafeTypeSafe with safety_identifier on /v1/systemone
typesafe-strictwith.safety_identifier="end-user-7"on/v1/systemone, then the same through the/systemonealiasUnsupportedParamsErrorboth timesdrop_params on the body, the deployment and litellm_settings
typesafe-strictwith.safety_identifier="end-user-7" | .drop_params=truereturns HTTP 200 withanswerson both routestypesafe-droppingwith.safety_identifier="end-user-7"returns HTTP 200 withanswerson both routeslitellm_settings: {drop_params: true}added toconfig.yaml.typesafe-strictwith.safety_identifier="end-user-7"and with.safety_identifier=7returns HTTP 200 on both routes ({"model":"jev-1.13.0","answers":[defect, severity, confidence],...}), and all four TypeSafe wire bodies carry onlymodel,questionsandstateNon-string safety_identifier
typesafe-strictwith.safety_identifier=7or.safety_identifier=["end-user-7"]on/v1/decisionsreturns HTTP 400.safety_identifier=7ontypesafe-dropping, and ontypesafe-strictwith.drop_params=true, return HTTP 200 withanswerson/v1/decisions, and the TypeSafe wire body has nosafety_identifier.safety_identifier=7on/v1/systemonereturns the same HTTP 400 ontypesafe-strict,openai-strictandazure-strict, each naming its own model group, and HTTP 200 with the identifier dropped ontypesafe-droppingOpenAI with safety_identifier
openai-strictwith.safety_identifier="end-user-7"on/v1/decisions, with and without.drop_params=truegpt-6-lunaanswers as before, and both OpenAI wire bodies carry"safety_identifier":"end-user-7"/v1/systemonereturns HTTP 200, and OpenAI's wire body now carries"safety_identifier":"end-user-7"tooAzure AI and Databricks with safety_identifier
azure-strictwith.safety_identifier="end-user-7"on/v1/decisionsand on/v1/systemone{"error":{"message":"litellm.UnsupportedParamsError: azure_ai does not support parameters: ['safety_identifier'], for model=azure_ai/decision-1. To drop these, set `litellm.drop_params=True` or for proxy:\n\n`litellm_settings:\n drop_params: true`\n\n\nLiteLLM: model group 'azure-strict' failed with the error above. No fallback was attempted.","type":"invalid_request_error","param":null,"code":"400"}}databricks-strictwith the same identifier on both routes returns the same HTTP 400, namingdatabricks,model=databricks/databricks-openjev-qwen35-4band model groupdatabricks-strictdrop_paramsonazure-strict(both routes) and ondatabricks-strict(/v1/decisions), and theazure-droppinganddatabricks-droppingdeployments on both routes, return HTTP 200 as on basesafety_identifierPython SDK
Spend rows
curl -sS $PROXY/spend/logs -H "Authorization: Bearer $KEY"12 s after the last request0.000015666and 4 at0.000015456), 5 for Azure AI (2 at0.000003822and 3 at0.000004032), 4 for Databricks at0.0and 4 forgpt-6-luna. That is one fewer for every request the head now refuses, plus one more TypeSafe row for each non-string identifier it now dropstypesafe-strict, 3azure-strict, 2databricks-strictand 1openai-strict), plus the no-key and deleted-key 401sUnchanged surfaces
GET /healthreturns 200 with all eight deployments healthy, and/health/livelinessand/health/readinessreturn 200POST /v1/systemonereturns HTTP 400 with the same two validation errors, and its echoed input is now printed asmappingproxy({'model': 't...iption': 'Certain'}]}]})(Low caveat below)Authorizationheader returns HTTP 401Authentication Error, No api key passed in.POST /v1/chat/completionsongpt-5-minireturns 200, streaming returnschunks=3 done=1, andPOST /v1/messagesreturns 200POST /key/generatescoped totypesafe-droppingreturns 200, a decisions request withsafety_identifieron that key returns 200,GET /key/infoandPOST /key/deletereturn 200, and the deleted key gets 401Both legs' OpenAI recorders also log two
GET /v1/modelscalls per proxy boot, which is a rig artifact, and the head leg booted twice because of thelitellm_settingscase/live-pr-risk
Breaking
None. Every dependent path that answered on the base leg answers the same on the head leg, except the requests this PR names below
Backward incompatible
POST /v1/decisions,/decisions,/v1/systemone,/systemoneandlitellm.decisions()withsafety_identifieron a System One deployment answer 400 (base answers 200 with the identifier dropped) unlessdrop_paramsis set on the deployment, underlitellm_settings, in the body, or onlitellm.drop_params. All four were driven live above, and the 400 was driven live on TypeSafe, Azure AI and Databricks. A non-string identifier follows the same rule, so a System One-shape body with one changes from 200 to 400 and an OpenAI-shape body withdrop_paramschanges from 400 to 200. That 400 now namessafety_identifierand the model group instead ofOpenAIDecisionRequestBody. Each refused request now writes a failure spend row at spend 0 naming its model group. This ships under @mateo-berri's standing drop_params rule (2026-10-02, decided on #40775), which says an unsupported or malformed param answers 400 unless drop_params drops it, the convention every other route follows. Recorded as the Medium caveats belowRegression risk
Perplexity, OpenRouter, Cloudflare, StrandsDecider and hosted vLLM share the refuse path with TypeSafe, Azure AI and Databricks, and the scripted-upstream wire tests in this PR cover them, but they were not driven live here. Guardrails on
/v1/decisionsnow see a non-string identifier that the route refused before routing on base. LLM Shield's request collector skips non-string values (traced, not driven), and chat routes already pass any JSON value through to guardrails. The Lens signal judge no longer calls decisions in process since #45529, so it is not a caller hereDependency graph
_request_safety_identifiervalidates the identifier as a string, or drops a non-string one underdrop_params, and_provider_ir_requestreturns the IR request, the IR request with the identifier dropped, or_UnsupportedSafetyIdentifier, which_prepare_callraises asUnsupportedParamsErrorbefore the HTTP call._prepare_callserveslitellm.decisionsandlitellm.adecisions(verified live, SDK case),Router.adecisions(verified live through the proxy),POST /v1/decisionsand/decisions(verified live),POST /v1/systemoneand/systemone(verified live), and the/healthevaluation probe (verified live on both legs, eight deployments healthy)._fields_checked_before_routingin_process_decisionsis shared by both routes and was verified live on both. On/v1/systemonethe identifier was already an extra field, so only the echoed error text changed there.BaseDecisionsConfig.supports_safety_identifieris a new attribute that defaults to False. OpenAI overrides it (verified live), and TypeSafe, Azure AI and Databricks inherit it (verified live), as do Perplexity, OpenRouter, Cloudflare, StrandsDecider and hosted vLLM (tested by the wire tests). No UI, config-key, DB, or lockfile change, and no legacy-suite hit for the changed symbolsNot verified
Perplexity, OpenRouter, Cloudflare, StrandsDecider and hosted vLLM live, and a guardrail receiving a non-string identifier live
/audit (round 3 of 3 at e547414)
Round 3 ran at
e547414e1dagainst the merge base097d018dbf, and every one of its 77 cells passed.6ed3e65910,48fd7420eaand21d083399achanged product code after that round. Then two merges of main (883302e102and4c3a6049ab) brought the Databricks and Azure AI decisions providers into this PR's refuse path, and10080051a5changed the refusal into a returned value that_prepare_callraises. A fourth round did not run under the three-round cap. The 20 cells those commits and merges added or changed ran head-only at10080051a5on the GCE box and passed twice (20 passed in 40.09s, then20 passed in 41.45s). The base and control lanes were not run again at the tipRound 3 matrix at e547414, 77 cells
Merge base 097d018, head e547414, rig
lit9282hon the GCE box (three legs, two workers each, own database, Redis and scripted upstream per leg, CI's Python 3.12), group providers,INTEGRATION_WORKERS=1andINTEGRATION_PROXY_READY_SECONDS=240as CI sets them. The lanes ran one after another because every cell takes the sharedproviderfixture, which binds the host's port 8191 (two lanes cannot hold it at once). The summaries areround-lanes_base.json,round-lanes_ctl.jsonandround-lanes_head.jsonbasectlhead-run1head-run2providers/test_decisions_openai_format_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cost_map[perplexity]providers/test_decisions_openai_format_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cost_map[typesafe]providers/test_decisions_openai_format_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cost_map[openrouter]providers/test_decisions_openai_format_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cost_map[strands_decider]providers/test_decisions_openai_format_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cost_map[cloudflare]providers/test_decisions_openai_format_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cost_map[hosted_vllm]providers/test_decisions_openai_format_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed_from_the_cost_map[openai]providers/test_decisions_openai_format_wire.py::test_the_unversioned_alias_serves_the_same_requestproviders/test_decisions_openai_format_wire.py::test_repeated_identical_requests_each_reach_the_upstream_and_are_each_billedproviders/test_decisions_openai_format_wire.py::test_sdk_sync_and_async_clients_send_the_same_requestproviders/test_decisions_openai_format_wire.py::test_gateway_only_fields_stay_at_the_gateway_and_tags_reach_the_spend_logproviders/test_decisions_openai_format_wire.py::test_invalid_bodies_are_refused_at_the_gateway_without_an_upstream_callproviders/test_decisions_openai_format_wire.py::test_system_one_providers_refuse_images_at_the_gateway_without_an_upstream_call[perplexity]providers/test_decisions_openai_format_wire.py::test_system_one_providers_refuse_images_at_the_gateway_without_an_upstream_call[typesafe]providers/test_decisions_openai_format_wire.py::test_system_one_providers_refuse_images_at_the_gateway_without_an_upstream_call[openrouter]providers/test_decisions_openai_format_wire.py::test_system_one_providers_refuse_images_at_the_gateway_without_an_upstream_call[strands_decider]providers/test_decisions_openai_format_wire.py::test_system_one_providers_refuse_images_at_the_gateway_without_an_upstream_call[cloudflare]providers/test_decisions_openai_format_wire.py::test_system_one_providers_refuse_images_at_the_gateway_without_an_upstream_call[hosted_vllm]providers/test_decisions_openai_format_wire.py::test_openai_forwards_image_input_in_its_own_message_shapeproviders/test_decisions_openai_format_wire.py::test_a_message_list_input_reaches_a_system_one_provider_as_its_flattened_textproviders/test_decisions_openai_format_wire.py::test_safety_identifier_is_refused_by_system_one_providers_unless_the_deployment_drops_params[perplexity]providers/test_decisions_openai_format_wire.py::test_safety_identifier_is_refused_by_system_one_providers_unless_the_deployment_drops_params[typesafe]providers/test_decisions_openai_format_wire.py::test_safety_identifier_is_refused_by_system_one_providers_unless_the_deployment_drops_params[openrouter]providers/test_decisions_openai_format_wire.py::test_safety_identifier_is_refused_by_system_one_providers_unless_the_deployment_drops_params[strands_decider]providers/test_decisions_openai_format_wire.py::test_safety_identifier_is_refused_by_system_one_providers_unless_the_deployment_drops_params[cloudflare]providers/test_decisions_openai_format_wire.py::test_safety_identifier_is_refused_by_system_one_providers_unless_the_deployment_drops_params[hosted_vllm]providers/test_decisions_openai_format_wire.py::test_every_string_safety_identifier_is_refused_or_dropped_like_the_usual_one[empty]providers/test_decisions_openai_format_wire.py::test_every_string_safety_identifier_is_refused_or_dropped_like_the_usual_one[5kb]providers/test_decisions_openai_format_wire.py::test_a_request_body_drop_params_drops_the_safety_identifier_like_chatproviders/test_decisions_openai_format_wire.py::test_openai_keeps_the_safety_identifier_on_the_wire_without_drop_paramsproviders/test_decisions_openai_format_wire.py::test_litellm_settings_drop_params_drops_the_safety_identifier_for_a_strict_deploymenttranslation/decisions/basic/test_decisions_basic_cloudflare.py::test_decisions_basic_cloudflare[cloudflare/@cf/cloudflare/clef-basic]translation/decisions/basic/test_decisions_basic_cloudflare.py::test_decisions_basic_cloudflare[cloudflare/@cf/cloudflare/clef-systemone]translation/decisions/basic/test_decisions_basic_openrouter.py::test_decisions_basic_openrouter[openrouter/typesafe/jev-1.13-basic]translation/decisions/basic/test_decisions_basic_openrouter.py::test_decisions_basic_openrouter[openrouter/typesafe/jev-1.13-systemone]translation/decisions/basic/test_decisions_basic_perplexity.py::test_decisions_basic_perplexity[perplexity/pplx-decider-v1-27b-basic]translation/decisions/basic/test_decisions_basic_perplexity.py::test_decisions_basic_perplexity[perplexity/pplx-decider-v1-27b-systemone]translation/decisions/basic/test_decisions_basic_strands_decider.py::test_decisions_basic_strands_decider[strands_decider/strands-decider-2B-hobson-v19-basic]translation/decisions/basic/test_decisions_basic_strands_decider.py::test_decisions_basic_strands_decider[strands_decider/strands-decider-2B-hobson-v19-systemone]translation/decisions/basic/test_decisions_basic_typesafe.py::test_decisions_basic_typesafe[typesafe/jev-1.13.0-basic]translation/decisions/basic/test_decisions_basic_typesafe.py::test_decisions_basic_typesafe[typesafe/jev-1.13.0-systemone]providers/test_decisions_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed[perplexity]providers/test_decisions_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed[typesafe]providers/test_decisions_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed[openrouter]providers/test_decisions_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed[strands_decider]providers/test_decisions_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed[hosted_vllm]providers/test_decisions_wire.py::test_each_provider_gets_its_own_path_key_and_body_and_is_billed[cloudflare]providers/test_decisions_wire.py::test_test_connection_evaluation_mode_uses_typesafe_decisions_pathproviders/test_decisions_wire.py::test_repeated_identical_requests_each_reach_the_upstream_and_are_each_billedproviders/test_decisions_wire.py::test_sdk_sync_and_async_clients_send_the_same_requestproviders/test_decisions_wire.py::test_gateway_only_fields_stay_at_the_gateway_and_tags_reach_the_spend_logproviders/test_decisions_wire.py::test_invalid_bodies_are_refused_at_the_gateway_without_an_upstream_callproviders/test_decisions_wire.py::test_unknown_model_is_refused_like_chatproviders/test_decisions_wire.py::test_key_checks_match_chatproviders/test_decisions_wire.py::test_request_body_api_base_is_refused_like_chat_without_an_upstream_callproviders/test_decisions_wire.py::test_a_deployment_without_a_key_sends_the_provider_env_key_to_its_configured_api_baseproviders/test_decisions_wire.py::test_a_deployment_opted_into_client_api_base_sends_decisions_and_chat_to_the_body_api_baseproviders/test_decisions_wire.py::test_a_config_pass_through_at_v1_decisions_keeps_answering_and_the_native_api_serves_system_oneproviders/test_decisions_wire.py::test_upstream_errors_keep_their_status_and_log_an_unbilled_failure[401]providers/test_decisions_wire.py::test_upstream_errors_keep_their_status_and_log_an_unbilled_failure[429]providers/test_decisions_wire.py::test_upstream_errors_keep_their_status_and_log_an_unbilled_failure[500]providers/test_decisions_wire.py::test_upstream_success_without_answers_is_a_gateway_side_server_errorproviders/test_decisions_wire.py::test_unreachable_upstream_fails_only_its_own_deployment[perplexity]providers/test_decisions_wire.py::test_unreachable_upstream_fails_only_its_own_deployment[typesafe]providers/test_decisions_wire.py::test_unreachable_upstream_fails_only_its_own_deployment[openrouter]providers/test_decisions_wire.py::test_unreachable_upstream_fails_only_its_own_deployment[strands_decider]providers/test_decisions_wire.py::test_unreachable_upstream_fails_only_its_own_deployment[hosted_vllm]providers/test_decisions_wire.py::test_unreachable_upstream_fails_only_its_own_deployment[cloudflare]providers/test_decisions_wire.py::test_an_unreachable_openrouter_deployment_reports_a_connection_error_on_chat_embeddings_and_decisionsproviders/test_decisions_wire.py::test_sdk_openrouter_connection_failures_raise_a_connection_errorproviders/test_decisions_wire.py::test_a_deployment_whose_provider_has_no_decisions_support_is_refused_naming_every_supported_providerproviders/test_decisions_chaos.py::test_burst_over_both_routes_bills_each_call_once_with_its_own_statusproviders/test_decisions_chaos.py::test_worker_sigkill_mid_burst_leaves_the_sibling_serving_the_default_modelproviders/test_decisions_chaos.py::test_upstream_outage_fails_its_calls_and_recovery_on_the_same_port_restores_themtranslation/decisions/basic/test_decisions_basic_hosted_vllm.py::test_decisions_basic_hosted_vllm[hosted_vllm/Qwen/Qwen3-0.6B-basic]translation/decisions/basic/test_decisions_basic_hosted_vllm.py::test_decisions_basic_hosted_vllm[hosted_vllm/Qwen/Qwen3-0.6B-decisions]translation/decisions/basic/test_decisions_basic_hosted_vllm.py::test_decisions_basic_hosted_vllm[hosted_vllm/Qwen/Qwen3-0.6B-predicate_rejected]Every cell ran at e547414, and the two head runs collected and passed the same selections with no skips and no retries
Type
🐛 Bug Fix
Caveats (if any)
Medium
safety_identifierto a System One deployment now answers 400/v1/decisions,/v1/systemone, their aliases, andlitellm.decisions()withinput=orstate=drop_params: trueon the deployment, underlitellm_settings, in the body, or onlitellm.drop_paramsbrings the 200 backsafety_identifierchanges outcome on both body shapesdrop_paramsgoes from 400 to 200safety_identifierand the model group instead ofOpenAIDecisionRequestBodyLow
10080051a5, twicemappingproxy(...)instead of a dictadditional_drop_params: ["safety_identifier"]on a decisions deployment is not honoreddrop_params: trueis the remedy, since on decisions it drops only this one param/v1/systemone, and this PR moves them back/v1/decisionsand adds a systemone case with the System One bodyFinal Attestation
Link to Devin session: https://app.devin.ai/sessions/a7a7d712811643b1a6416531d7d99cfa
Open in Devin Desktop: https://app.devin.ai/desktop/session/a7a7d712811643b1a6416531d7d99cfa?variant=devin
Requested by: @kerry-berri
Note
Medium Risk
Intentional breaking API change: System One decisions deployments return 400 when callers send safety_identifier without drop_params; OpenAI behavior is preserved and extended for System One bodies.
Overview
Breaking: Decisions requests that include
safety_identifieragainst System One–style providers (defaultsupports_safety_identifier=False) now return 400UnsupportedParamsErrornaming the param, instead of 200 with the field dropped silently. This applies to the SDK,/v1/decisions, and/v1/systemone(and aliases). Settingdrop_params(global, deployment, request body, orlitellm_settings) drops the identifier and restores success, matching chat routes.The decisions stack validates
safety_identifieras an optional string (non-strings are 400 unless dropped), threads it into the IR for System One bodies, and gates upstream transformation via_provider_ir_request. OpenAI setssupports_safety_identifier=Trueand keeps the field on the wire for both OpenAI- and System One–shaped requests.The proxy pre-routing body check ignores
safety_identifierso it can ride on/v1/systemonewithout failing schema validation before routing. Wire, unit, and translation tests cover refuse/drop/forward behavior across providers.Reviewed by Cursor Bugbot for commit 1008005. Bugbot is set up for automated code reviews on this repo. Configure here.