mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_decrease_anys_fable5
# Conflicts: # ruff-strict-budget.json # type-discipline-budget.json
This commit is contained in:
commit
b554dd3bcd
38 changed files with 3054 additions and 159 deletions
56
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
56
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -23,30 +23,56 @@ body:
|
|||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
value: "A bug happened!"
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
id: user-flow
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Please provide a numbered list of the exact steps to reproduce this bug (include a curl/python snippet to reproduce it). Number each step (1., 2., 3., ...) in the order you performed them.
|
||||
label: User Flow
|
||||
description: |
|
||||
Two ordered lists, "Before a (hypothetical) fix" and "After a (hypothetical) fix", walking the same end user through the same task, written strictly from that user's seat. Every rule below applies.
|
||||
|
||||
- Describe the real application and the routes its users actually hit, not a generic scenario
|
||||
- Lead each list with one plain sentence saying where the flow fails (before) or would succeed (after), then number the steps
|
||||
- Every step is something the user does or observes: the HTTP method and full URL they hit, what they sent, and what visibly came back (status code, error text, the shape of an ID). UI steps name the page URL and what is on screen
|
||||
- No LiteLLM internals: never name functions, files, DB tables, config classes, hooks, callbacks, or code paths. "The upload hands back an ID that looks like OpenAI's own `file-abc123` instead of the scrambled one the gateway returned" is right, "no managed-file row was registered" is wrong
|
||||
- Keep the two lists step-for-step identical until they diverge, so the broken step is obvious
|
||||
- If the bug has a security or authorization consequence, end each list with what another user can do that they shouldn't be able to, and what they could no longer do after a fix
|
||||
placeholder: |
|
||||
1. config.yaml file/ .env file/ etc.
|
||||
2. Run the following code...
|
||||
3. Observe the error...
|
||||
value: |
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
Before a (hypothetical) fix: a developer whose app streams chat completions gets no token counts back, so their cost dashboard reads zero
|
||||
|
||||
1. They send POST https://litellm-domain/v1/chat/completions with "stream": true and no stream_options
|
||||
2. The last SSE chunk arrives with "usage": null, so their app records 0 prompt and 0 completion tokens
|
||||
3. They open https://litellm-domain/ui/?page=logs and see the request logged at $0 spend
|
||||
|
||||
After a (hypothetical) fix: the same request comes back with real token counts, so the dashboard shows real spend
|
||||
|
||||
1. The proxy admin sets always_include_stream_usage: true and restarts the proxy
|
||||
2. The developer sends the same POST https://litellm-domain/v1/chat/completions with "stream": true and no stream_options
|
||||
3. The last SSE chunk now carries a usage object with real prompt and completion token counts
|
||||
4. https://litellm-domain/ui/?page=logs shows that request at non-zero spend
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: logs
|
||||
id: proof-of-bug
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
label: Proof the bug occurs
|
||||
description: |
|
||||
The commands (e.g., curl) and their full output, screenshots, or a screen recording demonstrating that the bug happens. Every rule below applies.
|
||||
|
||||
- The proof must be completely e2e with no mocks, against a live proxy you ran yourself (e.g., `litellm --config config.yaml --detailed_debug` on localhost:4000), hitting real LLM provider APIs, costing real $ if needed, where the bug involves a provider call. `pytest` commands are not enough
|
||||
- Show exactly what the end user sees or does, matching the User Flow above step for step
|
||||
- Start with the config.yaml (or SDK setup) and any env vars the proxy ran with, then the exact version or commit hash the proof was captured at, so a maintainer can stand up the same proxy before running your commands. Keep the real values for env vars that aren't sensitive, they are often the reason the bug happens, and redact only the secrets: never paste a real API key, virtual key, database URL, or other credential, here or anywhere else in the issue
|
||||
- If the bug applies to more than one of the LLM endpoints (/v1/responses, /v1/chat/completions, /v1/messages), include proof for every one of them, not just one
|
||||
- For UI bugs: include screenshots and the page URLs you were on. Scrub keys and tokens out of screenshots too (for example, the virtual key is briefly shown in the panel right after you create a virtual key)
|
||||
placeholder: |
|
||||
Config / setup the proxy ran with:
|
||||
|
||||
Version or commit:
|
||||
|
||||
Commands and their full output:
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: component
|
||||
attributes:
|
||||
|
|
|
|||
49
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
49
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
|
|
@ -24,10 +24,53 @@ body:
|
|||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: motivation
|
||||
id: user-flow
|
||||
attributes:
|
||||
label: Motivation, pitch
|
||||
description: Please outline the motivation for the proposal. Is your feature request related to a specific problem? e.g., "I'm working on X and would like Y to be possible". If this is related to another GitHub issue, please link here too.
|
||||
label: User Flow
|
||||
description: |
|
||||
Two ordered lists, "Before this feature (today)" and "After this feature (ideal user flow)", walking the same end user through the same task, written strictly from that user's seat. Every rule below applies.
|
||||
|
||||
- Describe the real application and the routes its users actually hit, not a generic scenario. Link any related GitHub issue or provider API docs
|
||||
- Lead each list with one plain sentence saying where the flow dead-ends today and what it would let them do instead, then number the steps
|
||||
- Every step is something the user does or observes: the HTTP method and full URL they hit, what they sent, and what visibly came back (status code, error text, the shape of an ID). UI steps name the page URL and what is on screen
|
||||
- No LiteLLM internals: never name functions, files, DB tables, config classes, hooks, callbacks, or code paths. Ask for the behavior you need, not the implementation you imagine
|
||||
- Keep the two lists step-for-step identical until they diverge, so the missing capability is obvious
|
||||
- "Before this feature" is also where you show the workaround you're living with, which is what tells us how badly this is needed
|
||||
placeholder: |
|
||||
Before this feature (today): a developer batching nightly summaries has no way to mark those calls as low priority, so they compete with live traffic for the same rate limit
|
||||
|
||||
1. They send POST https://litellm-domain/v1/chat/completions for 500 documents in a loop
|
||||
2. Around document 120 they start getting 429s naming the rpm limit, and their user-facing chat app starts getting them too
|
||||
3. Their workaround is a hand-rolled sleep between calls, which stretches the batch to 3 hours and still collides at peak
|
||||
|
||||
After this feature (ideal user flow): the same batch runs as background work that yields to live traffic
|
||||
|
||||
1. The developer sends the same POST with "service_tier": "flex"
|
||||
2. Batch calls queue behind interactive ones instead of 429ing, and the response comes back with the tier it was served at
|
||||
3. The live chat app keeps returning 200s throughout the batch
|
||||
4. https://litellm-domain/ui/?page=logs shows the batch requests tagged with that tier
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: how-far-you-got
|
||||
attributes:
|
||||
label: How far you got
|
||||
description: |
|
||||
Run as many steps of the "After this feature (ideal user flow)" list as you can against a live proxy you ran yourself (e.g., `litellm --config config.yaml --detailed_debug` on localhost:4000), then paste the commands (e.g., curl) and their full output, ending at the step that dead-ends. Every rule below applies.
|
||||
|
||||
- Say plainly what stopped you there, in user terms: the option you passed came back ignored, the response 400'd naming an unsupported field, there is no button on the page for it. This is what proves the feature is genuinely missing rather than undocumented
|
||||
- No mocks. Where the flow involves a provider call, hit the real provider API, even if it costs real $. `pytest` commands are not enough
|
||||
- Include the config.yaml (or SDK setup) and env vars the proxy ran with, plus the version or commit you were on. Keep the real values for env vars that aren't sensitive, and redact only the secrets: never paste a real API key, virtual key, database URL, or other credential, here or anywhere else in the issue
|
||||
- If the provider already supports this, link their API docs and paste a direct call to them succeeding, so we can see the shape LiteLLM should be sending
|
||||
- For UI asks: include screenshots of the page you got stuck on and its URL. Scrub keys and tokens out of screenshots too (for example, the virtual key is briefly shown in the panel right after you create a virtual key)
|
||||
placeholder: |
|
||||
Config / setup the proxy ran with:
|
||||
|
||||
Version or commit:
|
||||
|
||||
Commands and their full output, up to the step that dead-ends:
|
||||
|
||||
What stopped me there:
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
|||
29
.github/scripts/triage_with_llm.py
vendored
29
.github/scripts/triage_with_llm.py
vendored
|
|
@ -582,7 +582,9 @@ def build_issue_prompt(*, title: str, body: str) -> str:
|
|||
Commands whose external dependencies (LLM provider, DB,
|
||||
network) are mocked or stubbed do NOT count.
|
||||
Prose-only "steps to reproduce" with no run output, video, or
|
||||
screenshot do NOT satisfy (1).
|
||||
screenshot do NOT satisfy (1). An unfilled template scaffold
|
||||
(bare headings such as "Version or commit:" with nothing under
|
||||
them, empty numbered lists) counts as absent, not as evidence.
|
||||
(2) Expected vs. actual behavior (`has_expected_vs_actual`).
|
||||
|
||||
FAIL the bug report if either (1) or (2) is missing. Do not bias
|
||||
|
|
@ -595,6 +597,13 @@ def build_issue_prompt(*, title: str, body: str) -> str:
|
|||
that it does not today).
|
||||
- Motivation / use case with a concrete example (config, API call,
|
||||
UI flow, or scenario showing what's blocked today).
|
||||
- END-TO-END EVIDENCE OF THE DEAD-END (set
|
||||
`has_dead_end_evidence=true` only when this is present): a video,
|
||||
a screenshot, or the exact command(s) actually run paired with
|
||||
their real output, showing the point where the flow stops today.
|
||||
Mocked or stubbed dependencies do NOT count, and an unfilled
|
||||
template scaffold (bare headings, empty numbered lists) counts as
|
||||
absent.
|
||||
|
||||
For an issue that is neither a bug report nor a feature request (a
|
||||
question, support request, or discussion), PASS as long as it has a
|
||||
|
|
@ -608,6 +617,7 @@ def build_issue_prompt(*, title: str, body: str) -> str:
|
|||
"has_repro": boolean,
|
||||
"has_expected_vs_actual": boolean,
|
||||
"has_motivation_example": boolean,
|
||||
"has_dead_end_evidence": boolean,
|
||||
"missing": ["plain-english strings naming what is missing"],
|
||||
"explanation": "1-2 sentence reasoning for the team to skim"
|
||||
}}
|
||||
|
|
@ -705,6 +715,10 @@ _ISSUE_BUG_LABELS: tuple[tuple[str, str], ...] = (
|
|||
)
|
||||
_ISSUE_FEATURE_LABELS: tuple[tuple[str, str], ...] = (
|
||||
("has_motivation_example", "Motivation and concrete example"),
|
||||
(
|
||||
"has_dead_end_evidence",
|
||||
"End-to-end evidence of the dead-end (video, screenshot, or command + real output)",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -836,8 +850,11 @@ def format_issue_close_comment(verdict: dict) -> str:
|
|||
"video, a screenshot, or the exact commands you ran with their real output / "
|
||||
"traceback) plus expected vs. actual behavior. Written steps with no run output, "
|
||||
"video, or screenshot don't count, and mocked or stubbed runs don't count.\n"
|
||||
" - For **feature requests**: a concrete description of what should change, plus a "
|
||||
"use case and example (config / API call / UI flow).\n"
|
||||
" - For **feature requests**: a concrete description of what should change, a "
|
||||
"use case and example (config / API call / UI flow), plus end-to-end evidence of "
|
||||
"the dead-end (a video, a screenshot, or the exact commands you ran with their "
|
||||
"real output showing where the flow stops today). Mocked or stubbed runs don't "
|
||||
"count.\n"
|
||||
"2. Comment `@agent-shin reconsider`. I'll re-run triage and reopen the issue if it "
|
||||
"now meets the bar. (GitHub doesn't let external authors reopen an issue a maintainer "
|
||||
"or bot closed, so the comment-based reconsider is the reliable path.)\n"
|
||||
|
|
@ -943,8 +960,10 @@ def format_grace_warning_issue_comment(verdict: dict) -> str:
|
|||
"screenshot, or the exact commands you ran with their real output / traceback) plus "
|
||||
"expected vs. actual behavior. Written steps with no run output don't count, and "
|
||||
"mocked or stubbed runs don't count.\n"
|
||||
"- For **feature requests**: a concrete description of what should change, plus a use "
|
||||
"case and example (config / API call / UI flow).\n"
|
||||
"- For **feature requests**: a concrete description of what should change, a use "
|
||||
"case and example (config / API call / UI flow), plus end-to-end evidence of the "
|
||||
"dead-end (a video, a screenshot, or the exact commands you ran with their real "
|
||||
"output showing where the flow stops today). Mocked or stubbed runs don't count.\n"
|
||||
"\n"
|
||||
"**If the issue does get auto-closed in 2 hours**, comment `@agent-shin reconsider` "
|
||||
"and I'll re-evaluate. If it now meets the bar, I'll reopen the issue.\n"
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ When creating PRs, don't set base to `main`. `litellm_internal_staging` is the d
|
|||
|
||||
When writing a PR body, treat the comments and imperative instructions inside @.github/pull_request_template.md as rules to follow, not just layout. Agent harnesses may strip HTML comments from copies of that file injected into context, so read .github/pull_request_template.md from disk before writing a PR body to make sure you see every comment rule
|
||||
|
||||
Same applies for filing bug reports and feature requests, with .github/ISSUE_TEMPLATE/bug_report.yml and .github/ISSUE_TEMPLATE/feature_request.yml, respectively
|
||||
|
||||
If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank
|
||||
|
||||
Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
{
|
||||
"reportAny": {
|
||||
"limit": 25153
|
||||
"limit": 23919
|
||||
},
|
||||
"reportArgumentType": {
|
||||
"limit": 2597
|
||||
"limit": 2580
|
||||
},
|
||||
"reportAssignmentType": {
|
||||
"limit": 325
|
||||
"limit": 323
|
||||
},
|
||||
"reportAttributeAccessIssue": {
|
||||
"limit": 501
|
||||
"limit": 488
|
||||
},
|
||||
"reportCallIssue": {
|
||||
"limit": 114
|
||||
|
|
@ -18,13 +18,13 @@
|
|||
"limit": 40
|
||||
},
|
||||
"reportDeprecated": {
|
||||
"limit": 214
|
||||
"limit": 213
|
||||
},
|
||||
"reportDuplicateImport": {
|
||||
"limit": 19
|
||||
},
|
||||
"reportExplicitAny": {
|
||||
"limit": 7946
|
||||
"limit": 7573
|
||||
},
|
||||
"reportFunctionMemberAccess": {
|
||||
"limit": 7
|
||||
|
|
@ -54,10 +54,10 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportMissingParameterType": {
|
||||
"limit": 5772
|
||||
"limit": 5719
|
||||
},
|
||||
"reportMissingTypeArgument": {
|
||||
"limit": 15676
|
||||
"limit": 15657
|
||||
},
|
||||
"reportMissingTypeStubs": {
|
||||
"limit": 40
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportOptionalMemberAccess": {
|
||||
"limit": 1073
|
||||
"limit": 1069
|
||||
},
|
||||
"reportOptionalOperand": {
|
||||
"limit": 0
|
||||
|
|
@ -99,37 +99,37 @@
|
|||
"limit": 0
|
||||
},
|
||||
"reportUnknownArgumentType": {
|
||||
"limit": 44914
|
||||
"limit": 44832
|
||||
},
|
||||
"reportUnknownLambdaType": {
|
||||
"limit": 113
|
||||
},
|
||||
"reportUnknownMemberType": {
|
||||
"limit": 39456
|
||||
"limit": 39269
|
||||
},
|
||||
"reportUnknownParameterType": {
|
||||
"limit": 20060
|
||||
"limit": 19988
|
||||
},
|
||||
"reportUnknownVariableType": {
|
||||
"limit": 31038
|
||||
"limit": 30923
|
||||
},
|
||||
"reportUnnecessaryCast": {
|
||||
"limit": 118
|
||||
},
|
||||
"reportUnnecessaryComparison": {
|
||||
"limit": 700
|
||||
"limit": 699
|
||||
},
|
||||
"reportUnnecessaryContains": {
|
||||
"limit": 5
|
||||
},
|
||||
"reportUnnecessaryIsInstance": {
|
||||
"limit": 855
|
||||
"limit": 853
|
||||
},
|
||||
"reportUntypedBaseClass": {
|
||||
"limit": 0
|
||||
},
|
||||
"reportUntypedFunctionDecorator": {
|
||||
"limit": 30
|
||||
"limit": 27
|
||||
},
|
||||
"reportUnusedClass": {
|
||||
"limit": 23
|
||||
|
|
@ -138,7 +138,7 @@
|
|||
"limit": 139
|
||||
},
|
||||
"reportUnusedImport": {
|
||||
"limit": 550
|
||||
"limit": 545
|
||||
},
|
||||
"reportUnusedVariable": {
|
||||
"limit": 146
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_VerificationToken" ADD COLUMN IF NOT EXISTS "settings_updated_at" TIMESTAMP(3);
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_DeletedVerificationToken" ADD COLUMN IF NOT EXISTS "settings_updated_at" TIMESTAMP(3);
|
||||
|
|
@ -452,6 +452,7 @@ model LiteLLM_VerificationToken {
|
|||
created_by String?
|
||||
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
|
||||
updated_by String?
|
||||
settings_updated_at DateTime? @map("settings_updated_at")
|
||||
last_active DateTime? // When this key was last used
|
||||
rotation_count Int? @default(0) // Number of times key has been rotated
|
||||
auto_rotate Boolean? @default(false) // Whether this key should be auto-rotated
|
||||
|
|
@ -548,6 +549,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
created_by String? // Original creator
|
||||
updated_at DateTime? // Last update timestamp before deletion
|
||||
updated_by String? // Last user who updated before deletion
|
||||
settings_updated_at DateTime? // Last configuration change before deletion
|
||||
last_active DateTime? // When this key was last used before deletion
|
||||
rotation_count Int? @default(0)
|
||||
auto_rotate Boolean? @default(false)
|
||||
|
|
|
|||
|
|
@ -382,19 +382,23 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
model: str,
|
||||
custom_llm_provider: str | None,
|
||||
tools: list | None = None,
|
||||
enable_prompt_caching: bool | None = None,
|
||||
) -> list[CacheControlInjectionPoint]:
|
||||
"""Default breakpoints when ``litellm.enable_anthropic_prompt_caching`` is on.
|
||||
|
||||
Caches the system prompt and the trailing turn, so the stable prefix
|
||||
(system + tools + history) is reused while the breakpoint advances with
|
||||
the conversation. Returns [] (stand down) when the flag is off, the
|
||||
provider does not consume cache_control breakpoints (only anthropic /
|
||||
bedrock do), the model lacks prompt-caching support, or the request
|
||||
already carries client-supplied cache_control.
|
||||
``enable_prompt_caching`` is the per-request override (stamped from key
|
||||
metadata by the proxy); True turns auto-injection on for this request
|
||||
even when the global flag is off. Caches the system prompt and the
|
||||
trailing turn, so the stable prefix (system + tools + history) is
|
||||
reused while the breakpoint advances with the conversation. Returns []
|
||||
(stand down) when neither flag is on, the provider does not consume
|
||||
cache_control breakpoints (only anthropic / bedrock do), the model
|
||||
lacks prompt-caching support, or the request already carries
|
||||
client-supplied cache_control.
|
||||
"""
|
||||
import litellm
|
||||
|
||||
if litellm.enable_anthropic_prompt_caching is not True:
|
||||
if litellm.enable_anthropic_prompt_caching is not True and enable_prompt_caching is not True:
|
||||
return []
|
||||
|
||||
provider = custom_llm_provider
|
||||
|
|
@ -433,6 +437,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
model: str,
|
||||
custom_llm_provider: str | None,
|
||||
tools: list | None = None,
|
||||
enable_prompt_caching: bool | None = None,
|
||||
) -> None:
|
||||
"""For /chat/completions: resolve the injection points the request should carry.
|
||||
|
||||
|
|
@ -458,6 +463,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
tools=tools,
|
||||
enable_prompt_caching=enable_prompt_caching,
|
||||
)
|
||||
if points:
|
||||
non_default_params["cache_control_injection_points"] = points
|
||||
|
|
@ -478,12 +484,17 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
judgment happens once per request; points a prior pass wrote back
|
||||
carry the judged stamp and are never re-judged (see
|
||||
``_should_stand_down``). When none are configured but
|
||||
``litellm.enable_anthropic_prompt_caching`` is on, synthesize default
|
||||
breakpoints for the native /v1/messages path. Pops the key from kwargs;
|
||||
``litellm.enable_anthropic_prompt_caching`` or the per-request
|
||||
``enable_prompt_caching`` kwarg (stamped from key metadata) is on,
|
||||
synthesize default breakpoints for the native /v1/messages path. Pops
|
||||
both keys from kwargs;
|
||||
if remaining (non-message) points exist they are written back so
|
||||
downstream transforms can handle them.
|
||||
"""
|
||||
typed_messages = cast(list[AllMessageValues], messages) # cast-ok: Anthropic-shaped dicts from v1/messages
|
||||
enable_prompt_caching: Final = cast( # cast-ok: kwargs is untyped; key stamped as bool by the proxy
|
||||
bool | None, kwargs.pop("enable_prompt_caching", None)
|
||||
)
|
||||
configured: Final = cast( # cast-ok: kwargs is untyped; this key only holds the documented injection-point list
|
||||
list[CacheControlInjectionPoint] | None, kwargs.pop("cache_control_injection_points", None)
|
||||
)
|
||||
|
|
@ -497,6 +508,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
|
|||
tools=tools,
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
enable_prompt_caching=enable_prompt_caching,
|
||||
)
|
||||
if not injection_points:
|
||||
return messages, system
|
||||
|
|
|
|||
|
|
@ -504,6 +504,7 @@ async def acompletion(
|
|||
model=model,
|
||||
custom_llm_provider=cast(str | None, custom_llm_provider), # cast-ok: read from untyped kwargs
|
||||
tools=tools,
|
||||
enable_prompt_caching=cast(bool | None, kwargs.get("enable_prompt_caching")), # cast-ok: untyped kwargs
|
||||
)
|
||||
|
||||
if isinstance(litellm_logging_obj, LiteLLMLoggingObj) and (
|
||||
|
|
@ -5158,6 +5159,7 @@ def completion(
|
|||
model=model,
|
||||
custom_llm_provider=cast(str | None, kwargs.get("custom_llm_provider")), # cast-ok: untyped kwargs
|
||||
tools=tools,
|
||||
enable_prompt_caching=cast(bool | None, kwargs.get("enable_prompt_caching")), # cast-ok: untyped kwargs
|
||||
)
|
||||
|
||||
if isinstance(litellm_logging_obj, LiteLLMLoggingObj) and (
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase):
|
|||
created_by: str | None = None
|
||||
updated_at: datetime | None = None
|
||||
updated_by: str | None = None
|
||||
settings_updated_at: datetime | None = None
|
||||
last_active: datetime | None = None
|
||||
object_permission_id: str | None = None
|
||||
object_permission: LiteLLM_ObjectPermissionTable | None = None
|
||||
|
|
|
|||
|
|
@ -1108,6 +1108,7 @@ class KeyRequestBase(GenerateRequestBase):
|
|||
budget_id: str | None = None
|
||||
tags: list[str] | None = None
|
||||
disable_global_guardrails: bool | None = None
|
||||
enable_prompt_caching: bool | None = None
|
||||
throttle_on_budget_exceeded: bool | None = None
|
||||
enforced_params: list[str] | None = None
|
||||
allowed_routes: list | None = []
|
||||
|
|
@ -4124,6 +4125,7 @@ LiteLLM_ManagementEndpoint_MetadataFields: Final = [
|
|||
"enforced_batch_output_expires_after",
|
||||
"enforced_file_expires_after",
|
||||
"throttle_on_budget_exceeded",
|
||||
"enable_prompt_caching",
|
||||
]
|
||||
|
||||
LiteLLM_ManagementEndpoint_MetadataFields_Premium: Final = [
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ _UNTRUSTED_ROOT_CONTROL_FIELDS: Final = (
|
|||
"mock_tool_calls",
|
||||
"disable_global_guardrails",
|
||||
"disable_global_guardrail",
|
||||
"enable_prompt_caching",
|
||||
"opted_out_global_guardrails",
|
||||
"applied_guardrails",
|
||||
"applied_policies",
|
||||
|
|
@ -1333,6 +1334,9 @@ class LiteLLMProxyRequestSetup:
|
|||
if "disable_fallbacks" in key_metadata and isinstance(key_metadata["disable_fallbacks"], bool):
|
||||
data["disable_fallbacks"] = key_metadata["disable_fallbacks"]
|
||||
|
||||
if isinstance(key_metadata.get("enable_prompt_caching"), bool):
|
||||
data["enable_prompt_caching"] = key_metadata["enable_prompt_caching"] # rebind-ok: data is an out-param
|
||||
|
||||
## KEY-LEVEL METADATA
|
||||
data = LiteLLMProxyRequestSetup.add_management_endpoint_metadata_to_request_metadata(
|
||||
data=data,
|
||||
|
|
@ -1862,6 +1866,24 @@ async def add_litellm_data_to_request(
|
|||
tags_to_add=project_metadata["tags"],
|
||||
)
|
||||
|
||||
# inherited_tags: every tag key/team/project policy contributed, read
|
||||
# directly from those three sources rather than snapshotted off the shared
|
||||
# "tags" list. A pre-auth pass (apply_client_tag_policy_pre_auth, run from
|
||||
# user_api_key_auth for _tag_max_budget_check) may already have merged the
|
||||
# caller's own header tags into that same list before this function ever
|
||||
# runs, so a snapshot taken here -- at any point in this function -- would
|
||||
# misattribute caller-supplied tags as policy-backed. tag_based_routing.py's
|
||||
# allow_fail_open reads this (rather than subtracting caller_tags from the
|
||||
# final merged set) so a caller can't strip an inherited "!"/"&"
|
||||
# constraint's protection just by resubmitting its exact value alongside a
|
||||
# conflicting one.
|
||||
_key_tags: Final = (key_metadata or MappingProxyType({})).get("tags") or ()
|
||||
_team_tags: Final = team_metadata.get("tags") or ()
|
||||
_project_tags: Final = project_metadata.get("tags") or ()
|
||||
data[_metadata_variable_name]["inherited_tags"] = tuple( # rebind-ok: matches this file's data[...] mutation idiom
|
||||
dict.fromkeys((*_key_tags, *_team_tags, *_project_tags))
|
||||
)
|
||||
|
||||
## TEAM-LEVEL METADATA
|
||||
data = LiteLLMProxyRequestSetup.add_management_endpoint_metadata_to_request_metadata(
|
||||
data=data,
|
||||
|
|
@ -1958,15 +1980,28 @@ async def add_litellm_data_to_request(
|
|||
tags_to_add=tags,
|
||||
)
|
||||
|
||||
if _metadata_variable_name != "metadata":
|
||||
_user_metadata = data.get("metadata")
|
||||
if isinstance(_user_metadata, dict):
|
||||
_user_tags: Final = _user_metadata.get("tags")
|
||||
if isinstance(_user_tags, list) and _user_tags:
|
||||
data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags(
|
||||
request_tags=data[_metadata_variable_name].get("tags"),
|
||||
tags_to_add=_user_tags,
|
||||
)
|
||||
_caller_body_metadata: Final = data.get("metadata") if _metadata_variable_name != "metadata" else None
|
||||
_caller_body_tags: Final = (
|
||||
_caller_body_metadata.get("tags")
|
||||
if isinstance(_caller_body_metadata, dict) and isinstance(_caller_body_metadata.get("tags"), list)
|
||||
else None
|
||||
)
|
||||
if _caller_body_tags:
|
||||
data[_metadata_variable_name]["tags"] = LiteLLMProxyRequestSetup._merge_tags( # rebind-ok: matches file idiom
|
||||
request_tags=data[_metadata_variable_name].get("tags"),
|
||||
tags_to_add=_caller_body_tags,
|
||||
)
|
||||
|
||||
# caller_tags: exactly what this request itself supplied (x-litellm-tags header,
|
||||
# body "tags", or body "metadata.tags" on litellm_metadata routes), never
|
||||
# anything from key/team/project metadata. Read directly from the header and
|
||||
# body values here, the same way inherited_tags above is read directly from
|
||||
# key/team/project metadata -- neither is derived by inspecting the shared
|
||||
# "tags" list, which a pre-auth pass (apply_client_tag_policy_pre_auth) may
|
||||
# have already merged caller header tags into before this function runs.
|
||||
data[_metadata_variable_name]["caller_tags"] = tuple( # rebind-ok: matches file idiom
|
||||
dict.fromkeys((*(tags or ()), *(_caller_body_tags or ())))
|
||||
)
|
||||
|
||||
# Team Callbacks controls
|
||||
callback_settings_obj: Final = _get_dynamic_logging_metadata(
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ from litellm.proxy.management_endpoints.common_utils import (
|
|||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
_add_model_to_db,
|
||||
)
|
||||
from litellm.proxy.management_helpers.key_settings_audit import with_settings_updated_at
|
||||
from litellm.proxy.management_helpers.object_permission_utils import (
|
||||
_set_object_permission,
|
||||
attach_object_permission_to_dict,
|
||||
|
|
@ -1602,6 +1603,7 @@ async def generate_key_fn(
|
|||
- policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules.
|
||||
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
|
||||
- throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely.
|
||||
- enable_prompt_caching: Optional[bool] - Auto-inject prompt caching breakpoints (Anthropic cache_control markers) on requests made with this key. Anthropic and Bedrock Claude models only.
|
||||
- permissions: Optional[dict] - key-specific permissions. Currently just used for turning off pii masking (if connected). Example - {"pii": false}
|
||||
- model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}}. IF null or {} then no model specific budget.
|
||||
- budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
|
||||
|
|
@ -2702,6 +2704,7 @@ async def update_key_fn(
|
|||
- policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules.
|
||||
- disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
|
||||
- throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely.
|
||||
- enable_prompt_caching: Optional[bool] - Auto-inject prompt caching breakpoints (Anthropic cache_control markers) on requests made with this key. Anthropic and Bedrock Claude models only.
|
||||
- prompts: Optional[List[str]] - List of prompts that the key is allowed to use.
|
||||
- blocked: Optional[bool] - Whether the key is blocked
|
||||
- aliases: Optional[dict] - Model aliases for the key - [Docs](https://litellm.vercel.app/docs/proxy/virtual_keys#model-aliases)
|
||||
|
|
@ -4703,7 +4706,7 @@ async def _execute_virtual_key_regeneration(
|
|||
|
||||
updated_token: Final[Mapping[str, object] | None] = await VerificationTokenRepository(prisma_client).table.update(
|
||||
where={"token": hashed_api_key},
|
||||
data=jsonified_update_data,
|
||||
data=with_settings_updated_at(jsonified_update_data),
|
||||
)
|
||||
updated_token_dict: Final[dict[str, object]] = dict(updated_token) if updated_token is not None else {}
|
||||
updated_token_dict["key"] = new_token
|
||||
|
|
@ -6215,7 +6218,7 @@ async def block_key(
|
|||
|
||||
record: Final = await _prisma_table(VerificationTokenRepository(prisma_client)).update(
|
||||
where={"token": hashed_token},
|
||||
data={"blocked": True},
|
||||
data=with_settings_updated_at({"blocked": True}),
|
||||
)
|
||||
|
||||
## UPDATE KEY CACHE - invalidate so next read re-fetches from DB
|
||||
|
|
@ -6328,7 +6331,7 @@ async def unblock_key(
|
|||
|
||||
record: Final = await _prisma_table(VerificationTokenRepository(prisma_client)).update(
|
||||
where={"token": hashed_token},
|
||||
data={"blocked": False},
|
||||
data=with_settings_updated_at({"blocked": False}),
|
||||
)
|
||||
|
||||
## UPDATE KEY CACHE - invalidate so next read re-fetches from DB
|
||||
|
|
|
|||
14
litellm/proxy/management_helpers/key_settings_audit.py
Normal file
14
litellm/proxy/management_helpers/key_settings_audit.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
"""Audit stamping for virtual key configuration changes."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def with_settings_updated_at(data: Mapping[str, object]) -> dict[str, object]:
|
||||
"""Stamp a key update payload with the time its configuration changed.
|
||||
|
||||
``updated_at`` carries Prisma's ``@updatedAt`` and so is rewritten by every
|
||||
spend flush, which makes it useless for auditing; ``settings_updated_at`` is
|
||||
written only from key-management write paths.
|
||||
"""
|
||||
return {**data, "settings_updated_at": datetime.now(timezone.utc)}
|
||||
|
|
@ -452,6 +452,7 @@ model LiteLLM_VerificationToken {
|
|||
created_by String?
|
||||
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
|
||||
updated_by String?
|
||||
settings_updated_at DateTime? @map("settings_updated_at")
|
||||
last_active DateTime? // When this key was last used
|
||||
rotation_count Int? @default(0) // Number of times key has been rotated
|
||||
auto_rotate Boolean? @default(false) // Whether this key should be auto-rotated
|
||||
|
|
@ -548,6 +549,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
created_by String? // Original creator
|
||||
updated_at DateTime? // Last update timestamp before deletion
|
||||
updated_by String? // Last user who updated before deletion
|
||||
settings_updated_at DateTime? // Last configuration change before deletion
|
||||
last_active DateTime? // When this key was last used before deletion
|
||||
rotation_count Int? @default(0)
|
||||
auto_rotate Boolean? @default(false)
|
||||
|
|
|
|||
|
|
@ -135,6 +135,7 @@ from litellm.proxy.hooks.sensitive_data_routing import (
|
|||
_PROXY_SensitiveDataRoutingHandler,
|
||||
)
|
||||
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
|
||||
from litellm.proxy.management_helpers.key_settings_audit import with_settings_updated_at
|
||||
from litellm.proxy.policy_engine.pipeline_executor import PipelineExecutor
|
||||
from litellm.repositories.budget_repository import BudgetRepository
|
||||
from litellm.repositories.config_repository import ConfigRepository
|
||||
|
|
@ -3999,7 +4000,7 @@ class PrismaClient:
|
|||
db_data["token"] = token
|
||||
response: Final = await VerificationTokenRepository(self).table.update(
|
||||
where={"token": token},
|
||||
data={**db_data},
|
||||
data=with_settings_updated_at(db_data),
|
||||
)
|
||||
verbose_proxy_logger.debug("\033[91m" + f"DB Token Table update succeeded {response}" + "\033[0m")
|
||||
_data: dict = {}
|
||||
|
|
|
|||
|
|
@ -410,6 +410,7 @@ class Router:
|
|||
enable_pre_call_checks: bool = False,
|
||||
enable_tag_filtering: bool = False,
|
||||
tag_filtering_match_any: bool = True,
|
||||
tag_routing_prefix: str = "",
|
||||
plugins: list[RoutingPlugin] | None = None,
|
||||
retry_after: int = 0, # min time to wait before retrying a failed request
|
||||
retry_policy: RetryPolicy | dict | None = None, # set custom retries for different exceptions
|
||||
|
|
@ -519,6 +520,7 @@ class Router:
|
|||
self.enable_pre_call_checks = enable_pre_call_checks
|
||||
self.enable_tag_filtering = enable_tag_filtering
|
||||
self.tag_filtering_match_any = tag_filtering_match_any
|
||||
self.tag_routing_prefix = tag_routing_prefix
|
||||
from litellm._service_logger import ServiceLogging
|
||||
|
||||
self.service_logger_obj: ServiceLogging = ServiceLogging()
|
||||
|
|
@ -10122,6 +10124,7 @@ class Router:
|
|||
"model_group_alias",
|
||||
"enable_weighted_failover",
|
||||
"enable_tag_filtering",
|
||||
"tag_routing_prefix",
|
||||
]
|
||||
|
||||
for var in vars_to_include:
|
||||
|
|
@ -10159,6 +10162,7 @@ class Router:
|
|||
"model_group_alias",
|
||||
"enable_weighted_failover",
|
||||
"enable_tag_filtering",
|
||||
"tag_routing_prefix",
|
||||
]
|
||||
|
||||
_int_settings: Final = [
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ Use this to route requests between Teams
|
|||
- If tags in request is a subset of tags in deployment, return deployment
|
||||
- if deployments are set with default tags, return all default deployment
|
||||
- If no default_deployments are set, return all deployments
|
||||
- A "!tag" excludes deployments carrying that tag; a "&tag" requires it
|
||||
"""
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -114,14 +117,52 @@ def _match_deployment(
|
|||
return None
|
||||
|
||||
|
||||
def _split_tags(tags: list[str]) -> tuple[list[str], list[str]]:
|
||||
positive: Final = [t for t in tags if not t.startswith("!")]
|
||||
excluded: Final = [tag[1:] for tag in tags if tag.startswith("!") and len(tag) > 1]
|
||||
return positive, excluded
|
||||
def _bare_tag_value(tag: str) -> str | None:
|
||||
# Mirrors _split_tags' own stripping rule exactly, so a confirmed value
|
||||
# compares equal to whatever required_set/excluded_set/positive_tags end up
|
||||
# holding for the same tag: a "&"/"!" marker is stripped only when something
|
||||
# follows it; a lone marker with nothing after it parses to nothing in any
|
||||
# of the three sets, so it must not become a confirmed value either.
|
||||
if tag.startswith(("&", "!")):
|
||||
return tag[1:] if len(tag) > 1 else None
|
||||
return tag
|
||||
|
||||
|
||||
def _strip_routing_prefix(tags: Sequence[str], prefix: str) -> tuple[tuple[str, ...], frozenset[str]]:
|
||||
# Strips the configured routing-prefix marker from any tag carrying it, used
|
||||
# exactly as configured with no delimiter auto-appended, and separately
|
||||
# tracks the post-strip, post-marker-strip values that arrived prefixed: tags
|
||||
# whose routing intent the caller declared explicitly, exempt from the "maybe
|
||||
# foreign to this group" heuristics in _unknown_required_tag_hides_an_answer
|
||||
# and _tag_known_to_group below. Confirmed values are compared against
|
||||
# required_set/excluded_set downstream, which are themselves already stripped
|
||||
# of their "&"/"!" marker by _split_tags -- confirmed must match that same
|
||||
# bare form, not the raw post-prefix-strip value that still carries the
|
||||
# marker character. An empty prefix must return every tag unconfirmed, not
|
||||
# run every tag through str.startswith(""), which is trivially True for
|
||||
# every string and would mark everything confirmed.
|
||||
if not prefix:
|
||||
return tuple(tags), frozenset()
|
||||
rewritten: Final = tuple(t.removeprefix(prefix) for t in tags)
|
||||
confirmed: Final = frozenset(
|
||||
bare
|
||||
for bare in (_bare_tag_value(t.removeprefix(prefix)) for t in tags if t.startswith(prefix))
|
||||
if bare is not None
|
||||
)
|
||||
return rewritten, confirmed
|
||||
|
||||
|
||||
def _split_tags(tags: Sequence[str]) -> tuple[tuple[str, ...], list[str], tuple[str, ...]]:
|
||||
required: Final = tuple(tag[1:] for tag in tags if tag.startswith("&") and len(tag) > 1)
|
||||
positive: Final = [
|
||||
t for t in tags if not t.startswith("!") and not t.startswith("&")
|
||||
] # mutable-ok: feeds _match_deployment's existing list[str]-typed request_tags param
|
||||
excluded: Final = tuple(tag[1:] for tag in tags if tag.startswith("!") and len(tag) > 1)
|
||||
return required, positive, excluded
|
||||
|
||||
|
||||
def _exclude_deployments(
|
||||
deployments: list[Any] | dict[Any, Any],
|
||||
deployments: Sequence[Any] | Mapping[Any, Any],
|
||||
excluded_set: frozenset[str],
|
||||
) -> list[Any]:
|
||||
if not excluded_set:
|
||||
|
|
@ -129,24 +170,223 @@ def _exclude_deployments(
|
|||
return [d for d in deployments if not excluded_set.intersection(d.get("litellm_params", {}).get("tags") or [])]
|
||||
|
||||
|
||||
def _require_candidates(
|
||||
candidates: list[Any],
|
||||
def _require_all_tags(
|
||||
deployments: Sequence[Any] | Mapping[Any, Any],
|
||||
required_set: frozenset[str],
|
||||
) -> tuple[Any, ...]:
|
||||
if not required_set:
|
||||
return tuple(deployments)
|
||||
return tuple(d for d in deployments if required_set.issubset(d.get("litellm_params", {}).get("tags") or []))
|
||||
|
||||
|
||||
def _default_tagged_pool(
|
||||
deployments: Sequence[Any] | Mapping[Any, Any],
|
||||
) -> tuple[Any, ...]:
|
||||
defaults: Final = tuple(d for d in deployments if "default" in (d.get("litellm_params", {}).get("tags") or []))
|
||||
return defaults if defaults else tuple(deployments)
|
||||
|
||||
|
||||
def _known_tag_values(deployments: Sequence[Any] | Mapping[Any, Any]) -> frozenset[str]:
|
||||
return frozenset(
|
||||
tag for d in deployments for tag in (d.get("litellm_params", MappingProxyType({})).get("tags") or ())
|
||||
)
|
||||
|
||||
|
||||
def _unknown_required_tag_hides_an_answer(
|
||||
healthy_deployments: Sequence[Any] | Mapping[Any, Any],
|
||||
excluded_set: frozenset[str],
|
||||
required_set: frozenset[str],
|
||||
routing_confirmed: frozenset[str],
|
||||
) -> bool:
|
||||
# A caller-invented "&" tag (one no deployment in this group has ever carried)
|
||||
# guarantees an empty required-AND result on its own, regardless of whether the
|
||||
# rest of the request's required tags were satisfiable. Dropping the unknown
|
||||
# tags and recomputing: if that reveals a specific, non-empty answer, the invented
|
||||
# tag was the actual cause of the exhaustion, and fail-open must not paper over
|
||||
# it. If every required tag is known, or none are, there's nothing hidden to
|
||||
# protect: either the caller made a real, honestly-unsatisfiable ask (fail-open
|
||||
# proceeds normally), or the whole required set is unrecognized noise with no
|
||||
# narrower answer to hide behind it. routing_confirmed (tag_routing_prefix)
|
||||
# counts as known too: the caller explicitly declared it a routing directive,
|
||||
# so it is never treated as invented noise regardless of deployment vocabulary.
|
||||
known_required: Final = required_set & (_known_tag_values(healthy_deployments) | routing_confirmed)
|
||||
if not known_required or known_required == required_set:
|
||||
return False
|
||||
allowed: Final = _exclude_deployments(healthy_deployments, excluded_set)
|
||||
return bool(_require_all_tags(allowed, known_required))
|
||||
|
||||
|
||||
def _chain_allows_fail_open(
|
||||
healthy_deployments: Sequence[Any] | Mapping[Any, Any],
|
||||
excluded_set: frozenset[str],
|
||||
required_set: frozenset[str],
|
||||
routing_confirmed: frozenset[str],
|
||||
) -> bool:
|
||||
if _unknown_required_tag_hides_an_answer(healthy_deployments, excluded_set, required_set, routing_confirmed):
|
||||
return False
|
||||
return any((d.get("model_info") or {}).get("allow_fail_open") is True for d in healthy_deployments)
|
||||
|
||||
|
||||
def _trusted_only_pool(
|
||||
healthy_deployments: Sequence[Any] | Mapping[Any, Any],
|
||||
excluded_set: frozenset[str],
|
||||
required_set: frozenset[str],
|
||||
inherited_excluded_set: frozenset[str] | None,
|
||||
inherited_required_set: frozenset[str] | None,
|
||||
) -> tuple[Any, ...]:
|
||||
# inherited_*_set is None only when this request carries no origin information
|
||||
# at all (e.g. direct SDK Router usage, bypassing the proxy layer that
|
||||
# populates metadata.inherited_tags) -- treat every constraint as
|
||||
# caller-controlled in that case (protected == empty), reproducing this
|
||||
# function's pre-provenance behavior exactly: an unconditional fall-open to the
|
||||
# full default-tagged pool, constraints discarded entirely. Otherwise, a tag
|
||||
# value is protected the moment it has ANY inherited backing, even when the
|
||||
# caller also happens to submit the identical value themselves -- set
|
||||
# membership can't distinguish "this value came from policy" from "this value
|
||||
# coincidentally matches policy," so presence in the inherited set (not
|
||||
# absence from a caller-supplied set) is what must gate discardability. This
|
||||
# is deliberately intersection with inherited_*_set, not subtraction of a
|
||||
# caller-supplied set: subtraction would let a caller strip an inherited
|
||||
# requirement's protection just by resubmitting its exact value alongside a
|
||||
# conflicting one (e.g. inherited "®ion:eu" plus caller "®ion:eu"
|
||||
# and "!region:eu" would otherwise cancel the inherited requirement out).
|
||||
trusted_excluded: Final = (
|
||||
frozenset[str]() if inherited_excluded_set is None else inherited_excluded_set & excluded_set
|
||||
)
|
||||
trusted_required: Final = (
|
||||
frozenset[str]() if inherited_required_set is None else inherited_required_set & required_set
|
||||
)
|
||||
return _require_all_tags(_exclude_deployments(healthy_deployments, trusted_excluded), trusted_required)
|
||||
|
||||
|
||||
def _resolve_or_fail_open(
|
||||
pool: Sequence[Any],
|
||||
healthy_deployments: Sequence[Any] | Mapping[Any, Any],
|
||||
excluded_set: frozenset[str],
|
||||
required_set: frozenset[str],
|
||||
inherited_excluded_set: frozenset[str] | None,
|
||||
inherited_required_set: frozenset[str] | None,
|
||||
routing_confirmed: frozenset[str],
|
||||
model: str,
|
||||
request_tags: Any,
|
||||
) -> list[Any]:
|
||||
if not candidates:
|
||||
raise ValueError(
|
||||
f"{RouterErrors.no_deployments_with_tag_routing.value}. Passed model={model} and tags={request_tags}"
|
||||
request_tags: object,
|
||||
) -> tuple[Any, ...]:
|
||||
if pool:
|
||||
return tuple(pool)
|
||||
if _chain_allows_fail_open(healthy_deployments, excluded_set, required_set, routing_confirmed):
|
||||
# Fall open only within whatever still satisfies whichever constraints
|
||||
# trace back to key/team policy. A constraint with no inherited backing at
|
||||
# all (or, when inherited_tags is unavailable, any constraint at all) can
|
||||
# be discarded; one inherited from key/team policy cannot -- if that alone
|
||||
# is unsatisfiable, raise instead of silently routing around it.
|
||||
trusted_pool: Final = _trusted_only_pool(
|
||||
healthy_deployments, excluded_set, required_set, inherited_excluded_set, inherited_required_set
|
||||
)
|
||||
return candidates
|
||||
if trusted_pool:
|
||||
return _default_tagged_pool(trusted_pool)
|
||||
raise ValueError(
|
||||
f"{RouterErrors.no_deployments_with_tag_routing.value}. Passed model={model} and tags={request_tags}"
|
||||
)
|
||||
|
||||
|
||||
def _ban_only_base_pool(
|
||||
deployments: list[Any] | dict[Any, Any],
|
||||
) -> list[Any]:
|
||||
# Mirrors untagged-request semantics so callers can't use !tags to escape the default pool.
|
||||
defaults: Final = [d for d in deployments if "default" in (d.get("litellm_params", {}).get("tags") or [])]
|
||||
return defaults if defaults else list(deployments)
|
||||
def _resolve_constraint_only_pool(
|
||||
healthy_deployments: Sequence[Any] | Mapping[Any, Any],
|
||||
excluded_set: frozenset[str],
|
||||
required_set: frozenset[str],
|
||||
inherited_excluded_set: frozenset[str] | None,
|
||||
inherited_required_set: frozenset[str] | None,
|
||||
routing_confirmed: frozenset[str],
|
||||
model: str,
|
||||
request_tags: object,
|
||||
) -> tuple[Any, ...]:
|
||||
pool: Final = (
|
||||
_require_all_tags(_exclude_deployments(healthy_deployments, excluded_set), required_set)
|
||||
if required_set
|
||||
else _exclude_deployments(_default_tagged_pool(healthy_deployments), excluded_set)
|
||||
)
|
||||
return _resolve_or_fail_open(
|
||||
pool,
|
||||
healthy_deployments,
|
||||
excluded_set,
|
||||
required_set,
|
||||
inherited_excluded_set,
|
||||
inherited_required_set,
|
||||
routing_confirmed,
|
||||
model,
|
||||
request_tags,
|
||||
)
|
||||
|
||||
|
||||
def _all_deployments_or_fallback(
|
||||
llm_router_instance: LitellmRouter,
|
||||
model: str,
|
||||
fallback: Sequence[Any] | Mapping[Any, Any],
|
||||
) -> Sequence[Any] | Mapping[Any, Any]:
|
||||
try:
|
||||
return llm_router_instance._get_all_deployments(model_name=model)
|
||||
except Exception: # noqa: BLE001 # fail safe toward today's healthy-only behavior on lookup errors
|
||||
return fallback
|
||||
|
||||
|
||||
def _chain_tag_filtering_override(
|
||||
llm_router_instance: LitellmRouter,
|
||||
model: str,
|
||||
healthy_deployments: Sequence[Any] | Mapping[Any, Any],
|
||||
) -> bool | None:
|
||||
# Resolved from every deployment configured for this model group, not just the
|
||||
# ones that survived cooldown/health filtering (async_get_healthy_deployments
|
||||
# filters cooldowns before calling get_deployments_for_tag) -- otherwise the
|
||||
# sole deployment carrying this group's only explicit override loses its effect
|
||||
# the moment it's transiently unhealthy, silently falling back to the
|
||||
# router-wide default and letting an attacker disable a chain's tag policy by
|
||||
# repeatedly failing that one deployment into cooldown. Falls back to
|
||||
# healthy_deployments on a lookup error, preserving today's behavior rather
|
||||
# than crashing the request.
|
||||
all_deployments: Final = _all_deployments_or_fallback(llm_router_instance, model, healthy_deployments)
|
||||
for d in all_deployments:
|
||||
value = (d.get("model_info") or MappingProxyType({})).get("enable_tag_filtering")
|
||||
if value is not None:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _inherited_constraint_sets(
|
||||
inherited_tags: object, routing_prefix: str
|
||||
) -> tuple[frozenset[str] | None, frozenset[str] | None]:
|
||||
# None means no origin information is available at all (e.g. this request
|
||||
# bypassed the proxy layer that populates metadata.inherited_tags, as direct
|
||||
# SDK Router usage does) -- callers of this must treat that as "nothing is
|
||||
# protected," not "nothing is inherited," see _trusted_only_pool.
|
||||
# metadata.inherited_tags is a snapshot of whatever key/team/project policy
|
||||
# merged into "tags" *before* this request's own caller-supplied tags were
|
||||
# merged in on top (see litellm_pre_call_utils.py), so a value present here is
|
||||
# policy-backed regardless of whether the caller also happens to submit the
|
||||
# identical value. inherited_tags is stripped through the same routing_prefix
|
||||
# as the main request tags so a policy-inherited prefixed tag still matches
|
||||
# correctly against the (already-stripped) required_set/excluded_set computed
|
||||
# from request_tags.
|
||||
if not isinstance(inherited_tags, (list, tuple)):
|
||||
return None, None
|
||||
rewritten_inherited_tags: Final = _strip_routing_prefix(inherited_tags, routing_prefix)[0]
|
||||
inherited_required, _inherited_positive, inherited_excluded = _split_tags(rewritten_inherited_tags)
|
||||
return frozenset(inherited_required), frozenset(inherited_excluded)
|
||||
|
||||
|
||||
def _tag_known_to_group(
|
||||
llm_router_instance: LitellmRouter,
|
||||
model: str,
|
||||
positive_tags: Sequence[str],
|
||||
routing_confirmed: frozenset[str],
|
||||
) -> bool:
|
||||
tag_set: Final = frozenset(positive_tags)
|
||||
if tag_set & routing_confirmed:
|
||||
return True
|
||||
try:
|
||||
all_deployments: Final = llm_router_instance._get_all_deployments(model_name=model)
|
||||
except Exception: # noqa: BLE001 # fail safe toward "unrecognized" so lookup errors preserve the existing silent-fallback behavior
|
||||
return False
|
||||
return any(
|
||||
tag_set.intersection(d.get("litellm_params", MappingProxyType({})).get("tags") or ()) for d in all_deployments
|
||||
)
|
||||
|
||||
|
||||
async def get_deployments_for_tag(
|
||||
|
|
@ -161,24 +401,29 @@ async def get_deployments_for_tag(
|
|||
|
||||
Executes tag based filtering based on the tags in request metadata and the tags on the deployments
|
||||
|
||||
Runs when the router-level `enable_tag_filtering` is True or the request carries
|
||||
`enable_tag_filtering=True` (set from key/team router_settings by the proxy).
|
||||
A request-level False never disables a router-level True, so per-request settings
|
||||
cannot escape an operator's global tag-routing policy.
|
||||
Runs when the effective enable_tag_filtering is True. Effective value: a
|
||||
request-level enable_tag_filtering=True (set from key/team router_settings by
|
||||
the proxy) always wins; otherwise model_info.enable_tag_filtering on this model
|
||||
group, if set on any of its deployments, overrides the router-wide default.
|
||||
A request-level False never disables either of those, so per-request settings
|
||||
cannot escape an operator's or a chain owner's tag-routing policy.
|
||||
"""
|
||||
request_enable_tag_filtering: Final = request_kwargs.get("enable_tag_filtering") if request_kwargs else None
|
||||
if request_enable_tag_filtering is not True and llm_router_instance.enable_tag_filtering is not True:
|
||||
return healthy_deployments
|
||||
|
||||
if request_kwargs is None:
|
||||
if request_kwargs is None or not healthy_deployments:
|
||||
verbose_logger.debug(
|
||||
"get_deployments_for_tag: request_kwargs is None returning healthy_deployments: %s",
|
||||
"get_deployments_for_tag: skipping tag filter (request_kwargs=%s, healthy_deployments=%s)",
|
||||
request_kwargs,
|
||||
healthy_deployments,
|
||||
)
|
||||
return healthy_deployments
|
||||
|
||||
if not healthy_deployments:
|
||||
verbose_logger.debug("get_deployments_for_tag: empty or None healthy_deployments; skipping tag filter")
|
||||
request_enable_tag_filtering: Final = request_kwargs.get("enable_tag_filtering")
|
||||
chain_enable_tag_filtering: Final = _chain_tag_filtering_override(llm_router_instance, model, healthy_deployments)
|
||||
chain_default: Final = (
|
||||
chain_enable_tag_filtering
|
||||
if chain_enable_tag_filtering is not None
|
||||
else llm_router_instance.enable_tag_filtering
|
||||
)
|
||||
if request_enable_tag_filtering is not True and chain_default is not True:
|
||||
return healthy_deployments
|
||||
|
||||
verbose_logger.debug("request metadata: %s", request_kwargs.get(metadata_variable_name))
|
||||
|
|
@ -186,29 +431,52 @@ async def get_deployments_for_tag(
|
|||
metadata: Final = request_kwargs[metadata_variable_name]
|
||||
request_tags: Final = metadata.get("tags")
|
||||
match_any: Final = llm_router_instance.tag_filtering_match_any
|
||||
routing_prefix: Final = llm_router_instance.tag_routing_prefix or ""
|
||||
|
||||
# Build header strings for regex matching from what the proxy already stores.
|
||||
# Currently we match against User-Agent; format matches "^User-Agent: claude-code/..."
|
||||
user_agent: Final = metadata.get("user_agent", "")
|
||||
header_strings: Final[list[str]] = [f"User-Agent: {user_agent}"] if user_agent else []
|
||||
|
||||
positive_tags, excluded_patterns = _split_tags(request_tags or [])
|
||||
# A tag_routing_prefix-marked tag is stripped before matching -- everything
|
||||
# downstream (_split_tags, deployment matching) works off the unprefixed
|
||||
# value, exactly as if the caller had sent it unprefixed -- and its
|
||||
# post-strip value is remembered in routing_confirmed as an explicit,
|
||||
# caller-declared routing directive, exempt from the "maybe foreign to this
|
||||
# group" heuristics that unprefixed tags still go through unchanged below.
|
||||
rewritten_tags, routing_confirmed = _strip_routing_prefix(request_tags or [], routing_prefix)
|
||||
required_tags, positive_tags, excluded_patterns = _split_tags(rewritten_tags)
|
||||
inherited_required_set, inherited_excluded_set = _inherited_constraint_sets(
|
||||
metadata.get("inherited_tags"), routing_prefix
|
||||
)
|
||||
|
||||
excluded_set: Final = frozenset(excluded_patterns)
|
||||
candidates: Final = _exclude_deployments(healthy_deployments, excluded_set)
|
||||
required_set: Final = frozenset(required_tags)
|
||||
allowed_deployments: Final = _exclude_deployments(healthy_deployments, excluded_set)
|
||||
candidates: Final = _require_all_tags(allowed_deployments, required_set)
|
||||
|
||||
has_regex_deployments: Final = any(d.get("litellm_params", {}).get("tag_regex") for d in candidates)
|
||||
has_tag_filter: Final = bool(positive_tags) or (bool(header_strings) and has_regex_deployments)
|
||||
ban_only: Final = bool(excluded_set) and not has_tag_filter
|
||||
has_positive_filter: Final = bool(positive_tags) or (
|
||||
bool(header_strings) and has_regex_deployments and not required_set
|
||||
)
|
||||
constraint_only: Final = (bool(excluded_set) or bool(required_set)) and not has_positive_filter
|
||||
|
||||
if ban_only:
|
||||
pool: Final = _exclude_deployments(_ban_only_base_pool(healthy_deployments), excluded_set)
|
||||
return _require_candidates(pool, model, request_tags)
|
||||
if constraint_only:
|
||||
return _resolve_constraint_only_pool(
|
||||
healthy_deployments,
|
||||
excluded_set,
|
||||
required_set,
|
||||
inherited_excluded_set,
|
||||
inherited_required_set,
|
||||
routing_confirmed,
|
||||
model,
|
||||
request_tags,
|
||||
)
|
||||
|
||||
new_healthy_deployments: Final[list[Any]] = []
|
||||
default_deployments: Final[list[Any]] = []
|
||||
|
||||
if has_tag_filter:
|
||||
if has_positive_filter:
|
||||
verbose_logger.debug(
|
||||
"get_deployments_for_tag routing: request_tags=%s user_agent=%s",
|
||||
request_tags,
|
||||
|
|
@ -245,9 +513,33 @@ async def get_deployments_for_tag(
|
|||
default_deployments.append(deployment)
|
||||
|
||||
if len(new_healthy_deployments) == 0 and len(default_deployments) == 0:
|
||||
raise ValueError(
|
||||
f"{RouterErrors.no_deployments_with_tag_routing.value}."
|
||||
f" Passed model={model} and tags={request_tags}"
|
||||
return _resolve_or_fail_open(
|
||||
(),
|
||||
healthy_deployments,
|
||||
excluded_set,
|
||||
required_set,
|
||||
inherited_excluded_set,
|
||||
inherited_required_set,
|
||||
routing_confirmed,
|
||||
model,
|
||||
request_tags,
|
||||
)
|
||||
|
||||
if (
|
||||
len(new_healthy_deployments) == 0
|
||||
and positive_tags
|
||||
and _tag_known_to_group(llm_router_instance, model, positive_tags, routing_confirmed)
|
||||
):
|
||||
return _resolve_or_fail_open(
|
||||
(),
|
||||
healthy_deployments,
|
||||
excluded_set,
|
||||
required_set,
|
||||
inherited_excluded_set,
|
||||
inherited_required_set,
|
||||
routing_confirmed,
|
||||
model,
|
||||
request_tags,
|
||||
)
|
||||
|
||||
return new_healthy_deployments if len(new_healthy_deployments) > 0 else default_deployments
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ class UpdateRouterConfig(BaseModel):
|
|||
context_window_fallbacks: list[dict] | None = None
|
||||
model_group_alias: dict[str, str | dict] | None = {}
|
||||
enable_tag_filtering: bool | None = None
|
||||
tag_routing_prefix: str | None = None
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
|
|
@ -170,6 +171,20 @@ class ModelInfo(MirroredPricingParams):
|
|||
ptu_effective_from: datetime.datetime | None = None
|
||||
ptu_effective_to: datetime.datetime | None = None
|
||||
|
||||
# when tag-based routing's "!" or "&" constraints eliminate every deployment
|
||||
# in this model group, fall back to the default-tagged pool instead of
|
||||
# raising no_deployments_with_tag_routing. Defaults to False (raise), so
|
||||
# existing "!" negation behavior is unchanged unless explicitly opted in.
|
||||
allow_fail_open: bool | None = None
|
||||
|
||||
# per-model-group override for router_settings.enable_tag_filtering; unset
|
||||
# defers to the router-wide default. Checked against any deployment in the
|
||||
# group, so set it consistently across every deployment sharing this
|
||||
# model_name. A request-level enable_tag_filtering=True (from key/team
|
||||
# settings) still wins over this, exactly as it already does over the
|
||||
# router-wide default.
|
||||
enable_tag_filtering: bool | None = None
|
||||
|
||||
def __init__(self, id: str | int | None = None, **params) -> None:
|
||||
if id is None:
|
||||
id = str(uuid.uuid4()) # Generate a UUID if id is None or not provided
|
||||
|
|
|
|||
|
|
@ -3467,6 +3467,7 @@ all_litellm_params = (
|
|||
"caching_groups",
|
||||
"ttl",
|
||||
"cache",
|
||||
"enable_prompt_caching",
|
||||
"no-log",
|
||||
"base_model",
|
||||
"stream_timeout",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
"limit": 133
|
||||
},
|
||||
"ANN401": {
|
||||
"limit": 1388
|
||||
"limit": 1384
|
||||
},
|
||||
"ASYNC230": {
|
||||
"limit": 11
|
||||
|
|
|
|||
|
|
@ -452,6 +452,7 @@ model LiteLLM_VerificationToken {
|
|||
created_by String?
|
||||
updated_at DateTime? @default(now()) @updatedAt @map("updated_at")
|
||||
updated_by String?
|
||||
settings_updated_at DateTime? @map("settings_updated_at")
|
||||
last_active DateTime? // When this key was last used
|
||||
rotation_count Int? @default(0) // Number of times key has been rotated
|
||||
auto_rotate Boolean? @default(false) // Whether this key should be auto-rotated
|
||||
|
|
@ -548,6 +549,7 @@ model LiteLLM_DeletedVerificationToken {
|
|||
created_by String? // Original creator
|
||||
updated_at DateTime? // Last update timestamp before deletion
|
||||
updated_by String? // Last user who updated before deletion
|
||||
settings_updated_at DateTime? // Last configuration change before deletion
|
||||
last_active DateTime? // When this key was last used before deletion
|
||||
rotation_count Int? @default(0)
|
||||
auto_rotate Boolean? @default(false)
|
||||
|
|
|
|||
|
|
@ -1728,6 +1728,87 @@ class TestEnableAnthropicPromptCaching:
|
|||
assert result_msgs[-1]["content"][-1]["cache_control"] == {"type": "ephemeral"}
|
||||
assert "cache_control" not in result_msgs[0]["content"][-1]
|
||||
|
||||
|
||||
class TestPerKeyEnablePromptCaching:
|
||||
"""Per-request enable_prompt_caching override (stamped from key metadata) with the global flag off."""
|
||||
|
||||
MESSAGES: List[AllMessageValues] = [
|
||||
{"role": "system", "content": "a long system prompt"},
|
||||
{"role": "user", "content": "latest turn"},
|
||||
]
|
||||
|
||||
def _points(self, enable_prompt_caching, model="claude-sonnet-4-5", provider="anthropic", messages=None):
|
||||
return AnthropicCacheControlHook.get_default_injection_points(
|
||||
messages=copy.deepcopy(self.MESSAGES) if messages is None else messages,
|
||||
system=None,
|
||||
model=model,
|
||||
custom_llm_provider=provider,
|
||||
enable_prompt_caching=enable_prompt_caching,
|
||||
)
|
||||
|
||||
def test_true_injects_with_global_flag_off(self):
|
||||
assert litellm.enable_anthropic_prompt_caching is False
|
||||
assert self._points(True) == [
|
||||
{"location": "message", "role": "system", "index": None, "control": {"type": "ephemeral"}},
|
||||
{"location": "message", "role": None, "index": -1, "control": {"type": "ephemeral"}},
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("enable_prompt_caching", [False, None])
|
||||
def test_false_and_none_fall_back_to_global_flag(self, enable_prompt_caching):
|
||||
assert self._points(enable_prompt_caching) == []
|
||||
|
||||
def test_false_does_not_suppress_global_flag(self, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True)
|
||||
assert [p["index"] for p in self._points(False)] == [None, -1]
|
||||
|
||||
def test_provider_gate_still_applies(self):
|
||||
assert self._points(True, model="gpt-4o", provider="openai") == []
|
||||
|
||||
def test_unsupported_model_gate_still_applies(self):
|
||||
assert self._points(True, model="anthropic.claude-3-5-sonnet-20240620-v1:0", provider="bedrock") == []
|
||||
|
||||
def test_client_markers_still_win(self):
|
||||
messages = [
|
||||
{"role": "system", "content": [{"type": "text", "text": "s", "cache_control": {"type": "ephemeral"}}]},
|
||||
{"role": "user", "content": "latest turn"},
|
||||
]
|
||||
assert self._points(True, messages=messages) == []
|
||||
|
||||
def test_seed_injects_with_global_flag_off(self):
|
||||
params: dict = {}
|
||||
AnthropicCacheControlHook.maybe_seed_default_injection_points(
|
||||
non_default_params=params,
|
||||
messages=copy.deepcopy(self.MESSAGES),
|
||||
model="claude-sonnet-4-5",
|
||||
custom_llm_provider="anthropic",
|
||||
enable_prompt_caching=True,
|
||||
)
|
||||
assert [p["index"] for p in params["cache_control_injection_points"]] == [None, -1]
|
||||
|
||||
def test_v1_messages_injects_and_pops_flag_from_kwargs(self):
|
||||
kwargs: dict = {"enable_prompt_caching": True}
|
||||
result_msgs, result_sys = AnthropicCacheControlHook.maybe_inject_cache_control(
|
||||
[{"role": "user", "content": [{"type": "text", "text": "latest"}]}],
|
||||
"a system prompt",
|
||||
kwargs,
|
||||
model="claude-sonnet-4-5",
|
||||
custom_llm_provider="anthropic",
|
||||
)
|
||||
assert result_sys == [{"type": "text", "text": "a system prompt", "cache_control": {"type": "ephemeral"}}]
|
||||
assert result_msgs[-1]["content"][-1]["cache_control"] == {"type": "ephemeral"}
|
||||
assert "enable_prompt_caching" not in kwargs
|
||||
|
||||
def test_v1_messages_pops_flag_even_when_noop(self):
|
||||
kwargs: dict = {"enable_prompt_caching": True}
|
||||
AnthropicCacheControlHook.maybe_inject_cache_control(
|
||||
[{"role": "user", "content": [{"type": "text", "text": "hi"}]}],
|
||||
None,
|
||||
kwargs,
|
||||
model="gpt-4o",
|
||||
custom_llm_provider="openai",
|
||||
)
|
||||
assert "enable_prompt_caching" not in kwargs
|
||||
|
||||
def test_v1_messages_is_noop_when_disabled(self):
|
||||
messages = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
result_msgs, result_sys = AnthropicCacheControlHook.maybe_inject_cache_control(
|
||||
|
|
|
|||
|
|
@ -2157,3 +2157,67 @@ async def test_daily_transaction_compression_saved_tokens_zero_when_absent():
|
|||
assert transaction["compression_saved_tokens"] == 0
|
||||
assert transaction["compression_savings_spend"] == 0
|
||||
assert transaction["prompt_caching_savings_spend"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commit_spend_updates_to_db_does_not_stamp_key_settings_updated_at():
|
||||
"""Spend flushes must leave settings_updated_at alone, or it decays into
|
||||
another `updated_at` and stops being an audit signal."""
|
||||
db_writer = DBSpendUpdateWriter()
|
||||
|
||||
mock_batcher = MagicMock()
|
||||
mock_batcher.litellm_verificationtoken = MagicMock()
|
||||
mock_batcher.litellm_verificationtoken.update_many = MagicMock()
|
||||
mock_batcher.litellm_usertable = MagicMock()
|
||||
mock_batcher.litellm_usertable.update_many = MagicMock()
|
||||
mock_batcher.litellm_teamtable = MagicMock()
|
||||
mock_batcher.litellm_teamtable.update_many = MagicMock()
|
||||
mock_batcher.litellm_teammembership = MagicMock()
|
||||
mock_batcher.litellm_teammembership.update_many = MagicMock()
|
||||
mock_batcher.litellm_organizationtable = MagicMock()
|
||||
mock_batcher.litellm_organizationtable.update_many = MagicMock()
|
||||
mock_batcher.litellm_tagtable = MagicMock()
|
||||
mock_batcher.litellm_tagtable.update_many = MagicMock()
|
||||
mock_batcher.litellm_agentstable = MagicMock()
|
||||
mock_batcher.litellm_agentstable.update_many = MagicMock()
|
||||
|
||||
mock_transaction = AsyncMock()
|
||||
mock_transaction.__aenter__ = AsyncMock(return_value=mock_transaction)
|
||||
mock_transaction.__aexit__ = AsyncMock(return_value=False)
|
||||
mock_transaction.batch_ = MagicMock(
|
||||
return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_batcher),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
)
|
||||
)
|
||||
|
||||
mock_prisma_client = MagicMock()
|
||||
mock_prisma_client.db = MagicMock()
|
||||
mock_prisma_client.db.tx = MagicMock(return_value=mock_transaction)
|
||||
|
||||
token = "hashed-token-abc"
|
||||
response_cost = 0.25
|
||||
db_spend_update_transactions = {
|
||||
"user_list_transactions": {},
|
||||
"end_user_list_transactions": {},
|
||||
"key_list_transactions": {token: response_cost},
|
||||
"team_list_transactions": {},
|
||||
"team_member_list_transactions": {},
|
||||
"org_list_transactions": {},
|
||||
"tag_list_transactions": {},
|
||||
"agent_list_transactions": {},
|
||||
}
|
||||
|
||||
with patch("litellm.proxy.utils._raise_failed_update_spend_exception"):
|
||||
await db_writer._commit_spend_updates_to_db(
|
||||
prisma_client=mock_prisma_client,
|
||||
n_retry_times=0,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
db_spend_update_transactions=db_spend_update_transactions,
|
||||
)
|
||||
|
||||
mock_batcher.litellm_verificationtoken.update_many.assert_called_once()
|
||||
call_kwargs = mock_batcher.litellm_verificationtoken.update_many.call_args[1]
|
||||
assert call_kwargs["where"] == {"token": token}
|
||||
assert set(call_kwargs["data"]) == {"spend", "last_active"}
|
||||
assert call_kwargs["data"]["spend"] == {"increment": response_cost}
|
||||
|
|
|
|||
|
|
@ -1733,6 +1733,21 @@ async def test_update_service_account_works_with_team_id():
|
|||
await prepare_key_update_data(data=data, existing_key_row=existing_key)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("flag_value", [True, False])
|
||||
async def test_update_key_enable_prompt_caching_folds_into_metadata(flag_value):
|
||||
"""Top-level enable_prompt_caching on /key/update lands in key metadata, including flipping back to False."""
|
||||
data = UpdateKeyRequest(key="sk-1", enable_prompt_caching=flag_value)
|
||||
existing_key = LiteLLM_VerificationToken(
|
||||
token="hashed", metadata={"enable_prompt_caching": not flag_value}
|
||||
)
|
||||
|
||||
updated = await prepare_key_update_data(data=data, existing_key_row=existing_key)
|
||||
|
||||
assert updated["metadata"]["enable_prompt_caching"] is flag_value
|
||||
assert "enable_prompt_caching" not in {k for k in updated if k != "metadata"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_preserves_service_account_id_when_metadata_replaced():
|
||||
"""
|
||||
|
|
@ -2293,9 +2308,9 @@ async def test_unblock_key_supports_both_sk_and_hashed_tokens(monkeypatch):
|
|||
)
|
||||
|
||||
# Verify that the database update was called with hashed token
|
||||
mock_prisma_client.db.litellm_verificationtoken.update.assert_called_with(
|
||||
where={"token": test_hashed_token}, data={"blocked": False}
|
||||
)
|
||||
sk_token_call = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs
|
||||
assert sk_token_call["where"] == {"token": test_hashed_token}
|
||||
assert sk_token_call["data"]["blocked"] is False
|
||||
|
||||
assert result == mock_key_record
|
||||
|
||||
|
|
@ -2313,9 +2328,9 @@ async def test_unblock_key_supports_both_sk_and_hashed_tokens(monkeypatch):
|
|||
)
|
||||
|
||||
# Verify that the database update was called with the same hashed token
|
||||
mock_prisma_client.db.litellm_verificationtoken.update.assert_called_with(
|
||||
where={"token": test_hashed_token}, data={"blocked": False}
|
||||
)
|
||||
hashed_token_call = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs
|
||||
assert hashed_token_call["where"] == {"token": test_hashed_token}
|
||||
assert hashed_token_call["data"]["blocked"] is False
|
||||
|
||||
assert result == mock_key_record
|
||||
|
||||
|
|
@ -2849,9 +2864,10 @@ async def test_block_key_existing_key_succeeds(monkeypatch):
|
|||
mock_prisma_client.db.litellm_verificationtoken.find_unique.assert_called_once_with(
|
||||
where={"token": test_hashed_token}
|
||||
)
|
||||
mock_prisma_client.db.litellm_verificationtoken.update.assert_called_once_with(
|
||||
where={"token": test_hashed_token}, data={"blocked": True}
|
||||
)
|
||||
mock_prisma_client.db.litellm_verificationtoken.update.assert_called_once()
|
||||
block_call = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs
|
||||
assert block_call["where"] == {"token": test_hashed_token}
|
||||
assert block_call["data"]["blocked"] is True
|
||||
assert result == mock_updated_record
|
||||
|
||||
|
||||
|
|
@ -4717,6 +4733,7 @@ def test_transform_verification_tokens_to_deleted_records():
|
|||
user_role=LitellmUserRoles.PROXY_ADMIN.value,
|
||||
)
|
||||
|
||||
config_stamp = datetime(2026, 8, 10, 12, 30, 45, tzinfo=timezone.utc)
|
||||
key1 = LiteLLM_VerificationToken(
|
||||
token="hashed-token-1",
|
||||
user_id="user-123",
|
||||
|
|
@ -4733,6 +4750,7 @@ def test_transform_verification_tokens_to_deleted_records():
|
|||
model_spend={},
|
||||
soft_budget_cooldown=False,
|
||||
allowed_routes=[],
|
||||
settings_updated_at=config_stamp,
|
||||
)
|
||||
|
||||
key2 = LiteLLM_VerificationToken(
|
||||
|
|
@ -4775,6 +4793,7 @@ def test_transform_verification_tokens_to_deleted_records():
|
|||
assert record1["token"] == "hashed-token-1"
|
||||
assert record1["user_id"] == "user-123"
|
||||
assert record1["team_id"] == "team-456"
|
||||
assert record1["settings_updated_at"] == config_stamp
|
||||
assert isinstance(record1["aliases"], str)
|
||||
assert isinstance(record1["config"], str)
|
||||
assert isinstance(record1["permissions"], str)
|
||||
|
|
@ -15817,3 +15836,91 @@ async def test_regenerate_key_output_token_estimate_lowered_rejected_for_non_adm
|
|||
|
||||
assert exc.value.status_code == 403
|
||||
assert "Only proxy admins can set" in str(exc.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_virtual_key_regeneration_stamps_settings_updated_at():
|
||||
"""Regenerate rewrites the key's config, so it must move settings_updated_at."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from litellm.proxy._types import RegenerateKeyRequest
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_execute_virtual_key_regeneration,
|
||||
)
|
||||
|
||||
mock_prisma_client = _make_regenerate_mock_prisma()
|
||||
|
||||
with _patch_regenerate_side_effects():
|
||||
before = datetime.now(timezone.utc)
|
||||
await _execute_virtual_key_regeneration(
|
||||
prisma_client=mock_prisma_client,
|
||||
key_in_db=_make_regenerate_existing_key(),
|
||||
hashed_api_key="abc123",
|
||||
key="abc123",
|
||||
data=RegenerateKeyRequest(max_budget=42.0),
|
||||
user_api_key_dict=_make_regenerate_user_api_key_dict(),
|
||||
litellm_changed_by=None,
|
||||
user_api_key_cache=MagicMock(),
|
||||
proxy_logging_obj=MagicMock(),
|
||||
)
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
sent = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs["data"]
|
||||
assert sent["max_budget"] == 42.0
|
||||
assert before <= sent["settings_updated_at"] <= after
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_block_key_stamps_settings_updated_at(monkeypatch):
|
||||
"""Blocking a key is a config change, not spend activity."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from litellm.proxy._types import BlockKeyRequest
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import block_key
|
||||
|
||||
mock_prisma_client, _ = _setup_block_unblock_mocks(monkeypatch)
|
||||
|
||||
before = datetime.now(timezone.utc)
|
||||
await block_key(
|
||||
data=BlockKeyRequest(key="sk-test123456789"),
|
||||
http_request=MagicMock(),
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
api_key="sk-admin",
|
||||
user_id="admin_user",
|
||||
),
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
sent = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs["data"]
|
||||
assert sent["blocked"] is True
|
||||
assert before <= sent["settings_updated_at"] <= after
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unblock_key_stamps_settings_updated_at(monkeypatch):
|
||||
"""Unblocking a key is a config change, not spend activity."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from litellm.proxy._types import BlockKeyRequest
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import unblock_key
|
||||
|
||||
mock_prisma_client, _ = _setup_block_unblock_mocks(monkeypatch)
|
||||
|
||||
before = datetime.now(timezone.utc)
|
||||
await unblock_key(
|
||||
data=BlockKeyRequest(key="sk-test123456789"),
|
||||
http_request=MagicMock(),
|
||||
user_api_key_dict=UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
api_key="sk-admin",
|
||||
user_id="admin_user",
|
||||
),
|
||||
litellm_changed_by=None,
|
||||
)
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
sent = mock_prisma_client.db.litellm_verificationtoken.update.call_args.kwargs["data"]
|
||||
assert sent["blocked"] is False
|
||||
assert before <= sent["settings_updated_at"] <= after
|
||||
|
|
|
|||
|
|
@ -688,6 +688,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
|
|||
"mock_response": "free response",
|
||||
"mock_tool_calls": [{"id": "call_1"}],
|
||||
"disable_global_guardrails": True,
|
||||
"enable_prompt_caching": True,
|
||||
"routing_decision": {"cause": "forged", "routed_model": "spoofed"},
|
||||
"metadata": copy.deepcopy(malicious_metadata),
|
||||
"litellm_metadata": copy.deepcopy(malicious_metadata),
|
||||
|
|
@ -705,6 +706,7 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
|
|||
assert "mock_response" not in updated
|
||||
assert "mock_tool_calls" not in updated
|
||||
assert "disable_global_guardrails" not in updated
|
||||
assert "enable_prompt_caching" not in updated
|
||||
assert "routing_decision" not in updated
|
||||
|
||||
stripped_keys = {
|
||||
|
|
@ -741,6 +743,42 @@ async def test_add_litellm_data_to_request_strips_user_control_fields():
|
|||
assert "pillar_response_headers" not in snapshot_body["metadata"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"key_value, expected",
|
||||
[(True, True), (False, False), ("yes", None), (None, None)],
|
||||
)
|
||||
async def test_key_metadata_enable_prompt_caching_promoted_to_request_root(key_value, expected):
|
||||
"""Key metadata enable_prompt_caching is stamped onto the request root (bools only), even when the client spoofs it."""
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {"Content-Type": "application/json"}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
data = {
|
||||
"model": "claude-sonnet-4-5",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"enable_prompt_caching": "spoofed-by-client",
|
||||
}
|
||||
key_metadata = {} if key_value is None else {"enable_prompt_caching": key_value}
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=request_mock,
|
||||
user_api_key_dict=UserAPIKeyAuth(api_key="hashed-key", metadata=key_metadata),
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated.get("enable_prompt_caching") == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"control_field",
|
||||
|
|
@ -6309,3 +6347,239 @@ class TestPromotedTraceControlFields:
|
|||
assert "litellm_metadata" not in updated
|
||||
assert updated["metadata"]["trace_id"] == "trace-1"
|
||||
assert updated["metadata"]["session_id"] == "session-1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_inherited_tags_excludes_caller_tags():
|
||||
"""inherited_tags must carry only what key/team/project policy contributed,
|
||||
never anything the caller's own request (header/body) supplied, even when the
|
||||
caller resubmits the identical value -- it's a snapshot taken before the
|
||||
caller's own tags are merged in, not a set difference against caller_tags.
|
||||
tag_based_routing.py's allow_fail_open relies on this so a caller can't strip
|
||||
an inherited constraint's protection by resubmitting its exact value."""
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {"Content-Type": "application/json"}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
data = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
# Caller resubmits the exact value the key policy also contributes.
|
||||
"tags": ["key-supplied"],
|
||||
}
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="hashed-key",
|
||||
user_id="real-user",
|
||||
metadata={"tags": ["key-supplied"]},
|
||||
team_metadata={"tags": ["team-supplied"]},
|
||||
spend=0.0,
|
||||
max_budget=100.0,
|
||||
model_max_budget={},
|
||||
team_spend=0.0,
|
||||
team_max_budget=200.0,
|
||||
)
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=request_mock,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert set(updated["metadata"]["tags"]) == {"key-supplied", "team-supplied"}
|
||||
assert set(updated["metadata"]["inherited_tags"]) == {"key-supplied", "team-supplied"}
|
||||
assert tuple(updated["metadata"]["caller_tags"]) == ("key-supplied",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_inherited_tags_survives_pre_auth_header_merge():
|
||||
"""Regression: apply_client_tag_policy_pre_auth (run from user_api_key_auth,
|
||||
for _tag_max_budget_check) merges the caller's x-litellm-tags header into the
|
||||
same metadata.tags list this function later reads from -- before this
|
||||
function ever runs. A snapshot-based inherited_tags would misattribute that
|
||||
caller-controlled value as policy-backed; inherited_tags must instead be read
|
||||
directly from key/team/project metadata, immune to whatever the pre-auth pass
|
||||
already merged into "tags"."""
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {"Content-Type": "application/json", "x-litellm-tags": "caller-invented-tag"}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
data: dict = {"model": "gpt-3.5-turbo"}
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="hashed-key",
|
||||
user_id="real-user",
|
||||
metadata={"tags": ["key-supplied"]},
|
||||
team_metadata={},
|
||||
spend=0.0,
|
||||
max_budget=100.0,
|
||||
model_max_budget={},
|
||||
team_spend=0.0,
|
||||
team_max_budget=200.0,
|
||||
)
|
||||
|
||||
# Simulate the real request pipeline: the pre-auth merge runs first, on the
|
||||
# same data dict, before add_litellm_data_to_request is ever called.
|
||||
LiteLLMProxyRequestSetup.apply_client_tag_policy_pre_auth(
|
||||
request=request_mock,
|
||||
request_data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
assert data["metadata"]["tags"] == ["caller-invented-tag"]
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=request_mock,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert set(updated["metadata"]["tags"]) == {"caller-invented-tag", "key-supplied"}
|
||||
assert updated["metadata"]["inherited_tags"] == ("key-supplied",)
|
||||
assert updated["metadata"]["caller_tags"] == ("caller-invented-tag",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_caller_tags_excludes_key_and_team_tags():
|
||||
"""caller_tags must carry only what the caller itself sent (header + body
|
||||
tags), never anything merged in from key/team metadata, even though the
|
||||
merged "tags" field (used for matching) legitimately contains all three."""
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {"Content-Type": "application/json"}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
data = {
|
||||
"model": "gpt-3.5-turbo",
|
||||
"tags": ["caller-supplied"],
|
||||
}
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="hashed-key",
|
||||
user_id="real-user",
|
||||
metadata={"tags": ["key-supplied"]},
|
||||
team_metadata={"tags": ["team-supplied"]},
|
||||
spend=0.0,
|
||||
max_budget=100.0,
|
||||
model_max_budget={},
|
||||
team_spend=0.0,
|
||||
team_max_budget=200.0,
|
||||
)
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=request_mock,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert set(updated["metadata"]["tags"]) == {"caller-supplied", "key-supplied", "team-supplied"}
|
||||
assert tuple(updated["metadata"]["caller_tags"]) == ("caller-supplied",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_caller_tags_includes_header_tags():
|
||||
"""The x-litellm-tags header is as much a caller-controlled input as the
|
||||
body's "tags" field; both must land in caller_tags."""
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {"Content-Type": "application/json", "x-litellm-tags": "header-tag"}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
data = {"model": "gpt-3.5-turbo"}
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="hashed-key",
|
||||
user_id="real-user",
|
||||
metadata={"tags": ["key-supplied"]},
|
||||
team_metadata={},
|
||||
spend=0.0,
|
||||
max_budget=100.0,
|
||||
model_max_budget={},
|
||||
team_spend=0.0,
|
||||
team_max_budget=200.0,
|
||||
)
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=request_mock,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert set(updated["metadata"]["tags"]) == {"header-tag", "key-supplied"}
|
||||
assert tuple(updated["metadata"]["caller_tags"]) == ("header-tag",)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_litellm_data_to_request_caller_tags_empty_when_caller_sends_nothing():
|
||||
"""caller_tags must be present (an empty tuple), not absent, when the caller
|
||||
supplied no tags of their own -- an empty-but-present value tells
|
||||
tag_based_routing.py's allow_fail_open that any required/excluded tag on the
|
||||
request is entirely inherited, not that no origin information is available.
|
||||
"""
|
||||
request_mock = MagicMock(spec=Request)
|
||||
request_mock.url.path = "/v1/chat/completions"
|
||||
request_mock.url = MagicMock()
|
||||
request_mock.url.__str__.return_value = "http://localhost/v1/chat/completions"
|
||||
request_mock.method = "POST"
|
||||
request_mock.query_params = {}
|
||||
request_mock.headers = {"Content-Type": "application/json"}
|
||||
request_mock.client = MagicMock()
|
||||
request_mock.client.host = "127.0.0.1"
|
||||
|
||||
data = {"model": "gpt-3.5-turbo"}
|
||||
|
||||
user_api_key_dict = UserAPIKeyAuth(
|
||||
api_key="hashed-key",
|
||||
user_id="real-user",
|
||||
metadata={"tags": ["key-supplied"]},
|
||||
team_metadata={},
|
||||
spend=0.0,
|
||||
max_budget=100.0,
|
||||
model_max_budget={},
|
||||
team_spend=0.0,
|
||||
team_max_budget=200.0,
|
||||
)
|
||||
|
||||
updated = await add_litellm_data_to_request(
|
||||
data=data,
|
||||
request=request_mock,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_config=MagicMock(),
|
||||
general_settings={},
|
||||
version="test-version",
|
||||
)
|
||||
|
||||
assert updated["metadata"]["tags"] == ["key-supplied"]
|
||||
assert updated["metadata"]["caller_tags"] == ()
|
||||
|
|
|
|||
|
|
@ -1169,3 +1169,25 @@ async def test_prisma_health_check_failure_redacts_database_credentials(caplog):
|
|||
assert emitted
|
||||
assert all("hunter2" not in message for message in emitted)
|
||||
assert any("postgresql://REDACTED@db.internal" in message for message in emitted)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_data_key_branch_stamps_settings_updated_at():
|
||||
"""`updated_at` carries Prisma's @updatedAt and is rewritten by every spend
|
||||
flush, so key config edits need their own audit column."""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.proxy.utils import PrismaClient
|
||||
|
||||
client = MagicMock()
|
||||
client.jsonify_object = MagicMock(side_effect=lambda data: dict(data))
|
||||
client.db.litellm_verificationtoken.update = AsyncMock(return_value=None)
|
||||
|
||||
before = datetime.now(timezone.utc)
|
||||
await PrismaClient.update_data(client, token="sk-test-key", data={"models": ["gpt-4"]})
|
||||
after = datetime.now(timezone.utc)
|
||||
|
||||
sent = client.db.litellm_verificationtoken.update.call_args.kwargs["data"]
|
||||
assert sent["models"] == ["gpt-4"]
|
||||
assert before <= sent["settings_updated_at"] <= after
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ def _make_router_mock(enable_tag_filtering=True, match_any=True):
|
|||
mock = MagicMock()
|
||||
mock.enable_tag_filtering = enable_tag_filtering
|
||||
mock.tag_filtering_match_any = match_any
|
||||
mock.tag_routing_prefix = ""
|
||||
return mock
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -207,6 +207,23 @@ class TestCloseCommentText:
|
|||
assert "end-to-end qa proof" in body.lower()
|
||||
assert "mock" in body.lower()
|
||||
|
||||
def test_issue_recovery_comments_should_name_feature_dead_end_evidence(
|
||||
self, triage_module
|
||||
):
|
||||
# The feature-request pass bar demands end-to-end evidence of the
|
||||
# dead-end, so the close and grace-warning recovery bullets must ask
|
||||
# for it too — otherwise a requester follows those exact instructions
|
||||
# (description + use case only) and fails `reconsider` again with no
|
||||
# hint of what else was needed.
|
||||
verdict = {"verdict": "fail", "missing": [], "explanation": ""}
|
||||
for body in (
|
||||
triage_module.format_issue_close_comment(verdict),
|
||||
triage_module.format_grace_warning_issue_comment(verdict),
|
||||
):
|
||||
normalized = " ".join(body.split())
|
||||
assert "end-to-end evidence of the dead-end" in normalized
|
||||
assert "showing where the flow stops today" in normalized
|
||||
|
||||
def test_all_agent_shin_comments_should_use_bullet_train_emoji(self, triage_module):
|
||||
# The bullet train (🚅) is Agent Shin's symbol, matching the LiteLLM
|
||||
# logo; the previous wave (👋) was generic and didn't match the bot's
|
||||
|
|
@ -289,6 +306,27 @@ class TestCloseCommentText:
|
|||
assert "Expected vs. actual behavior" in body
|
||||
assert "- ✅ End-to-end evidence of the bug" not in body
|
||||
|
||||
def test_issue_close_comment_should_credit_feature_dead_end_evidence(
|
||||
self, triage_module
|
||||
):
|
||||
# A feature requester who pasted their dead-end run but skipped the
|
||||
# motivation must see the evidence credited and only the motivation
|
||||
# listed as a gap — without a dedicated verdict field the praise
|
||||
# block could never acknowledge the work they did do.
|
||||
body = triage_module.format_issue_close_comment(
|
||||
{
|
||||
"verdict": "fail",
|
||||
"kind": "feature",
|
||||
"has_motivation_example": False,
|
||||
"has_dead_end_evidence": True,
|
||||
"missing": ["motivation / use case"],
|
||||
"explanation": "no use case given",
|
||||
}
|
||||
)
|
||||
assert "What you got right" in body
|
||||
assert "- ✅ End-to-end evidence of the dead-end" in body
|
||||
assert "- ✅ Motivation and concrete example" not in body
|
||||
|
||||
def test_close_comments_should_use_softer_park_for_later_framing(
|
||||
self, triage_module
|
||||
):
|
||||
|
|
@ -672,6 +710,29 @@ class TestBuildPrompts:
|
|||
assert "mocked or stubbed" in normalized
|
||||
# Prose-only steps are explicitly insufficient now.
|
||||
assert "steps to reproduce" in normalized
|
||||
# An unedited issue-form scaffold must not read as evidence: the proof
|
||||
# field ships with visible headings, so the judge has to be told that
|
||||
# bare headings with nothing under them count as absent.
|
||||
assert "unfilled template scaffold" in normalized
|
||||
assert "counts as absent, not as evidence" in normalized
|
||||
|
||||
def test_issue_feature_rubric_requires_evidence_of_the_dead_end(
|
||||
self, triage_module
|
||||
):
|
||||
# The feature form asks the requester to walk the ideal flow against a
|
||||
# live proxy and paste output up to the step that dead-ends, so the
|
||||
# judge has to demand that evidence, and must not accept an unedited
|
||||
# scaffold of bare headings as if it were a real attempt.
|
||||
prompt = triage_module.build_issue_prompt(title="t", body="x")
|
||||
normalized = " ".join(prompt.split())
|
||||
assert "END-TO-END EVIDENCE OF THE DEAD-END" in normalized
|
||||
assert "showing the point where the flow stops today" in normalized
|
||||
assert "unfilled template scaffold" in normalized
|
||||
# The evidence has its own verdict field so feature requesters who
|
||||
# provided it get credited in "What you got right", exactly like
|
||||
# `has_repro` credits bug evidence.
|
||||
assert "`has_dead_end_evidence=true` only when this is present" in normalized
|
||||
assert '"has_dead_end_evidence": boolean' in normalized
|
||||
|
||||
def test_should_not_crash_when_pr_body_contains_curly_braces(self, triage_module):
|
||||
"""User-supplied content with `{` / `}` must NOT be re-parsed by
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
{
|
||||
"LIT001": {
|
||||
"limit": 23021
|
||||
"limit": 23003
|
||||
},
|
||||
"LIT002": {
|
||||
"limit": 27148
|
||||
"limit": 27146
|
||||
},
|
||||
"LIT003": {
|
||||
"limit": 269
|
||||
|
|
@ -27,7 +27,7 @@
|
|||
"limit": 0
|
||||
},
|
||||
"LIT010": {
|
||||
"limit": 16733
|
||||
"limit": 16731
|
||||
},
|
||||
"LIT011": {
|
||||
"limit": 5596
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ export interface KeyResponse {
|
|||
created_at: string;
|
||||
created_by?: string;
|
||||
updated_at: string;
|
||||
settings_updated_at?: string | null;
|
||||
last_active: string | null;
|
||||
team_spend: number;
|
||||
team_alias: string;
|
||||
|
|
|
|||
|
|
@ -1191,6 +1191,21 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
>
|
||||
<Switch checkedChildren="Yes" unCheckedChildren="No" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
label={
|
||||
<span>
|
||||
Enable Prompt Caching{" "}
|
||||
<Tooltip title="Automatically add prompt caching breakpoints (cache_control markers) to requests made with this key, cutting input cost on repeated prompts. Applies to Anthropic and Bedrock Claude models; requests that already set their own cache_control markers are left untouched.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="enable_prompt_caching"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="Yes" unCheckedChildren="No" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -410,6 +410,36 @@ describe("KeyEditView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should initialize and submit enable_prompt_caching from key metadata", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const keyDataWithPromptCaching = {
|
||||
...MOCK_KEY_DATA,
|
||||
metadata: { ...MOCK_KEY_DATA.metadata, enable_prompt_caching: true },
|
||||
};
|
||||
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={keyDataWithPromptCaching}
|
||||
onCancel={() => {}}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Enable Prompt Caching")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalledWith(expect.objectContaining({ enable_prompt_caching: true }));
|
||||
});
|
||||
});
|
||||
|
||||
it("should disable models field when management routes are selected", async () => {
|
||||
const keyDataWithManagementRoutes = {
|
||||
...MOCK_KEY_DATA,
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ export function KeyEditView({
|
|||
guardrails: keyData.metadata?.guardrails,
|
||||
disable_global_guardrails: keyData.metadata?.disable_global_guardrails || false,
|
||||
throttle_on_budget_exceeded: keyData.metadata?.throttle_on_budget_exceeded || false,
|
||||
enable_prompt_caching: keyData.metadata?.enable_prompt_caching || false,
|
||||
...estimateFields(keyData.metadata),
|
||||
prompts: keyData.metadata?.prompts,
|
||||
tags: keyData.metadata?.tags,
|
||||
|
|
@ -178,36 +179,8 @@ export function KeyEditView({
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({
|
||||
...keyData,
|
||||
token: keyData.token || keyData.token_id,
|
||||
budget_duration: canonicalBudgetDuration(keyData.budget_duration),
|
||||
metadata: formatMetadataForDisplay(stripTagsFromMetadata(keyData.metadata)),
|
||||
guardrails: keyData.metadata?.guardrails,
|
||||
disable_global_guardrails: keyData.metadata?.disable_global_guardrails || false,
|
||||
prompts: keyData.metadata?.prompts,
|
||||
tags: keyData.metadata?.tags,
|
||||
vector_stores: keyData.object_permission?.vector_stores || [],
|
||||
mcp_servers_and_groups: {
|
||||
servers: keyData.object_permission?.mcp_servers || [],
|
||||
accessGroups: keyData.object_permission?.mcp_access_groups || [],
|
||||
toolsets: keyData.object_permission?.mcp_toolsets || [],
|
||||
},
|
||||
mcp_tool_permissions: keyData.object_permission?.mcp_tool_permissions || {},
|
||||
throttle_on_budget_exceeded: keyData.metadata?.throttle_on_budget_exceeded || false,
|
||||
...estimateFields(keyData.metadata),
|
||||
logging_settings: extractLoggingSettings(keyData.metadata),
|
||||
disabled_callbacks: Array.isArray(keyData.metadata?.litellm_disabled_callbacks)
|
||||
? mapInternalToDisplayNames(keyData.metadata.litellm_disabled_callbacks)
|
||||
: [],
|
||||
access_group_ids: keyData.access_group_ids || [],
|
||||
auto_rotate: keyData.auto_rotate || false,
|
||||
...(keyData.rotation_interval && { rotation_interval: keyData.rotation_interval }),
|
||||
allowed_routes:
|
||||
Array.isArray(keyData.allowed_routes) && keyData.allowed_routes.length > 0
|
||||
? keyData.allowed_routes.join(", ")
|
||||
: "",
|
||||
});
|
||||
form.setFieldsValue(initialValues);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- initialValues is rebuilt from keyData every render; depending on it would re-run each render
|
||||
}, [keyData, form]);
|
||||
|
||||
// Sync auto-rotation state with form values
|
||||
|
|
@ -532,6 +505,21 @@ export function KeyEditView({
|
|||
<Switch checkedChildren="Yes" unCheckedChildren="No" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Enable Prompt Caching{" "}
|
||||
<Tooltip title="Automatically add prompt caching breakpoints (cache_control markers) to requests made with this key, cutting input cost on repeated prompts. Applies to Anthropic and Bedrock Claude models; requests that already set their own cache_control markers are left untouched.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="enable_prompt_caching"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch checkedChildren="Yes" unCheckedChildren="No" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Max Parallel Requests" name="max_parallel_requests">
|
||||
<NumericalInput min={0} />
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -149,6 +149,46 @@ describe("KeyInfoView", () => {
|
|||
await userEvent.click(await screen.findByRole("button", { name: /more key actions/i }));
|
||||
};
|
||||
|
||||
describe("last updated", () => {
|
||||
const renderWithTimestamps = (overrides: Partial<KeyResponse>) => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
return renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={{
|
||||
...MOCK_KEY_DATA,
|
||||
created_at: "2021-06-15T12:00:00Z",
|
||||
updated_at: "2023-06-15T12:00:00Z",
|
||||
...overrides,
|
||||
}}
|
||||
onClose={() => {}}
|
||||
keyId={"test-key-id"}
|
||||
onKeyDataUpdate={() => {}}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
const findLastUpdatedText = async () => {
|
||||
const label = await screen.findByText("Last Updated");
|
||||
return label.closest("div")?.parentElement?.parentElement?.textContent ?? "";
|
||||
};
|
||||
|
||||
it("should show when the key was last configured, not when it last recorded spend", async () => {
|
||||
renderWithTimestamps({ settings_updated_at: "2022-06-15T12:00:00Z" });
|
||||
|
||||
expect(await findLastUpdatedText()).toMatch(/Jun \d+, 2022/);
|
||||
expect(screen.queryByText(/Jun \d+, 2023/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should fall back to creation time for a key that was never reconfigured", async () => {
|
||||
renderWithTimestamps({ settings_updated_at: null });
|
||||
|
||||
expect(await findLastUpdatedText()).toMatch(/Jun \d+, 2021/);
|
||||
expect(screen.queryByText(/Jun \d+, 2023/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render tags", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
|
|
|
|||
|
|
@ -444,6 +444,8 @@ export default function KeyInfoView({
|
|||
);
|
||||
};
|
||||
|
||||
const lastConfiguredAt = currentKeyData.settings_updated_at || currentKeyData.created_at;
|
||||
|
||||
const parentTeam = currentKeyData.team_id ? teamsData?.find((team) => team.team_id === currentKeyData.team_id) : null;
|
||||
|
||||
const budgetDisplay =
|
||||
|
|
@ -468,7 +470,7 @@ export default function KeyInfoView({
|
|||
currentKeyData.created_by ||
|
||||
"",
|
||||
createdAt: currentKeyData.created_at ? formatTimestamp(currentKeyData.created_at) : "",
|
||||
lastUpdated: currentKeyData.updated_at ? formatTimestamp(currentKeyData.updated_at) : "",
|
||||
lastUpdated: lastConfiguredAt ? formatTimestamp(lastConfiguredAt) : "",
|
||||
lastActive: currentKeyData.last_active ? formatTimestamp(currentKeyData.last_active) : "Never",
|
||||
expires: currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never",
|
||||
}}
|
||||
|
|
@ -780,6 +782,13 @@ export default function KeyInfoView({
|
|||
<Text>{currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"}</Text>
|
||||
</div>
|
||||
|
||||
{Boolean(currentKeyData.metadata?.enable_prompt_caching) && (
|
||||
<div>
|
||||
<Text className="font-medium">Prompt Caching</Text>
|
||||
<Text>Enabled (auto-injects cache_control markers on Anthropic and Bedrock Claude requests)</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AutoRotationView
|
||||
autoRotate={currentKeyData.auto_rotate}
|
||||
rotationInterval={currentKeyData.rotation_interval}
|
||||
|
|
|
|||
24
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
24
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -6781,6 +6781,7 @@ export interface paths {
|
|||
* - policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules.
|
||||
* - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
|
||||
* - throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely.
|
||||
* - enable_prompt_caching: Optional[bool] - Auto-inject prompt caching breakpoints (Anthropic cache_control markers) on requests made with this key. Anthropic and Bedrock Claude models only.
|
||||
* - permissions: Optional[dict] - key-specific permissions. Currently just used for turning off pii masking (if connected). Example - {"pii": false}
|
||||
* - model_max_budget: Optional[Dict[str, BudgetConfig]] - Model-specific budgets {"gpt-4": {"budget_limit": 0.0005, "time_period": "30d"}}}. IF null or {} then no model specific budget.
|
||||
* - budget_fallbacks: Optional[Dict[str, List[str]]] - Per-model fallback chain tried in order when that model's own `model_max_budget` is exceeded, e.g. {"gpt-4o": ["gpt-4o-mini"]}.
|
||||
|
|
@ -7240,6 +7241,7 @@ export interface paths {
|
|||
* - policies: Optional[List[str]] - List of policy names to apply to the key. Policies define guardrails, conditions, and inheritance rules.
|
||||
* - disable_global_guardrails: Optional[bool] - Whether to disable global guardrails for the key.
|
||||
* - throttle_on_budget_exceeded: Optional[bool] - When the key exceeds its max_budget, throttle its tpm/rpm to the global budget_exceeded_throttle_percentage instead of blocking the key entirely.
|
||||
* - enable_prompt_caching: Optional[bool] - Auto-inject prompt caching breakpoints (Anthropic cache_control markers) on requests made with this key. Anthropic and Bedrock Claude models only.
|
||||
* - prompts: Optional[List[str]] - List of prompts that the key is allowed to use.
|
||||
* - blocked: Optional[bool] - Whether the key is blocked
|
||||
* - aliases: Optional[dict] - Model aliases for the key - [Docs](https://litellm.vercel.app/docs/proxy/virtual_keys#model-aliases)
|
||||
|
|
@ -24987,6 +24989,8 @@ export interface components {
|
|||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
duration?: string | null;
|
||||
/** Enable Prompt Caching */
|
||||
enable_prompt_caching?: boolean | null;
|
||||
/** Enforced Params */
|
||||
enforced_params?: string[] | null;
|
||||
/** Guardrails */
|
||||
|
|
@ -25147,6 +25151,8 @@ export interface components {
|
|||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
duration?: string | null;
|
||||
/** Enable Prompt Caching */
|
||||
enable_prompt_caching?: boolean | null;
|
||||
/** Enforced Params */
|
||||
enforced_params?: string[] | null;
|
||||
/** Expires */
|
||||
|
|
@ -26280,6 +26286,8 @@ export interface components {
|
|||
} | null;
|
||||
/** Rpm Limit */
|
||||
rpm_limit?: number | null;
|
||||
/** Settings Updated At */
|
||||
settings_updated_at?: string | null;
|
||||
/**
|
||||
* Soft Budget Cooldown
|
||||
* @default false
|
||||
|
|
@ -27719,6 +27727,8 @@ export interface components {
|
|||
} | null;
|
||||
/** Rpm Limit */
|
||||
rpm_limit?: number | null;
|
||||
/** Settings Updated At */
|
||||
settings_updated_at?: string | null;
|
||||
/**
|
||||
* Soft Budget Cooldown
|
||||
* @default false
|
||||
|
|
@ -29616,6 +29626,8 @@ export interface components {
|
|||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
duration?: string | null;
|
||||
/** Enable Prompt Caching */
|
||||
enable_prompt_caching?: boolean | null;
|
||||
/** Enforced Params */
|
||||
enforced_params?: string[] | null;
|
||||
/** Expires */
|
||||
|
|
@ -31461,6 +31473,8 @@ export interface components {
|
|||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
duration?: string | null;
|
||||
/** Enable Prompt Caching */
|
||||
enable_prompt_caching?: boolean | null;
|
||||
/** Enforced Params */
|
||||
enforced_params?: string[] | null;
|
||||
/** Grace Period */
|
||||
|
|
@ -33856,6 +33870,8 @@ export interface components {
|
|||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
duration?: string | null;
|
||||
/** Enable Prompt Caching */
|
||||
enable_prompt_caching?: boolean | null;
|
||||
/** Enforced Params */
|
||||
enforced_params?: string[] | null;
|
||||
/** Guardrails */
|
||||
|
|
@ -34218,6 +34234,8 @@ export interface components {
|
|||
routing_strategy_args?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/** Tag Routing Prefix */
|
||||
tag_routing_prefix?: string | null;
|
||||
/** Timeout */
|
||||
timeout?: number | null;
|
||||
};
|
||||
|
|
@ -34887,6 +34905,8 @@ export interface components {
|
|||
rpm_limit_per_model?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Settings Updated At */
|
||||
settings_updated_at?: string | null;
|
||||
/** Soft Budget */
|
||||
soft_budget?: number | null;
|
||||
/**
|
||||
|
|
@ -35385,6 +35405,8 @@ export interface components {
|
|||
};
|
||||
/** ModelInfo */
|
||||
litellm__types__router__ModelInfo: {
|
||||
/** Allow Fail Open */
|
||||
allow_fail_open?: boolean | null;
|
||||
/** Base Model */
|
||||
base_model?: string | null;
|
||||
/** Blocked */
|
||||
|
|
@ -35404,6 +35426,8 @@ export interface components {
|
|||
* @default false
|
||||
*/
|
||||
db_model: boolean;
|
||||
/** Enable Tag Filtering */
|
||||
enable_tag_filtering?: boolean | null;
|
||||
/** Id */
|
||||
id: string | null;
|
||||
/** Input Cost Per Character */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue