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_azure_postgres_entra_auth
This commit is contained in:
commit
affe2b4529
72 changed files with 3408 additions and 337 deletions
5
.github/workflows/test-linting.yml
vendored
5
.github/workflows/test-linting.yml
vendored
|
|
@ -122,6 +122,11 @@ jobs:
|
|||
uv run --no-sync ruff check .
|
||||
cd ..
|
||||
|
||||
- name: Run Ruff linting (test tree)
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
uv run --no-sync ruff check --config ruff-tests.toml tests
|
||||
|
||||
- name: Check strict-rule budget (delta vs base)
|
||||
if: steps.changes.outputs.decision != 'skip'
|
||||
run: |
|
||||
|
|
|
|||
1
Makefile
1
Makefile
|
|
@ -160,6 +160,7 @@ lint-format-check-changed: $(LINT_DEP_INSTALL) $(LINT_DEP_BASE)
|
|||
# Linting targets
|
||||
lint-ruff: $(LINT_DEP_INSTALL)
|
||||
cd litellm && $(UV_RUN) ruff check . && cd ..
|
||||
$(UV_RUN) ruff check --config ruff-tests.toml tests
|
||||
|
||||
# faster linter for developing ...
|
||||
# inspiration from:
|
||||
|
|
|
|||
|
|
@ -1039,16 +1039,29 @@ class LiteLLMUnknownProvider(BadRequestError):
|
|||
|
||||
|
||||
class GuardrailRaisedException(Exception):
|
||||
"""
|
||||
Raised both when a guardrail judged content and when it could not judge it at all, since a
|
||||
guardrail that fails closed refuses the request the same way a policy violation does.
|
||||
|
||||
``blocked_content`` separates the two. Set it only where the guardrail actually reached a
|
||||
verdict on the payload; leave it alone for an unreachable backend, a timeout, or a response
|
||||
the integration could not parse. Callers that treat a block as something other than a plain
|
||||
failure, such as the batch path dropping one record and submitting the rest, must gate on it,
|
||||
because dropping a record no guardrail ever inspected is a silent loss of enforcement.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
guardrail_name: str | None = None,
|
||||
message: str = "",
|
||||
should_wrap_with_default_message: bool = True,
|
||||
status_code: int = 400,
|
||||
blocked_content: bool = False,
|
||||
):
|
||||
default_message: Final = f"Guardrail raised an exception, Guardrail: {guardrail_name}, Message: {message}"
|
||||
self.guardrail_name = guardrail_name
|
||||
self.status_code = status_code
|
||||
self.blocked_content = blocked_content
|
||||
self.message = default_message if should_wrap_with_default_message else message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,41 @@ _guardrail_self_recorded: Final[contextvars.ContextVar[bool]] = contextvars.Cont
|
|||
)
|
||||
|
||||
|
||||
def is_guardrail_intervention(e: Exception) -> bool:
|
||||
"""
|
||||
Returns True if the exception represents an intentional guardrail block
|
||||
(this was logged previously as an API failure - guardrail_failed_to_respond).
|
||||
|
||||
Guardrails signal intentional blocks by raising:
|
||||
- GuardrailRaisedException (generic guardrail API, tool permission)
|
||||
- BlockedPiiEntityError (Presidio PII detection)
|
||||
- SensitiveDataRouteException (sensitive-data reroute to on-premise model)
|
||||
- HTTPException with a block-signalling status (400, 403, 422)
|
||||
- ModifyResponseException (passthrough mode violation)
|
||||
|
||||
Only the statuses guardrails use in-tree to signal a deliberate rejection
|
||||
count as an intervention: 400 (content policy), 403 (e.g. akto) and 422
|
||||
(e.g. llm_as_a_judge). Other 4xx codes are commonly propagated from an
|
||||
upstream guardrail provider response (401 bad key, 408 timeout, 429 rate
|
||||
limit, or a raw upstream status), which are technical failures, not
|
||||
blocks, so they stay guardrail_failed_to_respond.
|
||||
"""
|
||||
if isinstance(e, ModifyResponseException):
|
||||
return True
|
||||
if isinstance(
|
||||
e,
|
||||
(
|
||||
GuardrailRaisedException,
|
||||
BlockedPiiEntityError,
|
||||
SensitiveDataRouteException,
|
||||
),
|
||||
):
|
||||
return True
|
||||
if HTTPException is not None and isinstance(e, HTTPException) and e.status_code in _GUARDRAIL_BLOCK_STATUS_CODES:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _strict_guardrail_modes_enabled() -> bool:
|
||||
"""Whether guardrail-mode validation raises (default) or logs a warning.
|
||||
|
||||
|
|
@ -429,11 +464,13 @@ class CustomGuardrail(CustomLogger):
|
|||
f"Sensitive data detected by {self.guardrail_name} (routing skipped: request has no session_id)"
|
||||
),
|
||||
guardrail_name=self.guardrail_name,
|
||||
blocked_content=True,
|
||||
)
|
||||
else:
|
||||
raise GuardrailRaisedException(
|
||||
message=f"Sensitive data detected by {self.guardrail_name}",
|
||||
guardrail_name=self.guardrail_name,
|
||||
blocked_content=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -1068,42 +1105,8 @@ class CustomGuardrail(CustomLogger):
|
|||
|
||||
@staticmethod
|
||||
def _is_guardrail_intervention(e: Exception) -> bool:
|
||||
"""
|
||||
Returns True if the exception represents an intentional guardrail block
|
||||
(this was logged previously as an API failure - guardrail_failed_to_respond).
|
||||
|
||||
Guardrails signal intentional blocks by raising:
|
||||
- GuardrailRaisedException (generic guardrail API, tool permission)
|
||||
- BlockedPiiEntityError (Presidio PII detection)
|
||||
- SensitiveDataRouteException (sensitive-data reroute to on-premise model)
|
||||
- HTTPException with a block-signalling status (400, 403, 422)
|
||||
- ModifyResponseException (passthrough mode violation)
|
||||
|
||||
Only the statuses guardrails use in-tree to signal a deliberate rejection
|
||||
count as an intervention: 400 (content policy), 403 (e.g. akto) and 422
|
||||
(e.g. llm_as_a_judge). Other 4xx codes are commonly propagated from an
|
||||
upstream guardrail provider response (401 bad key, 408 timeout, 429 rate
|
||||
limit, or a raw upstream status), which are technical failures, not
|
||||
blocks, so they stay guardrail_failed_to_respond.
|
||||
"""
|
||||
if isinstance(e, ModifyResponseException):
|
||||
return True
|
||||
if isinstance(
|
||||
e,
|
||||
(
|
||||
GuardrailRaisedException,
|
||||
BlockedPiiEntityError,
|
||||
SensitiveDataRouteException,
|
||||
),
|
||||
):
|
||||
return True
|
||||
if (
|
||||
HTTPException is not None
|
||||
and isinstance(e, HTTPException)
|
||||
and e.status_code in _GUARDRAIL_BLOCK_STATUS_CODES
|
||||
):
|
||||
return True
|
||||
return False
|
||||
"""Retained spelling for existing callers; prefer ``is_guardrail_intervention``."""
|
||||
return is_guardrail_intervention(e)
|
||||
|
||||
def _process_error(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@ CLI Token Utilities
|
|||
SDK-level utilities for reading the credential minted by `lite login`.
|
||||
|
||||
Non-secret metadata lives in ~/.litellm/token.json. The secret material (the
|
||||
bearer key, plus a JWT when one is issued) lives in the OS keychain when the
|
||||
machine has one, and in that same 0600 file otherwise. This module hides the
|
||||
split from callers, and migrates a legacy plaintext file into the keychain the
|
||||
first time it reads one.
|
||||
bearer key, the refresh token that renews it, and a JWT when one is issued)
|
||||
lives in the OS keychain when the machine has one, and in that same 0600 file
|
||||
otherwise. This module hides the split from callers, and migrates a plaintext
|
||||
file into the keychain the first time it reads one.
|
||||
|
||||
This module has no dependencies on proxy code and can be safely imported at the SDK level.
|
||||
"""
|
||||
|
|
@ -111,13 +111,20 @@ class CliTokenSecret(BaseModel):
|
|||
secret minted for one server is never handed to another, even if the
|
||||
metadata file is edited underneath us. `timestamp` is the sign-in this
|
||||
secret came from, which is what decides it against a secret still on disk.
|
||||
|
||||
Every field a thief could sign in with belongs here, which is why the
|
||||
refresh token is one of them: it buys a fresh key from the proxy on demand,
|
||||
so leaving it on disk would leave the login readable there. `key` is
|
||||
optional because the file can hold a refresh token without one, and moving
|
||||
that into the keychain must not invent a key to go with it.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
base_url: str
|
||||
key: str
|
||||
key: str | None = None
|
||||
jwt_token: str = ""
|
||||
refresh_token: str | None = None
|
||||
timestamp: float = 0.0
|
||||
|
||||
|
||||
|
|
@ -155,7 +162,7 @@ def save_cli_token(record: CliTokenRecord, *, vault: SecretVault = SYSTEM_KEYRIN
|
|||
staged: Final = _stage_token_file(_without_secret(stamped))
|
||||
if isinstance(staged, CredentialNotSaved):
|
||||
return staged
|
||||
outcome: Final = SecretStored() if stamped.key is None else vault.write(_encode_secret(stamped, stamped.key))
|
||||
outcome: Final = vault.write(_encode_secret(stamped)) if _holds_a_secret(stamped) else SecretStored()
|
||||
if isinstance(outcome, SecretStored):
|
||||
return outcome if _commit_token_file(staged) else CredentialNotRecorded()
|
||||
discard_staged_json(staged)
|
||||
|
|
@ -390,8 +397,9 @@ def _apply_vault_secret(record: CliTokenRecord, blob: str, vault: SecretVault) -
|
|||
secret is usually left on disk by a keychain that would not take it, which makes the file the
|
||||
fresher of the two. It is the older one when a login the keychain did take could not replace
|
||||
the file afterwards, and serving that one would put a superseded credential back in use. Equal
|
||||
stamps are one login sitting in both stores, left by a migration whose scrub was refused, so
|
||||
that branch retries the migration rather than trading one credential for another.
|
||||
stamps are one login sitting in both stores, left by a migration whose scrub was refused or by
|
||||
an upgrade that took the key into the keychain and left the refresh token behind, so that branch
|
||||
rejoins the halves and retries the migration rather than trading one credential for another.
|
||||
|
||||
A scrub the file refuses leaves that superseded secret where it lies, which is the state the
|
||||
login already named when it could not replace the file, and which `lite logout` reports rather
|
||||
|
|
@ -400,21 +408,46 @@ def _apply_vault_secret(record: CliTokenRecord, blob: str, vault: SecretVault) -
|
|||
superseded one back out.
|
||||
"""
|
||||
secret: Final = _decode_secret(blob, record.base_url)
|
||||
if secret is None or (record.key is not None and secret.timestamp <= record.timestamp):
|
||||
return _migrate_file_secret(record, vault)
|
||||
if secret is None or (_holds_a_secret(record) and secret.timestamp <= record.timestamp):
|
||||
return _migrate_file_secret(_rejoined(record, secret), vault, replacing=secret)
|
||||
_scrub_file_secret(record)
|
||||
return record.model_copy(
|
||||
update=MappingProxyType(
|
||||
{
|
||||
"key": secret.key,
|
||||
"jwt_token": secret.jwt_token,
|
||||
"refresh_token": secret.refresh_token,
|
||||
"timestamp": max(secret.timestamp, record.timestamp),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migrate_file_secret(record: CliTokenRecord, vault: SecretVault) -> CliTokenRecord | None:
|
||||
def _rejoined(record: CliTokenRecord, secret: CliTokenSecret | None) -> CliTokenRecord:
|
||||
"""Put one sign-in's secret material back together when each store holds part of it.
|
||||
|
||||
Upgrading from the release that kept only the key in the keychain leaves the refresh token
|
||||
behind in the file, so a single login sits across both stores. Filling in whatever the file is
|
||||
missing before the migration writes its entry is what stops that write from replacing a live key
|
||||
with nothing. Only a matching stamp is one login. Two stamps are two logins, and pairing one's
|
||||
key with the other's refresh token would build a credential neither store ever held.
|
||||
"""
|
||||
if secret is None or secret.timestamp != record.timestamp:
|
||||
return record
|
||||
return record.model_copy(
|
||||
update=MappingProxyType(
|
||||
{
|
||||
"key": record.key if record.key is not None else secret.key,
|
||||
"jwt_token": record.jwt_token or secret.jwt_token,
|
||||
"refresh_token": record.refresh_token if record.refresh_token is not None else secret.refresh_token,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _migrate_file_secret(
|
||||
record: CliTokenRecord, vault: SecretVault, *, replacing: CliTokenSecret | None = None
|
||||
) -> CliTokenRecord | None:
|
||||
"""Move a file-held secret into the vault, but only once the file's copy can be taken away.
|
||||
|
||||
The scrubbed file is staged first so a directory that will not accept it stops the migration
|
||||
|
|
@ -426,23 +459,28 @@ def _migrate_file_secret(record: CliTokenRecord, vault: SecretVault) -> CliToken
|
|||
asked to take the new entry back, so the migration finishes on a directory that would only ever
|
||||
have refused it. Rolling back is the last resort, and a rollback the keychain also refuses
|
||||
leaves the secret in both stores until the next read, which retries this same migration.
|
||||
|
||||
Only an entry this migration put there is taken back. `replacing` names one that was already in
|
||||
the keychain, whose material the new entry carries forward, so erasing it would take away the
|
||||
half the file never had, and a machine that refuses the scrub is exactly the one with nowhere
|
||||
else to keep it. The next read finds the same two halves and tries the move again.
|
||||
"""
|
||||
if record.key is None:
|
||||
if not _holds_a_secret(record):
|
||||
return None
|
||||
staged: Final = _stage_scrubbed_file(record)
|
||||
if staged is None:
|
||||
return record
|
||||
if not isinstance(vault.write(_encode_secret(record, record.key)), SecretStored):
|
||||
if not isinstance(vault.write(_encode_secret(record)), SecretStored):
|
||||
discard_staged_json(staged)
|
||||
return record
|
||||
if not _commit_token_file(staged) and not _overwrite_file_secret(record):
|
||||
if not _commit_token_file(staged) and not _overwrite_file_secret(record) and replacing is None:
|
||||
vault.erase()
|
||||
return record
|
||||
|
||||
|
||||
def _scrub_file_secret(record: CliTokenRecord) -> bool:
|
||||
"""Leave no secret material in the token file once the vault holds it"""
|
||||
if record.key is None and not record.jwt_token:
|
||||
if not _holds_a_secret(record):
|
||||
return True
|
||||
staged: Final = _stage_scrubbed_file(record)
|
||||
if staged is not None and _commit_token_file(staged):
|
||||
|
|
@ -487,13 +525,22 @@ def _commit_token_file(staged: str) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
def _holds_a_secret(record: CliTokenRecord) -> bool:
|
||||
"""Whether the record carries anything that would sign someone in as this user"""
|
||||
return record.key is not None or bool(record.jwt_token) or record.refresh_token is not None
|
||||
|
||||
|
||||
def _without_secret(record: CliTokenRecord) -> CliTokenRecord:
|
||||
return record.model_copy(update=MappingProxyType({"key": None, "jwt_token": ""}))
|
||||
return record.model_copy(update=MappingProxyType({"key": None, "jwt_token": "", "refresh_token": None}))
|
||||
|
||||
|
||||
def _encode_secret(record: CliTokenRecord, key: str) -> str:
|
||||
def _encode_secret(record: CliTokenRecord) -> str:
|
||||
return CliTokenSecret(
|
||||
base_url=record.base_url, key=key, jwt_token=record.jwt_token, timestamp=record.timestamp
|
||||
base_url=record.base_url,
|
||||
key=record.key,
|
||||
jwt_token=record.jwt_token,
|
||||
refresh_token=record.refresh_token,
|
||||
timestamp=record.timestamp,
|
||||
).model_dump_json()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -832,6 +832,10 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# Team guardrail submissions - endpoint scopes results to caller's teams (non-admin)
|
||||
"/guardrails/submissions",
|
||||
"/guardrails/submissions/{guardrail_id}",
|
||||
# Auto-router dry runs - both gate like the /model/new write they rehearse:
|
||||
# proxy admin, or team admin naming their own team via team_id
|
||||
"/auto_router/test_routing",
|
||||
"/auto_router/validate_complexity_router_config",
|
||||
] # routes that manage their own allowed/disallowed logic
|
||||
|
||||
## Org Admin Routes ##
|
||||
|
|
|
|||
|
|
@ -331,7 +331,7 @@ sequenceDiagram
|
|||
|
||||
CLI->>Proxy: Poll /sso/cli/poll/login_id with poll_secret header
|
||||
Proxy->>CLI: Return {"status": "ready", "key": "jwt"}
|
||||
CLI->>CLI: Save key to the OS keychain (metadata to ~/.litellm/token.json)
|
||||
CLI->>CLI: Save the secret to the OS keychain (metadata to ~/.litellm/token.json)
|
||||
```
|
||||
|
||||
### Authentication Commands
|
||||
|
|
@ -365,7 +365,7 @@ The CLI provides these authentication commands:
|
|||
|
||||
### Token Storage
|
||||
|
||||
The key itself goes into the OS keychain (macOS Keychain, Windows Credential Manager, or the Linux Secret Service) under service `litellm-cli`, account `credential`. Only the non-secret session metadata is written to `~/.litellm/token.json`, in a `0700` directory with `0600` file permissions:
|
||||
The key itself, together with the refresh token that renews a `--pkce` credential, goes into the OS keychain (macOS Keychain, Windows Credential Manager, or the Linux Secret Service) under service `litellm-cli`, account `credential`. Only the non-secret session metadata is written to `~/.litellm/token.json`, in a `0700` directory with `0600` file permissions:
|
||||
|
||||
```json
|
||||
{
|
||||
|
|
@ -378,7 +378,7 @@ The key itself goes into the OS keychain (macOS Keychain, Windows Credential Man
|
|||
}
|
||||
```
|
||||
|
||||
Keychain storage needs the `keyring` package, which ships with `pip install 'litellm[cli]'`. Headless boxes and CI runners usually have no keychain either. In all of those cases the key stays in the same `0600` file alongside the metadata, exactly as it did before, and `lite login` names which one applies: the package is missing, the machine has no keychain, or you set `LITELLM_CLI_DISABLE_KEYRING=1` to force the file even where a keychain exists. A `token.json` written by an older `lite` keeps working and is moved into the keychain, and scrubbed from the file, the first time a keychain-capable `lite` reads it.
|
||||
Keychain storage needs the `keyring` package, which ships with `pip install 'litellm[cli]'`. Headless boxes and CI runners usually have no keychain either. In all of those cases the key and the refresh token stay in the same `0600` file alongside the metadata, exactly as they did before, and `lite login` names which one applies: the package is missing, the machine has no keychain, or you set `LITELLM_CLI_DISABLE_KEYRING=1` to force the file even where a keychain exists. A `token.json` written by an older `lite` keeps working and is moved into the keychain, and scrubbed from the file, the first time a keychain-capable `lite` reads it. That includes a refresh token left behind by the release that moved only the key.
|
||||
|
||||
`lite logout` clears both stores. If the keychain is locked at that moment it says so, and re-running it once the keychain is unlocked finishes the job.
|
||||
|
||||
|
|
|
|||
|
|
@ -356,6 +356,7 @@ class DeepKeepGuardrail(CustomGuardrail):
|
|||
guardrail_name=GUARDRAIL_NAME,
|
||||
message=error_message,
|
||||
should_wrap_with_default_message=False,
|
||||
blocked_content=True,
|
||||
)
|
||||
|
||||
return self._build_return_inputs(
|
||||
|
|
|
|||
|
|
@ -464,6 +464,7 @@ class GenericGuardrailAPI(CustomGuardrail):
|
|||
guardrail_name=GUARDRAIL_NAME,
|
||||
message=error_message,
|
||||
should_wrap_with_default_message=False,
|
||||
blocked_content=True,
|
||||
)
|
||||
|
||||
return self._build_guardrail_return_inputs(
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ class OvalixGuardrailBlockedException(GuardrailRaisedException):
|
|||
guardrail_name=guardrail_name,
|
||||
message=message,
|
||||
should_wrap_with_default_message=should_wrap_with_default_message,
|
||||
blocked_content=True,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@ class PromptGuardGuardrail(CustomGuardrail):
|
|||
raise GuardrailRaisedException(
|
||||
guardrail_name=self.guardrail_name,
|
||||
message=(f"Blocked by PromptGuard: {threat_type} (confidence={confidence}, event_id={event_id})"),
|
||||
blocked_content=True,
|
||||
)
|
||||
|
||||
if decision == "redact":
|
||||
|
|
|
|||
|
|
@ -211,6 +211,7 @@ class SingulrGuardrail(CustomGuardrail):
|
|||
raise GuardrailRaisedException(
|
||||
guardrail_name=self.guardrail_name,
|
||||
message=f"Blocked by Singulr: {result.blocking_due_to or 'unknown'}",
|
||||
blocked_content=True,
|
||||
)
|
||||
|
||||
return inputs
|
||||
|
|
|
|||
|
|
@ -536,12 +536,14 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
request_data: dict,
|
||||
input_type: Literal["request", "response"],
|
||||
message: str,
|
||||
blocked_content: bool = False,
|
||||
) -> NoReturn:
|
||||
if input_type == "request":
|
||||
raise GuardrailRaisedException(
|
||||
guardrail_name=self.guardrail_name or GUARDRAIL_NAME,
|
||||
message=message,
|
||||
should_wrap_with_default_message=False,
|
||||
blocked_content=blocked_content,
|
||||
)
|
||||
raise ModifyResponseException(
|
||||
message=message,
|
||||
|
|
@ -623,6 +625,7 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
request_data=request_data,
|
||||
input_type=input_type,
|
||||
message=parsed.blocked_reason or DEFAULT_BLOCK_MESSAGE,
|
||||
blocked_content=True,
|
||||
)
|
||||
if parsed.action == "GUARDRAIL_INTERVENED":
|
||||
is_streamed_response: Final = input_type == "response" and _is_streamed_request(request_data)
|
||||
|
|
@ -631,6 +634,7 @@ class StraikerGuardrail(CustomGuardrail):
|
|||
request_data=request_data,
|
||||
input_type=input_type,
|
||||
message=parsed.blocked_reason or DEFAULT_BLOCK_MESSAGE,
|
||||
blocked_content=True,
|
||||
)
|
||||
return self._intervened_inputs(inputs, parsed)
|
||||
return inputs
|
||||
|
|
|
|||
|
|
@ -527,7 +527,9 @@ class ToolPermissionGuardrail(CustomGuardrail):
|
|||
if not is_allowed and message is not None:
|
||||
verbose_proxy_logger.warning("Tool Permission Guardrail: %s", message)
|
||||
if self.on_disallowed_action == "block":
|
||||
raise GuardrailRaisedException(guardrail_name=self.guardrail_name, message=message)
|
||||
raise GuardrailRaisedException(
|
||||
guardrail_name=self.guardrail_name, message=message, blocked_content=True
|
||||
)
|
||||
|
||||
return tuple(
|
||||
(
|
||||
|
|
|
|||
|
|
@ -205,6 +205,7 @@ class VigilGuardGuardrail(CustomGuardrail):
|
|||
guardrail_name=self.guardrail_name,
|
||||
message=self._build_block_reason(analysis),
|
||||
should_wrap_with_default_message=False,
|
||||
blocked_content=True,
|
||||
)
|
||||
|
||||
if decision == "SANITIZED":
|
||||
|
|
@ -245,6 +246,7 @@ class VigilGuardGuardrail(CustomGuardrail):
|
|||
guardrail_name=self.guardrail_name,
|
||||
message=self._build_block_reason(analysis),
|
||||
should_wrap_with_default_message=False,
|
||||
blocked_content=True,
|
||||
)
|
||||
|
||||
if decision == "SANITIZED":
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
AUTO ROUTER MANAGEMENT ENDPOINTS
|
||||
|
||||
POST /auto_router/test_routing - Route one prompt through an unsaved complexity-router config
|
||||
POST /auto_router/validate_complexity_router_config - Dry-run the complexity-router write gate without saving
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
|
|
@ -43,6 +44,8 @@ from litellm.types.management_endpoints.auto_router_endpoints import (
|
|||
AutoRouterCacheStats,
|
||||
AutoRouterRoutingTestRequest,
|
||||
AutoRouterRoutingTestResponse,
|
||||
ComplexityRouterConfigValidationRequest,
|
||||
ComplexityRouterConfigValidationResponse,
|
||||
RequestComplexityRouterConfig,
|
||||
ShadowEvalDirection,
|
||||
ShadowEvalJobKeyResponse,
|
||||
|
|
@ -130,12 +133,13 @@ async def _query_raw(prisma_client: "PrismaClient", query: str, *args: object) -
|
|||
return await prisma_client.db.query_raw(query, *args)
|
||||
|
||||
|
||||
async def _authorize_routing_test(user_api_key_dict: UserAPIKeyAuth, team_id: str | None) -> None:
|
||||
async def _authorize_router_dry_run(user_api_key_dict: UserAPIKeyAuth, team_id: str | None) -> None:
|
||||
"""Allow exactly the callers who could create this router.
|
||||
|
||||
Routing a prompt can spend money (an `llm` classifier config calls its classifier, a
|
||||
semantic config embeds the prompt), so this is gated like a write rather than a read:
|
||||
a proxy admin, or a team admin naming their own team, matching /model/new.
|
||||
Both dry runs are gated like the write they rehearse rather than as reads: a proxy
|
||||
admin, or a team admin naming their own team, matching /model/new. Routing a test
|
||||
prompt can also spend money (an `llm` classifier config calls its classifier, a
|
||||
semantic config embeds the prompt), so a read-level gate would be too loose anyway.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.model_management_endpoints import (
|
||||
ModelManagementAuthChecks,
|
||||
|
|
@ -149,7 +153,7 @@ async def _authorize_routing_test(user_api_key_dict: UserAPIKeyAuth, team_id: st
|
|||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={ # mutable-ok: HTTPException detail must be a plain mapping to keep this route's {"error": ...} response shape
|
||||
"error": f"User does not have permission to test an auto router. Your role={user_api_key_dict.user_role}. Test as a PROXY_ADMIN, or as a team admin by specifying a team_id."
|
||||
"error": f"User does not have permission to dry-run an auto router. Your role={user_api_key_dict.user_role}. Call as a PROXY_ADMIN, or as a team admin by specifying a team_id."
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -238,6 +242,35 @@ async def _authorize_models_this_test_can_call(
|
|||
) from e
|
||||
|
||||
|
||||
@router.post(
|
||||
"/auto_router/validate_complexity_router_config",
|
||||
tags=["model management"], # mutable-ok: fastapi's decorator signature types tags as a list
|
||||
dependencies=[Depends(user_api_key_auth)], # mutable-ok: fastapi's decorator signature types dependencies as a list
|
||||
response_model=ComplexityRouterConfigValidationResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def validate_complexity_router_config(
|
||||
data: ComplexityRouterConfigValidationRequest,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
) -> ComplexityRouterConfigValidationResponse:
|
||||
"""
|
||||
Validate a complexity-router config without saving it.
|
||||
|
||||
Runs the same check every write path runs (the router's own pydantic model), so a form can
|
||||
show the backend's exact verdict while the operator is still editing rather than after a
|
||||
rejected save. Gated exactly like the save it rehearses: a proxy admin, or a team admin
|
||||
naming their own team. Nothing is created, routed, or billed.
|
||||
"""
|
||||
await _authorize_router_dry_run(user_api_key_dict=user_api_key_dict, team_id=data.team_id)
|
||||
|
||||
from litellm.router_utils.auto_router_model_naming import (
|
||||
validate_complexity_router_config_write,
|
||||
)
|
||||
|
||||
error: Final = validate_complexity_router_config_write(data.complexity_router_config)
|
||||
return ComplexityRouterConfigValidationResponse(valid=error is None, error=error)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/auto_router/test_routing",
|
||||
tags=["model management"], # mutable-ok: fastapi's decorator signature types tags as a list
|
||||
|
|
@ -270,9 +303,17 @@ async def preview_auto_router_routing(
|
|||
}
|
||||
```
|
||||
"""
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings,
|
||||
llm_router,
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
user_model,
|
||||
)
|
||||
from litellm.proxy.utils import get_available_models_for_user
|
||||
|
||||
await _authorize_routing_test(user_api_key_dict=user_api_key_dict, team_id=data.team_id)
|
||||
await _authorize_router_dry_run(user_api_key_dict=user_api_key_dict, team_id=data.team_id)
|
||||
|
||||
if llm_router is None:
|
||||
raise HTTPException(
|
||||
|
|
@ -327,9 +368,19 @@ async def preview_auto_router_routing(
|
|||
},
|
||||
)
|
||||
|
||||
available_models: Final = await get_available_models_for_user(
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
llm_router=llm_router,
|
||||
general_settings=general_settings,
|
||||
user_model=user_model,
|
||||
prisma_client=prisma_client,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
team_id=data.team_id,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
return AutoRouterRoutingTestResponse(
|
||||
routed_model=hook_response.model,
|
||||
routed_model_configured=hook_response.model in frozenset(llm_router.get_model_names()),
|
||||
routed_model_configured=hook_response.model in frozenset(available_models),
|
||||
routing_decision=hook_response.routing_decision,
|
||||
)
|
||||
|
||||
|
|
|
|||
548
litellm/proxy/openai_files_endpoints/batch_guardrails.py
Normal file
548
litellm/proxy/openai_files_endpoints/batch_guardrails.py
Normal file
|
|
@ -0,0 +1,548 @@
|
|||
"""
|
||||
Run the configured pre-call guardrails over every record of a batch input file.
|
||||
|
||||
Runs after ``batch_file_validation.check_batch_file_upload``, so every line here is already known
|
||||
to parse as a JSON object carrying ``custom_id``, ``method``, ``url`` and ``body``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import tempfile
|
||||
from collections.abc import Iterator, Mapping
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, BinaryIO, Final, NoReturn, TypeAlias
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from fastapi import HTTPException
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from litellm.exceptions import GuardrailRaisedException
|
||||
from litellm.integrations.custom_guardrail import is_guardrail_intervention
|
||||
from litellm.litellm_core_utils.api_route_to_call_types import get_call_types_for_route
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.llms.openai import BatchGuardrailRecord, BatchGuardrailReport
|
||||
from litellm.types.utils import CallTypes, CallTypesLiteral
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
EMPTY_MAPPING: Final[Mapping[str, object]] = MappingProxyType({})
|
||||
|
||||
_SCAN_WINDOW: Final = 32
|
||||
|
||||
# Past this the rewrite rolls to disk, keeping the router's per-deployment deepcopy of the handle
|
||||
# as cheap as it is for the spooled upload this replaces.
|
||||
_REWRITE_SPOOL_BYTES: Final = 1024 * 1024
|
||||
|
||||
# custom_id is caller-supplied and reaches a log line, so it is stripped of control characters
|
||||
# and capped rather than rendered as given.
|
||||
_CONTROL_CHARACTERS: Final = re.compile(r"[\x00-\x1f\x7f]")
|
||||
_CUSTOM_ID_LOG_LIMIT: Final = 128
|
||||
_SUMMARY_LIMIT: Final = 50
|
||||
|
||||
_SCAN_METADATA_KEY: Final = "litellm_metadata"
|
||||
_SCAN_METADATA_BAGS: Final = (_SCAN_METADATA_KEY, "metadata")
|
||||
|
||||
# Set by pre_call_hook when a guardrail rerouted the request to a different model.
|
||||
_ROUTE_APPLIED_KEY: Final = "sensitive_data_routing_applied"
|
||||
|
||||
# Dropped before dispatch and restored afterwards rather than diffed. Guardrail dispatch writes
|
||||
# its bookkeeping into `metadata`, and a record's own metadata is not scanned content on the
|
||||
# online path either. `guardrails` is dropped because guardrail selection reads it ahead of the
|
||||
# proxy-injected list, so leaving it would let a record's own body opt out of the chain its key
|
||||
# and team selected; online that key can only add to the list, never replace it.
|
||||
_INJECTED_KEYS: Final = frozenset({_SCAN_METADATA_KEY, "metadata", "guardrails"})
|
||||
|
||||
# Only what guardrail dispatch reads. The parent OTel span is deliberately left out: parenting one
|
||||
# guardrail span per record would put tens of thousands of spans on a single upload's trace.
|
||||
_SCAN_METADATA_KEYS: Final = frozenset(
|
||||
{
|
||||
"guardrails",
|
||||
"_guardrail_pipelines",
|
||||
"_pipeline_managed_guardrails",
|
||||
"user_api_key_metadata",
|
||||
"user_api_key_team_metadata",
|
||||
"tags",
|
||||
"headers",
|
||||
}
|
||||
)
|
||||
|
||||
_SCANNABLE_CALL_TYPES: Final = frozenset(
|
||||
{
|
||||
CallTypes.acompletion,
|
||||
CallTypes.atext_completion,
|
||||
CallTypes.aembedding,
|
||||
CallTypes.aresponses,
|
||||
CallTypes.anthropic_messages,
|
||||
}
|
||||
)
|
||||
|
||||
# Mirrors the record classifier in litellm/llms/bedrock/files/transformation.py, so a record
|
||||
# litellm already accepts without a url keeps working.
|
||||
_BODY_SHAPE_CALL_TYPES: Final = (
|
||||
("messages", CallTypes.acompletion),
|
||||
("prompt", CallTypes.atext_completion),
|
||||
("input", CallTypes.aembedding),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnparseableRecord:
|
||||
line_number: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnscannableRecord:
|
||||
line_number: int
|
||||
custom_id: str | None
|
||||
url: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class UnroutableRecord:
|
||||
line_number: int
|
||||
custom_id: str | None
|
||||
guardrail: str | None
|
||||
|
||||
|
||||
BatchScanFailure: TypeAlias = UnparseableRecord | UnscannableRecord | UnroutableRecord
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Redaction:
|
||||
"""A rewritten record on its way to the scan spool, held only for the window it was scanned in."""
|
||||
|
||||
line_number: int
|
||||
custom_id: str | None
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordRedacted:
|
||||
line_number: int
|
||||
custom_id: str | None
|
||||
offset: int
|
||||
length: int
|
||||
"""Where the re-serialized record sits in the scan spool, so a large file's rewrites stay off the heap."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RecordDropped:
|
||||
line_number: int
|
||||
custom_id: str | None
|
||||
guardrail: str | None = None
|
||||
|
||||
|
||||
_RecordChange: TypeAlias = RecordRedacted | RecordDropped
|
||||
_ScanOutcome: TypeAlias = BatchScanFailure | _Redaction | RecordDropped
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BatchScanResult:
|
||||
"""What the scan decided, per record. Empty changes means the upload proceeds untouched."""
|
||||
|
||||
changes: tuple[_RecordChange, ...]
|
||||
scanned_records: int
|
||||
redactions: BinaryIO
|
||||
"""Spool holding every rewritten record, keyed by the offsets on each ``RecordRedacted``."""
|
||||
|
||||
@property
|
||||
def submitted_records(self) -> int:
|
||||
return self.scanned_records - sum(1 for change in self.changes if isinstance(change, RecordDropped))
|
||||
|
||||
def summary(self) -> str:
|
||||
"""Compact per-record outcome for the server-side log line, capped so one upload cannot flood it."""
|
||||
shown: Final = ", ".join(
|
||||
f"line {change.line_number}{_describe(change.custom_id)} "
|
||||
f"{'redacted' if isinstance(change, RecordRedacted) else 'dropped'}"
|
||||
for change in self.changes[:_SUMMARY_LIMIT]
|
||||
)
|
||||
remaining: Final = len(self.changes) - _SUMMARY_LIMIT
|
||||
return shown if remaining <= 0 else f"{shown}, and {remaining} more"
|
||||
|
||||
def report(self) -> BatchGuardrailReport:
|
||||
return BatchGuardrailReport(
|
||||
submitted_records=self.submitted_records,
|
||||
modified_records=tuple(
|
||||
BatchGuardrailRecord(
|
||||
line=change.line_number,
|
||||
custom_id=change.custom_id,
|
||||
action="redacted" if isinstance(change, RecordRedacted) else "dropped",
|
||||
guardrail=change.guardrail if isinstance(change, RecordDropped) else None,
|
||||
)
|
||||
for change in self.changes
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ParsedRecord:
|
||||
line_number: int
|
||||
payload: Mapping[str, object]
|
||||
|
||||
|
||||
def _rejected(message: str) -> HTTPException:
|
||||
return HTTPException(status_code=400, detail={"error": message}) # mutable-ok: FastAPI detail shape
|
||||
|
||||
|
||||
def raise_public(failure: BatchScanFailure) -> NoReturn:
|
||||
"""Map a scan failure onto the 400 contract the files endpoint already returns."""
|
||||
match failure:
|
||||
case UnparseableRecord(line_number=line_number):
|
||||
raise _rejected(
|
||||
f"The 'body' of batch input line {line_number} is not an object, so guardrails cannot be applied to it"
|
||||
)
|
||||
case UnscannableRecord(line_number=line_number, custom_id=custom_id, url=url):
|
||||
raise _rejected(
|
||||
f"Batch input line {line_number}{_describe(custom_id)} targets {url or 'no url'} "
|
||||
"and its body has no messages, prompt or input, so guardrails cannot read it. "
|
||||
"Give the record a chat, completion, embedding, responses or messages body"
|
||||
)
|
||||
case UnroutableRecord(line_number=line_number, custom_id=custom_id, guardrail=guardrail):
|
||||
raise _rejected(
|
||||
f"Batch input line {line_number}{_describe(custom_id)} was routed to a different model by "
|
||||
f"{guardrail or 'a guardrail'}, and every record of a batch file goes to one provider, so "
|
||||
"the file cannot be submitted. Send that record outside the batch"
|
||||
)
|
||||
case _:
|
||||
assert_never(failure)
|
||||
|
||||
|
||||
def raise_nothing_to_submit() -> NoReturn:
|
||||
"""Every record was blocked, so there is no batch left to create."""
|
||||
raise _rejected(
|
||||
"Every record in the batch input file was blocked by a guardrail, so there is nothing left to submit"
|
||||
)
|
||||
|
||||
|
||||
def _is_content_block(exc: BaseException) -> bool:
|
||||
"""
|
||||
Whether the guardrail judged the record, as opposed to failing to judge it.
|
||||
|
||||
Stricter than ``is_guardrail_intervention``, which answers a different question and counts
|
||||
every ``GuardrailRaisedException`` as a block. Several integrations raise that same exception
|
||||
for an unreachable backend or an unparseable response, and only when the operator configured
|
||||
the guardrail to fail closed, so treating it as a block would turn "refuse this request" into
|
||||
"drop this record and submit the rest", which is the silent loss of enforcement this whole
|
||||
path exists to prevent. A guardrail that does not say it blocked content aborts the upload.
|
||||
|
||||
Guardrails that report a technical failure as an ``HTTPException`` carrying a block status
|
||||
are caught by ``__cause__``: raising ``from`` the underlying error is a deliberate statement
|
||||
that something else caused this, which a verdict on content never is. Implicit context is
|
||||
left alone, since a block raised inside an unrelated ``except`` would read as a failure.
|
||||
"""
|
||||
if isinstance(exc, GuardrailRaisedException):
|
||||
return exc.blocked_content
|
||||
if exc.__cause__ is not None:
|
||||
return False
|
||||
return is_guardrail_intervention(exc)
|
||||
|
||||
|
||||
def _naming_guardrail(exc: BaseException) -> str | None:
|
||||
"""The guardrail that raised, from whichever place it recorded its own name."""
|
||||
named: Final = getattr(exc, "guardrail_name", None)
|
||||
if isinstance(named, str):
|
||||
return named
|
||||
detail: Final = getattr(exc, "detail", None)
|
||||
enriched: Final = detail.get("guardrail_name") if isinstance(detail, dict) else None
|
||||
return enriched if isinstance(enriched, str) else None
|
||||
|
||||
|
||||
def _describe(custom_id: str | None) -> str:
|
||||
if not custom_id:
|
||||
return ""
|
||||
safe: Final = _CONTROL_CHARACTERS.sub(" ", custom_id)[:_CUSTOM_ID_LOG_LIMIT]
|
||||
return f" (custom_id {safe})"
|
||||
|
||||
|
||||
def _iter_lines(source: BinaryIO) -> Iterator[tuple[int, str]]:
|
||||
"""Yield every non-blank line with its 1-based number, so both passes number records alike."""
|
||||
for line_number, raw_line in enumerate(source, start=1):
|
||||
text = raw_line.decode("utf-8")
|
||||
if text.strip():
|
||||
yield line_number, text
|
||||
|
||||
|
||||
def _iter_records(source: BinaryIO) -> Iterator[_ParsedRecord]:
|
||||
"""Yield one record per line, relying on the upload validation that already ran."""
|
||||
for line_number, text in _iter_lines(source):
|
||||
yield _ParsedRecord(line_number=line_number, payload=json.loads(text))
|
||||
|
||||
|
||||
def _call_type_from_url(url: str) -> CallTypesLiteral | None:
|
||||
"""
|
||||
Resolve the route a record names, tolerating how callers actually write it.
|
||||
|
||||
An absolute url has to reduce to its path or nothing matches, and a record naming
|
||||
``/v1/responses`` in full would fall through to its body, where ``input`` reads as an
|
||||
embedding and the record gets scanned as the wrong call type rather than the right one.
|
||||
"""
|
||||
path: Final = urlsplit(url).path.split("?")[0].rstrip("/")
|
||||
call_types: Final = get_call_types_for_route(path)
|
||||
if call_types is None:
|
||||
return None
|
||||
scannable: Final = next((c for c in call_types if c in _SCANNABLE_CALL_TYPES), None)
|
||||
return None if scannable is None else scannable.value
|
||||
|
||||
|
||||
def _call_type_from_body(body: Mapping[str, object]) -> CallTypesLiteral | None:
|
||||
shape: Final = next((call_type for field, call_type in _BODY_SHAPE_CALL_TYPES if field in body), None)
|
||||
return None if shape is None else shape.value
|
||||
|
||||
|
||||
def _scannable_call_type(url: object, body: Mapping[str, object]) -> CallTypesLiteral | None:
|
||||
"""
|
||||
Resolve how to scan a record: its url when we recognize one, otherwise its body shape.
|
||||
|
||||
An unrecognized url falls through to the body rather than rejecting, because a record we can
|
||||
still read is a record we can still scan, and the provider transformers treat an unknown url
|
||||
as chat rather than as an error.
|
||||
"""
|
||||
from_url: Final = _call_type_from_url(url) if isinstance(url, str) and url else None
|
||||
return from_url if from_url is not None else _call_type_from_body(body)
|
||||
|
||||
|
||||
def _custom_id_of(payload: Mapping[str, object]) -> str | None:
|
||||
custom_id: Final = payload.get("custom_id")
|
||||
return custom_id if isinstance(custom_id, str) else None
|
||||
|
||||
|
||||
def _fingerprint(body: Mapping[str, object], keys: frozenset[str]) -> str:
|
||||
"""
|
||||
Order-insensitive projection, so a guardrail re-serializing a dict does not read as a change.
|
||||
|
||||
An absent key projects to ``null`` while a key holding ``None`` projects to the string
|
||||
``"null"``, so adding or dropping a null-valued key still reads as a change.
|
||||
"""
|
||||
return json.dumps(
|
||||
tuple(
|
||||
(key, json.dumps(body[key], sort_keys=True, default=str) if key in body else None) for key in sorted(keys)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_scan_metadata(request_metadata: Mapping[str, object]) -> Mapping[str, object]:
|
||||
"""
|
||||
Narrow the request metadata to the keys guardrail dispatch reads.
|
||||
|
||||
Passing the whole thing through would carry values that cannot be copied, such as the parent
|
||||
OTel span, and would hand every record proxy state it has no business seeing.
|
||||
"""
|
||||
return MappingProxyType(
|
||||
{key: value for key, value in request_metadata.items() if key in _SCAN_METADATA_KEYS}
|
||||
) # mutable-ok: MappingProxyType freezes the comprehension
|
||||
|
||||
|
||||
async def _scan_record(
|
||||
record: _ParsedRecord,
|
||||
scan_metadata: Mapping[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> _ScanOutcome | None:
|
||||
body: Final = record.payload.get("body")
|
||||
if not isinstance(body, dict):
|
||||
return UnparseableRecord(line_number=record.line_number)
|
||||
|
||||
custom_id: Final = _custom_id_of(record.payload)
|
||||
url: Final = record.payload.get("url")
|
||||
call_type: Final = _scannable_call_type(url, body)
|
||||
if call_type is None:
|
||||
return UnscannableRecord(
|
||||
line_number=record.line_number,
|
||||
custom_id=custom_id,
|
||||
url=url if isinstance(url, str) else None,
|
||||
)
|
||||
|
||||
scan_input: Final[dict[str, object]] = copy.deepcopy(body) # mutable-ok: pre_call_hook mutates the dict it is given
|
||||
own_injected: Final = MappingProxyType({key: body[key] for key in _INJECTED_KEYS if key in body})
|
||||
for injected in _INJECTED_KEYS:
|
||||
scan_input.pop(injected, None)
|
||||
# Both bags, because guardrails read whichever one their own route populates and a record
|
||||
# scanned as chat reaches ones that only ever look at `metadata`; both are injected keys, so
|
||||
# neither survives into the record that ships. Deep, and per bag per record, because `headers`
|
||||
# and `tags` are nested containers otherwise shared with the upload request and with every
|
||||
# other record in the window. The narrowing above already removed what cannot be copied.
|
||||
for injected in _SCAN_METADATA_BAGS:
|
||||
scan_input[injected] = copy.deepcopy(dict(scan_metadata)) # mutable-ok: guardrails write here
|
||||
|
||||
try:
|
||||
# The chain hands back the body it produced, which may be a replacement for the dict it was
|
||||
# given rather than that same dict mutated, so this is what gets compared.
|
||||
scanned: Final[dict] = await proxy_logging_obj.pre_call_hook( # mutable-ok: the guardrails' own dict
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
data=scan_input,
|
||||
call_type=call_type,
|
||||
guardrails_only=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
if _is_content_block(exc):
|
||||
return RecordDropped(line_number=record.line_number, custom_id=custom_id, guardrail=_naming_guardrail(exc))
|
||||
raise
|
||||
|
||||
rerouted: Final = scanned.get("metadata")
|
||||
if isinstance(rerouted, dict) and rerouted.get(_ROUTE_APPLIED_KEY):
|
||||
return UnroutableRecord(
|
||||
line_number=record.line_number,
|
||||
custom_id=custom_id,
|
||||
guardrail=rerouted.get("sensitive_data_routing_guardrail"),
|
||||
)
|
||||
|
||||
compared: Final = (frozenset(body) | frozenset(scanned)) - _INJECTED_KEYS
|
||||
if _fingerprint(scanned, compared) == _fingerprint(body, compared):
|
||||
return None
|
||||
for injected in _INJECTED_KEYS:
|
||||
scanned.pop(injected, None)
|
||||
scanned.update(own_injected)
|
||||
return _Redaction(
|
||||
line_number=record.line_number,
|
||||
custom_id=custom_id,
|
||||
text=json.dumps({**record.payload, "body": scanned}), # mutable-ok: json.dumps needs a plain dict
|
||||
)
|
||||
|
||||
|
||||
async def _scan_window(
|
||||
window: tuple[_ParsedRecord, ...],
|
||||
scan_metadata: Mapping[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> tuple[tuple[int, _ScanOutcome | BaseException], ...]:
|
||||
"""``return_exceptions=True`` so one record raising never leaves its siblings unobserved."""
|
||||
outcomes: Final = await asyncio.gather(
|
||||
*(_scan_record(record, scan_metadata, user_api_key_dict, proxy_logging_obj) for record in window),
|
||||
return_exceptions=True,
|
||||
)
|
||||
return tuple((record.line_number, outcome) for record, outcome in zip(window, outcomes) if outcome is not None)
|
||||
|
||||
|
||||
def _spool(redactions: BinaryIO, redaction: _Redaction) -> RecordRedacted:
|
||||
"""Park the rewritten record on disk so only its location is carried for the rest of the scan."""
|
||||
encoded: Final = redaction.text.encode("utf-8")
|
||||
redactions.seek(0, 2)
|
||||
offset: Final = redactions.tell()
|
||||
redactions.write(encoded)
|
||||
return RecordRedacted(
|
||||
line_number=redaction.line_number,
|
||||
custom_id=redaction.custom_id,
|
||||
offset=offset,
|
||||
length=len(encoded),
|
||||
)
|
||||
|
||||
|
||||
def _worst(problems: tuple[tuple[int, BatchScanFailure | BaseException], ...]) -> BatchScanFailure | BaseException:
|
||||
"""A guardrail that blocked outranks a record we merely refused; then earliest line wins."""
|
||||
raised: Final = tuple(problem for problem in problems if isinstance(problem[1], BaseException))
|
||||
return min(raised or problems, key=lambda problem: problem[0])[1]
|
||||
|
||||
|
||||
async def scan_batch_input_file(
|
||||
*,
|
||||
file_source: BinaryIO,
|
||||
request_metadata: Mapping[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> BatchScanFailure | BatchScanResult:
|
||||
"""
|
||||
Stream a batch input file and run the pre-call guardrail chain against every record.
|
||||
|
||||
A record a guardrail rewrites is kept in its rewritten form and a record it blocks is dropped,
|
||||
which is what the online path does per request. Both are returned for reporting. A guardrail
|
||||
exception that is not a block is re-raised untouched so its status code survives, since dropping
|
||||
a record that was never inspected is worse than refusing the file.
|
||||
"""
|
||||
scan_metadata: Final = build_scan_metadata(request_metadata)
|
||||
problems: Final[list[tuple[int, BatchScanFailure | BaseException]]] = [] # mutable-ok: spans windows
|
||||
changes: Final[list[_RecordChange]] = [] # mutable-ok: accumulates across windows
|
||||
window: Final[list[_ParsedRecord]] = [] # mutable-ok: bounded read-ahead buffer
|
||||
scanned: Final[list[int]] = [] # mutable-ok: counts records the scan actually reached
|
||||
redactions: Final = tempfile.SpooledTemporaryFile( # noqa: SIM115 # the rewrite reads this back
|
||||
max_size=_REWRITE_SPOOL_BYTES
|
||||
)
|
||||
|
||||
async def drain() -> None:
|
||||
if window:
|
||||
scanned.append(len(window))
|
||||
for line_number, outcome in await _scan_window(
|
||||
tuple(window), scan_metadata, user_api_key_dict, proxy_logging_obj
|
||||
):
|
||||
if isinstance(outcome, _Redaction):
|
||||
changes.append(_spool(redactions, outcome))
|
||||
elif isinstance(outcome, RecordDropped):
|
||||
changes.append(outcome)
|
||||
else:
|
||||
problems.append((line_number, outcome))
|
||||
window.clear()
|
||||
|
||||
try:
|
||||
for item in _iter_records(file_source):
|
||||
window.append(item)
|
||||
if len(window) >= _SCAN_WINDOW:
|
||||
await drain()
|
||||
if problems:
|
||||
break
|
||||
if not problems:
|
||||
await drain()
|
||||
except BaseException:
|
||||
redactions.close()
|
||||
raise
|
||||
finally:
|
||||
file_source.seek(0)
|
||||
|
||||
if problems:
|
||||
redactions.close()
|
||||
worst: Final = _worst(tuple(problems))
|
||||
if isinstance(worst, BaseException):
|
||||
raise worst
|
||||
return worst
|
||||
if not changes:
|
||||
redactions.close()
|
||||
return BatchScanResult(
|
||||
changes=tuple(sorted(changes, key=lambda change: change.line_number)),
|
||||
scanned_records=sum(scanned),
|
||||
redactions=redactions,
|
||||
)
|
||||
|
||||
|
||||
def _read_spooled(redactions: BinaryIO, change: RecordRedacted) -> str:
|
||||
redactions.seek(change.offset)
|
||||
return redactions.read(change.length).decode("utf-8")
|
||||
|
||||
|
||||
def rewrite_batch_input_file(file_source: BinaryIO, result: BatchScanResult) -> BinaryIO:
|
||||
"""
|
||||
Re-emit the file with redacted records rewritten and dropped records left out.
|
||||
|
||||
Untouched records are copied through as written rather than re-serialized, so enabling the
|
||||
feature does not reformat records no guardrail objected to. Blank lines between records are
|
||||
not carried over, since they are not records. Rewritten records are read back from the scan's
|
||||
spool rather than from memory, so a file whose records are mostly rewritten does not put a
|
||||
second copy of itself on the heap.
|
||||
"""
|
||||
redacted: Final = MappingProxyType(
|
||||
{change.line_number: change for change in result.changes if isinstance(change, RecordRedacted)}
|
||||
) # mutable-ok: MappingProxyType freezes the lookup table
|
||||
dropped: Final = frozenset(change.line_number for change in result.changes if isinstance(change, RecordDropped))
|
||||
|
||||
output: Final = tempfile.SpooledTemporaryFile( # noqa: SIM115 # the caller uploads this handle
|
||||
max_size=_REWRITE_SPOOL_BYTES
|
||||
)
|
||||
wrote_any = False # rebind-ok: tracks whether a separator is needed
|
||||
try:
|
||||
for line_number, text in _iter_lines(file_source):
|
||||
if line_number in dropped:
|
||||
continue
|
||||
change = redacted.get(line_number)
|
||||
line = text.rstrip("\n") if change is None else _read_spooled(result.redactions, change)
|
||||
output.write((("\n" if wrote_any else "") + line).encode("utf-8"))
|
||||
wrote_any = True
|
||||
except BaseException:
|
||||
output.close()
|
||||
raise
|
||||
finally:
|
||||
file_source.seek(0)
|
||||
output.seek(0)
|
||||
return output
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import asyncio
|
||||
import traceback
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, BinaryIO, Final, cast, get_args
|
||||
|
||||
import httpx
|
||||
|
|
@ -29,6 +30,7 @@ from litellm._logging import verbose_proxy_logger
|
|||
from litellm.litellm_core_utils.cloud_storage_security import (
|
||||
is_managed_cloud_storage_uri,
|
||||
)
|
||||
from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket
|
||||
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
|
@ -46,6 +48,14 @@ from litellm.proxy.openai_files_endpoints.batch_file_validation import (
|
|||
check_batch_file_upload,
|
||||
raise_batch_file_validation_failure,
|
||||
)
|
||||
from litellm.proxy.openai_files_endpoints.batch_guardrails import (
|
||||
EMPTY_MAPPING,
|
||||
BatchScanResult,
|
||||
raise_nothing_to_submit,
|
||||
raise_public,
|
||||
rewrite_batch_input_file,
|
||||
scan_batch_input_file,
|
||||
)
|
||||
from litellm.proxy.openai_files_endpoints.common_utils import (
|
||||
_is_base64_encoded_unified_file_id,
|
||||
add_internal_model_credentials,
|
||||
|
|
@ -106,6 +116,34 @@ def get_files_provider_config(
|
|||
return None
|
||||
|
||||
|
||||
async def _scan_batch_upload(
|
||||
*,
|
||||
file_source: bytes | BinaryIO,
|
||||
purpose: str,
|
||||
request_metadata: Mapping[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
proxy_logging_obj: ProxyLogging,
|
||||
) -> BatchScanResult | None:
|
||||
"""Guardrail the records of a batch input file, or None when this upload has nothing to scan."""
|
||||
if (
|
||||
purpose != "batch"
|
||||
or isinstance(file_source, bytes)
|
||||
or not proxy_logging_obj.has_pre_call_guardrails(request_metadata)
|
||||
):
|
||||
return None
|
||||
outcome: Final = await scan_batch_input_file(
|
||||
file_source=file_source,
|
||||
request_metadata=request_metadata,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if not isinstance(outcome, BatchScanResult):
|
||||
raise_public(outcome)
|
||||
if outcome.changes and outcome.submitted_records == 0:
|
||||
raise_nothing_to_submit()
|
||||
return outcome
|
||||
|
||||
|
||||
def get_first_json_object(file_source: bytes | BinaryIO) -> dict | None:
|
||||
try:
|
||||
if isinstance(file_source, (bytes, bytearray)):
|
||||
|
|
@ -333,6 +371,10 @@ async def create_file(
|
|||
)
|
||||
|
||||
data: dict = {}
|
||||
# Spools this request owns. Starlette owns the upload handle; anything the guardrail scan
|
||||
# opens is ours, and a batch upload that fails after the scan would otherwise hold the
|
||||
# descriptor and its disk blocks until the collector runs.
|
||||
spools: Final[list[BinaryIO]] = [] # mutable-ok: filled as the scan opens handles
|
||||
try:
|
||||
# Batch uploads can be gigabytes. Starlette has already spooled the upload
|
||||
# to disk, so stream from that handle instead of reading it into memory.
|
||||
|
|
@ -471,14 +513,44 @@ async def create_file(
|
|||
proxy_config=proxy_config,
|
||||
)
|
||||
|
||||
# /v1/files stores its proxy metadata under litellm_metadata, not metadata
|
||||
request_metadata: Final = data.get("metadata") or data.get("litellm_metadata") or EMPTY_MAPPING
|
||||
scan_result: Final = await _scan_batch_upload(
|
||||
file_source=file_source,
|
||||
purpose=purpose,
|
||||
request_metadata=request_metadata,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if scan_result is not None and scan_result.changes:
|
||||
# The caller sees this in the response; a proxy admin needs it server side too,
|
||||
# and it has to land before the post-call hook for logging callbacks to pick it up.
|
||||
get_or_create_metadata_bucket(data)[1]["batch_guardrail"] = scan_result.report().model_dump()
|
||||
verbose_proxy_logger.warning(
|
||||
"batch guardrails changed %s of %s records in %s: %s",
|
||||
len(scan_result.changes),
|
||||
scan_result.scanned_records,
|
||||
file.filename,
|
||||
scan_result.summary(),
|
||||
)
|
||||
|
||||
# Prepare the file data according to FileTypes
|
||||
file_data: Final = (file.filename, file_source, file.content_type)
|
||||
if scan_result is not None:
|
||||
spools.append(scan_result.redactions)
|
||||
upload_source: Final = (
|
||||
await asyncio.to_thread(rewrite_batch_input_file, file_source, scan_result)
|
||||
if scan_result is not None and scan_result.changes
|
||||
else file_source
|
||||
)
|
||||
if upload_source is not file_source:
|
||||
spools.append(upload_source)
|
||||
file_data: Final = (file.filename, upload_source, file.content_type)
|
||||
|
||||
## check if model is a loadbalanced model
|
||||
router_model: str | None = None
|
||||
is_router_model = False
|
||||
if litellm.enable_loadbalancing_on_batch_endpoints is True:
|
||||
json_obj: Final = get_first_json_object(file_source)
|
||||
json_obj: Final = get_first_json_object(upload_source)
|
||||
if json_obj:
|
||||
router_model = get_model_from_json_obj(json_object=json_obj)
|
||||
is_router_model = is_known_model(model=router_model, llm_router=llm_router)
|
||||
|
|
@ -546,6 +618,9 @@ async def create_file(
|
|||
if _response is not None and isinstance(_response, OpenAIFileObject):
|
||||
response = _response
|
||||
|
||||
if scan_result is not None and scan_result.changes:
|
||||
response.litellm_batch_guardrail = scan_result.report()
|
||||
|
||||
### RESPONSE HEADERS ###
|
||||
hidden_params: Final = getattr(response, "_hidden_params", {}) or {}
|
||||
model_id: Final = hidden_params.get("model_id", None) or ""
|
||||
|
|
@ -585,6 +660,9 @@ async def create_file(
|
|||
param=getattr(e, "param", "None"),
|
||||
code=getattr(e, "status_code", 500),
|
||||
)
|
||||
finally:
|
||||
for spool in spools:
|
||||
spool.close()
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
|
|
@ -15255,6 +15255,29 @@ def get_logo_url():
|
|||
return {"logo_url": ""}
|
||||
|
||||
|
||||
def _serve_custom_ui_logo(candidate: str) -> Response | None:
|
||||
"""Serve one admin-configured logo, or None when it is unusable so the caller falls back."""
|
||||
from litellm.proxy.common_utils.static_asset_utils import (
|
||||
resolve_validated_local_image_path,
|
||||
)
|
||||
|
||||
# Remote logo URLs are loaded by the browser. The proxy should not fetch
|
||||
# arbitrary admin-configured URLs server-side.
|
||||
if candidate.startswith(("http://", "https://")):
|
||||
return RedirectResponse(url=candidate)
|
||||
|
||||
safe_logo: Final = resolve_validated_local_image_path(candidate)
|
||||
if safe_logo is None:
|
||||
verbose_proxy_logger.warning(
|
||||
"Custom UI logo %r is not a supported image file or does not exist, falling back",
|
||||
candidate,
|
||||
)
|
||||
return None
|
||||
|
||||
safe_logo_path, media_type = safe_logo
|
||||
return FileResponse(safe_logo_path, media_type=media_type)
|
||||
|
||||
|
||||
@app.get("/get_image", include_in_schema=False)
|
||||
async def get_image(theme: Literal["light", "dark"] | None = None):
|
||||
"""Get logo to show on admin UI"""
|
||||
|
|
@ -15293,31 +15316,33 @@ async def get_image(theme: Literal["light", "dark"] | None = None):
|
|||
if assets_dir != current_dir and not os.path.exists(default_logo):
|
||||
default_logo = default_site_logo
|
||||
|
||||
logo_path = os.getenv("UI_LOGO_PATH", default_logo)
|
||||
verbose_proxy_logger.debug("Reading logo from path: %s", logo_path)
|
||||
custom_logo_candidates: Final = tuple(
|
||||
candidate.strip()
|
||||
for candidate in (
|
||||
os.getenv("UI_LOGO_PATH_DARK", "") if theme == "dark" else "",
|
||||
os.getenv("UI_LOGO_PATH", ""),
|
||||
)
|
||||
if candidate.strip()
|
||||
)
|
||||
verbose_proxy_logger.debug("Custom logo candidates, in fallback order: %s", custom_logo_candidates)
|
||||
|
||||
custom_logo_response: Final = next(
|
||||
(
|
||||
response
|
||||
for response in (_serve_custom_ui_logo(candidate) for candidate in custom_logo_candidates)
|
||||
if response is not None
|
||||
),
|
||||
None,
|
||||
)
|
||||
if custom_logo_response is not None:
|
||||
return custom_logo_response
|
||||
|
||||
from litellm.proxy.common_utils.static_asset_utils import (
|
||||
resolve_validated_local_image_path,
|
||||
)
|
||||
|
||||
if logo_path != default_logo and not logo_path.startswith(("http://", "https://")):
|
||||
safe_logo = resolve_validated_local_image_path(logo_path)
|
||||
if safe_logo is not None:
|
||||
safe_logo_path, media_type = safe_logo
|
||||
return FileResponse(safe_logo_path, media_type=media_type)
|
||||
verbose_proxy_logger.warning(
|
||||
"UI_LOGO_PATH %r is not a supported image file or does not exist, falling back to default logo",
|
||||
logo_path,
|
||||
)
|
||||
logo_path = default_logo
|
||||
|
||||
# Remote logo URLs are loaded by the browser. The proxy should not fetch
|
||||
# arbitrary admin-configured URLs server-side.
|
||||
if logo_path.startswith(("http://", "https://")):
|
||||
return RedirectResponse(url=logo_path)
|
||||
|
||||
# Default logo (resolved from the bundled asset, not user-controlled).
|
||||
safe_logo = resolve_validated_local_image_path(logo_path)
|
||||
safe_logo: Final = resolve_validated_local_image_path(default_logo)
|
||||
if safe_logo is not None:
|
||||
safe_logo_path, media_type = safe_logo
|
||||
return FileResponse(safe_logo_path, media_type=media_type)
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@ def _config_param_db(repo: _HasConfigParamTable) -> _PrismaTableActions[_ConfigP
|
|||
# reflect a deployment branded purely through process env.
|
||||
_UI_THEME_FIELD_ENV_VARS: Final[dict[str, str]] = {
|
||||
"logo_url": "UI_LOGO_PATH",
|
||||
"logo_url_dark": "UI_LOGO_PATH_DARK",
|
||||
"favicon_url": "LITELLM_FAVICON_URL",
|
||||
}
|
||||
|
||||
|
|
@ -156,6 +157,14 @@ class UIThemeConfig(BaseModel):
|
|||
description="URL or path to custom logo image. Can be a local file path or HTTP/HTTPS URL",
|
||||
)
|
||||
|
||||
logo_url_dark: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"URL or path to a custom logo image for dark mode. Can be a local file path or HTTP/HTTPS URL. "
|
||||
"Leave unset to reuse logo_url in dark mode"
|
||||
),
|
||||
)
|
||||
|
||||
# Favicon configuration
|
||||
favicon_url: str | None = Field(
|
||||
default=None,
|
||||
|
|
@ -1184,6 +1193,7 @@ async def update_ui_theme_settings(
|
|||
)
|
||||
|
||||
_validate_public_image_url(theme_config.logo_url, "logo_url")
|
||||
_validate_public_image_url(theme_config.logo_url_dark, "logo_url_dark")
|
||||
_validate_public_image_url(theme_config.favicon_url, "favicon_url")
|
||||
|
||||
if store_model_in_db is not True:
|
||||
|
|
@ -1204,16 +1214,18 @@ async def update_ui_theme_settings(
|
|||
config["litellm_settings"] = {}
|
||||
config["litellm_settings"]["ui_theme_config"] = theme_data
|
||||
|
||||
# UI_LOGO_PATH and LITELLM_FAVICON_URL are the only environment variables
|
||||
# this endpoint owns. A non-empty value sets the var; an empty or missing
|
||||
# one clears it back to the default. Apply to the live process immediately,
|
||||
# then persist only these two keys so an unrelated env var (a YAML/OS value
|
||||
# merged in by get_config) is never snapshotted into the DB.
|
||||
# The vars below are the only environment variables this endpoint owns, and
|
||||
# they must stay in step with _UI_THEME_FIELD_ENV_VARS. A non-empty value
|
||||
# sets the var; an empty or missing one clears it back to the default. Apply
|
||||
# to the live process immediately, then persist only those keys so an
|
||||
# unrelated env var (a YAML/OS value merged in by get_config) is never
|
||||
# snapshotted into the DB.
|
||||
def _clean(url: str | None) -> str | None:
|
||||
return url if url is not None and url.strip() else None
|
||||
|
||||
env_updates: Final[dict[str, str | None]] = {
|
||||
"UI_LOGO_PATH": _clean(theme_config.logo_url),
|
||||
"UI_LOGO_PATH_DARK": _clean(theme_config.logo_url_dark),
|
||||
"LITELLM_FAVICON_URL": _clean(theme_config.favicon_url),
|
||||
}
|
||||
for env_key, env_value in env_updates.items():
|
||||
|
|
|
|||
|
|
@ -1527,6 +1527,23 @@ class ProxyLogging:
|
|||
|
||||
return data
|
||||
|
||||
def has_pre_call_guardrails(self, request_metadata: Mapping[str, object]) -> bool:
|
||||
"""
|
||||
Whether any guardrail or guardrail pipeline would inspect a request carrying this metadata.
|
||||
|
||||
Evaluated with the same predicate the pre-call loop uses, so a proxy configured only with
|
||||
post-call guardrails answers False. Callers that must pay a real cost to build the hook's
|
||||
input, such as streaming a batch input file off disk, use this to skip that work.
|
||||
"""
|
||||
if request_metadata.get("_guardrail_pipelines"):
|
||||
return True
|
||||
probe: Final = {"metadata": dict(request_metadata)} # mutable-ok: should_run_guardrail takes a dict
|
||||
return any(
|
||||
isinstance(callback, CustomGuardrail)
|
||||
and callback.should_run_guardrail(data=probe, event_type=GuardrailEventHooks.pre_call)
|
||||
for callback in ProxyLogging._callback_capabilities().resolved_callbacks
|
||||
)
|
||||
|
||||
# The actual implementation of the function
|
||||
@overload
|
||||
async def pre_call_hook(
|
||||
|
|
@ -1534,6 +1551,7 @@ class ProxyLogging:
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
data: None,
|
||||
call_type: CallTypesLiteral,
|
||||
guardrails_only: bool = False,
|
||||
) -> None:
|
||||
pass
|
||||
|
||||
|
|
@ -1543,6 +1561,7 @@ class ProxyLogging:
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
data: dict,
|
||||
call_type: CallTypesLiteral,
|
||||
guardrails_only: bool = False,
|
||||
) -> dict:
|
||||
pass
|
||||
|
||||
|
|
@ -1551,6 +1570,7 @@ class ProxyLogging:
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
data: dict | None,
|
||||
call_type: CallTypesLiteral,
|
||||
guardrails_only: bool = False,
|
||||
) -> dict | None:
|
||||
"""
|
||||
Allows users to modify/reject the incoming request to the proxy, without having to deal with parsing Request body.
|
||||
|
|
@ -1559,10 +1579,15 @@ class ProxyLogging:
|
|||
1. /chat/completions
|
||||
2. /embeddings
|
||||
3. /image/generation
|
||||
|
||||
With ``guardrails_only`` the walk is limited to guardrails and guardrail pipelines: rate
|
||||
limiting, budget accounting, prompt templates and hanging-request alerting are skipped.
|
||||
Use it to scan a payload that is not itself a request, such as one record of a batch file.
|
||||
"""
|
||||
verbose_proxy_logger.debug("Inside Proxy Logging Pre-call hook!")
|
||||
|
||||
self._init_response_taking_too_long_task(data=data)
|
||||
if not guardrails_only:
|
||||
self._init_response_taking_too_long_task(data=data)
|
||||
|
||||
if data is None:
|
||||
return None
|
||||
|
|
@ -1574,7 +1599,8 @@ class ProxyLogging:
|
|||
## PROMPT TEMPLATE CHECK ##
|
||||
|
||||
if (
|
||||
litellm_logging_obj is not None
|
||||
not guardrails_only
|
||||
and litellm_logging_obj is not None
|
||||
and prompt_id is not None
|
||||
and (call_type == "completion" or call_type == "acompletion")
|
||||
):
|
||||
|
|
@ -1605,7 +1631,7 @@ class ProxyLogging:
|
|||
# CustomGuardrail is configured. Saves the loop overhead +
|
||||
# ``time.time()`` x2 per registered callback for the common
|
||||
# "callbacks=[]" case on small / dev deployments.
|
||||
if not caps.has_guardrail and not caps.has_pre_call_override:
|
||||
if not caps.has_guardrail and (guardrails_only or not caps.has_pre_call_override):
|
||||
if data is not None:
|
||||
self._process_guardrail_metadata(data)
|
||||
return data
|
||||
|
|
@ -1642,7 +1668,8 @@ class ProxyLogging:
|
|||
data = result
|
||||
|
||||
elif (
|
||||
_callback is not None
|
||||
not guardrails_only
|
||||
and _callback is not None
|
||||
and isinstance(_callback, CustomLogger)
|
||||
and "async_pre_call_hook" in vars(_callback.__class__)
|
||||
and _callback.__class__.async_pre_call_hook != CustomLogger.async_pre_call_hook
|
||||
|
|
|
|||
|
|
@ -279,6 +279,40 @@ OpenAIFilesPurpose = Literal[
|
|||
]
|
||||
|
||||
|
||||
class BatchGuardrailRecord(BaseModel):
|
||||
"""One batch input record a guardrail acted on."""
|
||||
|
||||
line: int
|
||||
"""The 1-based line of the uploaded file the record started on."""
|
||||
|
||||
custom_id: str | None = None
|
||||
"""The record's own `custom_id`, when it carried one."""
|
||||
|
||||
action: Literal["redacted", "dropped"]
|
||||
"""`redacted` means the record was submitted with the guardrail's rewrite applied.
|
||||
|
||||
`dropped` means the guardrail blocked it and it was left out of the submitted file.
|
||||
"""
|
||||
|
||||
guardrail: str | None = None
|
||||
"""Which guardrail dropped the record, when it named itself.
|
||||
|
||||
Set for dropped records only. A guardrail refusing content and a guardrail that is
|
||||
unreachable under a fail-closed setting raise the same way, so this names the guardrail
|
||||
to check rather than claiming a reason it cannot distinguish.
|
||||
"""
|
||||
|
||||
|
||||
class BatchGuardrailReport(BaseModel):
|
||||
"""What guardrails did to a batch input file, per record."""
|
||||
|
||||
submitted_records: int
|
||||
"""How many records reached the provider."""
|
||||
|
||||
modified_records: tuple[BatchGuardrailRecord, ...]
|
||||
"""Every record that was redacted or dropped, in file order."""
|
||||
|
||||
|
||||
class OpenAIFileObject(BaseModel):
|
||||
id: str
|
||||
"""The file identifier, which can be referenced in the API endpoints."""
|
||||
|
|
@ -319,6 +353,12 @@ class OpenAIFileObject(BaseModel):
|
|||
`error` field on `fine_tuning.job`.
|
||||
"""
|
||||
|
||||
litellm_batch_guardrail: BatchGuardrailReport | None = None
|
||||
"""Set by the proxy when guardrails acted on a `purpose=batch` upload.
|
||||
|
||||
Absent on every other upload, so OpenAI-shaped clients see an unchanged response.
|
||||
"""
|
||||
|
||||
_hidden_params: dict = {"response_cost": 0.0} # no cost for writing a file
|
||||
|
||||
def __contains__(self, key) -> bool:
|
||||
|
|
|
|||
|
|
@ -27,6 +27,22 @@ class RequestComplexityRouterConfig(ComplexityRouterConfig):
|
|||
)
|
||||
|
||||
|
||||
class ComplexityRouterConfigValidationRequest(BaseModel):
|
||||
"""A complexity-router config to validate without saving, so a form can surface the
|
||||
backend's own verdict inline instead of a raw 400 at write time."""
|
||||
|
||||
complexity_router_config: Mapping[str, object]
|
||||
team_id: str | None = Field(
|
||||
default=None,
|
||||
description="Team the router is being created for. Required for a team admin, who may only validate their own team's routers",
|
||||
)
|
||||
|
||||
|
||||
class ComplexityRouterConfigValidationResponse(BaseModel):
|
||||
valid: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class AutoRouterRoutingTestRequest(BaseModel):
|
||||
"""A single prompt to classify against a complexity-router config that need not be saved yet."""
|
||||
|
||||
|
|
@ -60,7 +76,7 @@ class AutoRouterRoutingTestResponse(BaseModel):
|
|||
|
||||
routed_model: str = Field(description="The model group the router picked")
|
||||
routed_model_configured: bool = Field(
|
||||
description="Whether routed_model is a model group this proxy actually serves",
|
||||
description="Whether routed_model is a model group available to the caller, scoped to team_id when given. Never confirms models the caller could not use",
|
||||
)
|
||||
routing_decision: StandardLoggingRoutingDecision = Field(
|
||||
description="The decision record this request would have written to its log row",
|
||||
|
|
|
|||
15
ruff-tests.toml
Normal file
15
ruff-tests.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Lint config for the test tree, which ruff.toml excludes from `ruff check`.
|
||||
#
|
||||
# Deliberately one rule. F821 is the cheapest guard against a test that cannot fail:
|
||||
# a name that does not exist raises NameError, and a test whose body is wrapped in
|
||||
# `except Exception: pass` swallows that NameError and reports green. Widening this
|
||||
# select list means ratcheting thousands of pre-existing findings, so new rules go in
|
||||
# one at a time, each with its violations already fixed.
|
||||
#
|
||||
# No target-version here on purpose: it resolves from requires-python (>=3.10), so
|
||||
# 3.11-only builtins like BaseExceptionGroup are correctly flagged in a tree that
|
||||
# still has to run on 3.10.
|
||||
|
||||
line-length = 120
|
||||
|
||||
lint.select = ["F821"]
|
||||
|
|
@ -61,7 +61,7 @@ try:
|
|||
documented_keys.update(doc_key_pattern.findall(table_content))
|
||||
except Exception as e:
|
||||
raise Exception(
|
||||
f"Error reading documentation: {e}, \n repo base - {os.listdir(repo_base)}"
|
||||
f"Error reading documentation: {e}, \n repo base - {os.listdir(_repo_root)}"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -842,6 +842,8 @@ def test_completion_mistral_api_modified_input():
|
|||
|
||||
@pytest.mark.skip(reason="this test is flaky")
|
||||
def test_completion_gpt4_vision():
|
||||
import openai
|
||||
|
||||
try:
|
||||
litellm.set_verbose = True
|
||||
response = completion(
|
||||
|
|
@ -1820,6 +1822,8 @@ def test_completion_openai_litellm_key():
|
|||
|
||||
@pytest.mark.skip(reason="Unresponsive endpoint.[TODO] Rehost this somewhere else")
|
||||
def test_completion_ollama_hosted():
|
||||
import openai
|
||||
|
||||
try:
|
||||
litellm.request_timeout = 20 # give ollama 20 seconds to response
|
||||
litellm.set_verbose = True
|
||||
|
|
@ -2057,17 +2061,12 @@ def test_completion_openrouter_reasoning_effort():
|
|||
|
||||
|
||||
def test_completion_hf_model_no_provider():
|
||||
try:
|
||||
response = completion(
|
||||
with pytest.raises(litellm.BadRequestError, match="LLM Provider NOT provided"):
|
||||
completion(
|
||||
model="WizardLM/WizardLM-70B-V1.0",
|
||||
messages=messages,
|
||||
max_tokens=5,
|
||||
)
|
||||
# Add any assertions here to check the response
|
||||
print(response)
|
||||
pytest.fail(f"Error occurred: {e}")
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
||||
# test_completion_hf_model_no_provider()
|
||||
|
|
@ -2546,7 +2545,7 @@ def test_completion_replicate_vicuna():
|
|||
response_str = response["choices"][0]["message"]["content"]
|
||||
print("RESPONSE STRING\n", response_str)
|
||||
if type(response_str) != str:
|
||||
pytest.fail(f"Error occurred: {e}")
|
||||
pytest.fail(f"Expected a string response, got {type(response_str)}: {response_str}")
|
||||
except Exception as e:
|
||||
pytest.fail(f"Error occurred: {e}")
|
||||
|
||||
|
|
|
|||
|
|
@ -573,7 +573,7 @@ def test_content_policy_violation_error_streaming():
|
|||
num_finish_reason += 1
|
||||
print("finish_reason", chunk["choices"][0].get("finish_reason"))
|
||||
|
||||
pytest.fail(f"Expected to return 400 error In streaming{e}")
|
||||
pytest.fail("Expected a content-policy error in streaming, got a clean stream")
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ sys.path.insert(
|
|||
0, os.path.abspath("../..")
|
||||
) # Adds the parent directory to the system path
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import litellm
|
||||
from litellm_enterprise.enterprise_callbacks.llm_guard import _ENTERPRISE_LLMGuard
|
||||
from litellm import Router, mock_completion
|
||||
|
|
@ -128,7 +130,7 @@ async def test_llm_guard_error_raising():
|
|||
user_api_key_dict = UserAPIKeyAuth(api_key=_api_key)
|
||||
local_cache = DualCache()
|
||||
|
||||
try:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await llm_guard.async_moderation_hook(
|
||||
data={
|
||||
"messages": [
|
||||
|
|
@ -141,9 +143,9 @@ async def test_llm_guard_error_raising():
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
call_type="completion",
|
||||
)
|
||||
pytest.fail(f"Should have failed - {str(e)}")
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == {"error": "Violated content safety policy"}
|
||||
|
||||
|
||||
def test_llm_guard_key_specific_mode():
|
||||
|
|
|
|||
|
|
@ -148,65 +148,6 @@ async def test_claude_agent_sdk_streaming(
|
|||
f"Test failed for {model_name} ({model_description}) after {MAX_RETRIES} attempts: {last_error}"
|
||||
)
|
||||
|
||||
# Test query
|
||||
test_query = "Say 'Hello from LiteLLM!' and nothing else."
|
||||
|
||||
# Track streaming
|
||||
received_chunks = []
|
||||
full_response = ""
|
||||
|
||||
try:
|
||||
async with ClaudeSDKClient(options=options) as client:
|
||||
await client.query(test_query)
|
||||
|
||||
# Collect streaming response
|
||||
async for msg in client.receive_response():
|
||||
# Handle different message types
|
||||
if hasattr(msg, "type"):
|
||||
if msg.type == "content_block_delta":
|
||||
# Streaming text delta
|
||||
if hasattr(msg, "delta") and hasattr(msg.delta, "text"):
|
||||
chunk_text = msg.delta.text
|
||||
received_chunks.append(chunk_text)
|
||||
full_response += chunk_text
|
||||
elif msg.type == "content_block_start":
|
||||
# Start of content block
|
||||
if hasattr(msg, "content_block") and hasattr(
|
||||
msg.content_block, "text"
|
||||
):
|
||||
chunk_text = msg.content_block.text
|
||||
received_chunks.append(chunk_text)
|
||||
full_response += chunk_text
|
||||
|
||||
# Fallback to content handling
|
||||
if hasattr(msg, "content"):
|
||||
for content_block in msg.content:
|
||||
if hasattr(content_block, "text"):
|
||||
chunk_text = content_block.text
|
||||
received_chunks.append(chunk_text)
|
||||
full_response += chunk_text
|
||||
|
||||
# Assertions
|
||||
print(f"\n✅ Received {len(received_chunks)} chunks")
|
||||
print(f"📝 Full response: {full_response[:100]}...")
|
||||
|
||||
# Verify we got a response
|
||||
assert len(full_response) > 0, f"No response received from {model_name}"
|
||||
|
||||
# Verify streaming (should have multiple chunks for most responses)
|
||||
# Note: Very short responses might come in 1 chunk, so we just verify we got content
|
||||
assert len(received_chunks) > 0, f"No chunks received from {model_name}"
|
||||
|
||||
# Verify response is non-empty (don't assert on specific LLM content — it's non-deterministic)
|
||||
assert (
|
||||
len(full_response.strip()) > 0
|
||||
), f"Empty response received from {model_name}"
|
||||
|
||||
print(f"✅ Test passed for {model_name}")
|
||||
|
||||
except Exception as e:
|
||||
pytest.fail(f"Test failed for {model_name} ({model_description}): {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
## This test asserts the type of data passed into each method of the custom callback handler
|
||||
import asyncio
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
|
|
|||
|
|
@ -14,47 +14,6 @@ from typing import Optional
|
|||
"""
|
||||
|
||||
|
||||
async def chat_completion_with_headers(session, key, model="gpt-4"):
|
||||
url = "http://0.0.0.0:4000/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
data = {
|
||||
"model": model,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello!"},
|
||||
],
|
||||
}
|
||||
|
||||
async with session.post(url, headers=headers, json=data) as response:
|
||||
status = response.status
|
||||
response_text = await response.text()
|
||||
|
||||
print(response_text)
|
||||
print()
|
||||
|
||||
if status != 200:
|
||||
raise Exception(f"Request did not return a 200 status code: {status}")
|
||||
|
||||
response_header_check(
|
||||
response
|
||||
) # calling the function to check response headers
|
||||
|
||||
raw_headers = response.raw_headers
|
||||
raw_headers_json = {}
|
||||
|
||||
for (
|
||||
item
|
||||
) in (
|
||||
response.raw_headers
|
||||
): # ((b'date', b'Fri, 19 Apr 2024 21:17:29 GMT'), (), )
|
||||
raw_headers_json[item[0].decode("utf-8")] = item[1].decode("utf-8")
|
||||
|
||||
return raw_headers_json
|
||||
|
||||
|
||||
async def generate_key(
|
||||
session,
|
||||
i,
|
||||
|
|
|
|||
|
|
@ -80,8 +80,28 @@ def _write_metadata_only_file(home):
|
|||
return path
|
||||
|
||||
|
||||
def _blob(base_url=SERVER, key="sk-vault", jwt_token="", timestamp=0.0):
|
||||
return json.dumps({"base_url": base_url, "key": key, "jwt_token": jwt_token, "timestamp": timestamp})
|
||||
def _blob(base_url=SERVER, key="sk-vault", jwt_token="", timestamp=0.0, refresh_token=None):
|
||||
return json.dumps(
|
||||
{
|
||||
"base_url": base_url,
|
||||
"key": key,
|
||||
"jwt_token": jwt_token,
|
||||
"refresh_token": refresh_token,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _write_key_only_keychain_file(home, *, refresh_token="rt-live", timestamp=2000.0):
|
||||
"""What the release that kept only the key in the keychain left on disk: metadata, plus the
|
||||
refresh token in the clear."""
|
||||
path = _token_file(home)
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps({"base_url": SERVER, "user_id": "u-1", "refresh_token": refresh_token, "timestamp": timestamp})
|
||||
)
|
||||
path.chmod(0o600)
|
||||
return path
|
||||
|
||||
|
||||
_REAL_MKSTEMP = tempfile.mkstemp
|
||||
|
|
@ -161,6 +181,56 @@ class TestLoadCliToken:
|
|||
|
||||
assert (record.key, record.jwt_token) == ("sk-a", "jwt-a")
|
||||
|
||||
def test_the_refresh_token_round_trips_through_the_vault(self, isolated_home, secret_vault_factory):
|
||||
"""A refresh token mints a fresh key from the proxy on demand, so it is the credential just
|
||||
as much as the key is, and it has to come back out of the keychain to be usable."""
|
||||
_write_metadata_only_file(isolated_home)
|
||||
vault = secret_vault_factory(blob=_blob(key="sk-a", refresh_token="rt-a"))
|
||||
|
||||
record = load_cli_token(vault=vault)
|
||||
|
||||
assert (record.key, record.refresh_token) == ("sk-a", "rt-a")
|
||||
|
||||
def test_a_plaintext_refresh_token_is_moved_off_disk(self, isolated_home, secret_vault_factory):
|
||||
path = _write_legacy_file(isolated_home, refresh_token="rt-legacy")
|
||||
vault = secret_vault_factory()
|
||||
|
||||
record = load_cli_token(vault=vault)
|
||||
|
||||
assert record.refresh_token == "rt-legacy"
|
||||
assert "rt-legacy" not in path.read_text()
|
||||
assert json.loads(vault.blob)["refresh_token"] == "rt-legacy"
|
||||
|
||||
def test_an_upgrade_that_left_the_refresh_token_on_disk_rejoins_it_with_the_key(
|
||||
self, isolated_home, secret_vault_factory
|
||||
):
|
||||
"""The release before this one took the key into the keychain and left the refresh token
|
||||
behind, so upgrading finds one sign-in split across both stores. The read has to end with
|
||||
the whole credential in the keychain, not with whichever half it happened to prefer."""
|
||||
path = _write_key_only_keychain_file(isolated_home)
|
||||
vault = secret_vault_factory(blob=_blob(key="sk-live", timestamp=2000.0))
|
||||
|
||||
record = load_cli_token(vault=vault)
|
||||
|
||||
assert (record.key, record.refresh_token) == ("sk-live", "rt-live")
|
||||
assert "rt-live" not in path.read_text()
|
||||
assert json.loads(vault.blob)["key"] == "sk-live"
|
||||
assert json.loads(vault.blob)["refresh_token"] == "rt-live"
|
||||
|
||||
def test_a_superseded_refresh_token_on_disk_never_outlives_the_keychain(
|
||||
self, isolated_home, secret_vault_factory
|
||||
):
|
||||
"""Two stores, two sign-ins, and the newer one is in the keychain. Handing back its key with
|
||||
the older one's refresh token would build a credential neither store ever held, and would
|
||||
renew the login the user already replaced."""
|
||||
path = _write_legacy_file(isolated_home, key="sk-old", refresh_token="rt-old", timestamp=1000.0)
|
||||
vault = secret_vault_factory(blob=_blob(key="sk-new", refresh_token="rt-new", timestamp=2000.0))
|
||||
|
||||
record = load_cli_token(vault=vault)
|
||||
|
||||
assert (record.key, record.refresh_token) == ("sk-new", "rt-new")
|
||||
assert "rt-old" not in path.read_text()
|
||||
|
||||
def test_legacy_plaintext_file_still_authenticates_and_is_migrated(self, isolated_home, secret_vault_factory):
|
||||
"""A token.json written by an older `lite` keeps working, and reading it moves the secret
|
||||
into the keychain and scrubs it from disk."""
|
||||
|
|
@ -361,6 +431,36 @@ class TestSaveCliToken:
|
|||
assert json.loads(vault.blob)["key"] == "sk-new"
|
||||
assert load_cli_token(vault=vault).key == "sk-new"
|
||||
|
||||
def test_the_refresh_token_goes_to_the_keychain_and_never_to_the_file(
|
||||
self, isolated_home, secret_vault_factory
|
||||
):
|
||||
vault = secret_vault_factory()
|
||||
|
||||
stored = save_cli_token(
|
||||
CliTokenRecord(base_url=SERVER, key="sk-new", refresh_token="rt-new", timestamp=time.time()),
|
||||
vault=vault,
|
||||
)
|
||||
|
||||
assert stored == SecretStored()
|
||||
assert "rt-new" not in _token_file(isolated_home).read_text()
|
||||
assert json.loads(vault.blob)["refresh_token"] == "rt-new"
|
||||
assert load_cli_token(vault=vault).refresh_token == "rt-new"
|
||||
|
||||
def test_the_refresh_token_falls_back_to_the_owner_only_file_with_the_key(
|
||||
self, isolated_home, secret_vault_factory
|
||||
):
|
||||
"""A machine with no keychain keeps the whole credential in the 0600 file, refresh token
|
||||
included, because a renewal that cannot be stored logs the user out on the next command."""
|
||||
vault = secret_vault_factory(available=False, failure=KeyringNotInstalled())
|
||||
|
||||
save_cli_token(
|
||||
CliTokenRecord(base_url=SERVER, key="sk-new", refresh_token="rt-new", timestamp=time.time()),
|
||||
vault=vault,
|
||||
)
|
||||
|
||||
assert json.loads(_token_file(isolated_home).read_text())["refresh_token"] == "rt-new"
|
||||
assert load_cli_token(vault=vault).refresh_token == "rt-new"
|
||||
|
||||
def test_falls_back_to_the_owner_only_file_when_there_is_no_keychain(self, isolated_home, secret_vault_factory):
|
||||
stored = save_cli_token(
|
||||
CliTokenRecord(base_url=SERVER, key="sk-new", timestamp=time.time()),
|
||||
|
|
@ -628,6 +728,26 @@ class TestScrubFailure:
|
|||
assert json.loads(path.read_text()).get("key") is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.geteuid() == 0, reason="root ignores file permissions")
|
||||
def test_a_rejoin_the_file_refuses_never_takes_the_key_with_it(
|
||||
self, isolated_home, secret_vault_factory, monkeypatch
|
||||
):
|
||||
"""Rolling the rejoined entry back would erase a key that was safely in the keychain before
|
||||
this read began, and the file it would fall back to is the one that has just refused to be
|
||||
rewritten. The duplicate refresh token stays until a later read can finish the move."""
|
||||
path = _write_key_only_keychain_file(isolated_home)
|
||||
vault = secret_vault_factory(blob=_blob(key="sk-live", timestamp=2000.0))
|
||||
monkeypatch.setattr("litellm.litellm_core_utils.private_json.os.replace", _refuse_replace)
|
||||
path.chmod(0o400)
|
||||
|
||||
record = load_cli_token(vault=vault)
|
||||
|
||||
assert (record.key, record.refresh_token) == ("sk-live", "rt-live")
|
||||
assert json.loads(vault.blob)["key"] == "sk-live"
|
||||
assert json.loads(vault.blob)["refresh_token"] == "rt-live"
|
||||
assert vault.erases == 0
|
||||
|
||||
|
||||
class TestClearCliToken:
|
||||
def test_removes_the_credential_from_both_stores(self, isolated_home, secret_vault_factory):
|
||||
vault = secret_vault_factory()
|
||||
|
|
@ -717,12 +837,14 @@ class TestClearCliToken:
|
|||
):
|
||||
"""Keeping a record of the unreachable keychain must never mean keeping the cleartext copy
|
||||
the user just asked to be rid of."""
|
||||
_write_legacy_file(isolated_home)
|
||||
_write_legacy_file(isolated_home, refresh_token="rt-legacy")
|
||||
vault = secret_vault_factory(available=False, failure=KeyringUnreachable())
|
||||
|
||||
clear_cli_token(vault=vault)
|
||||
|
||||
assert "sk-legacy" not in _token_file(isolated_home).read_text()
|
||||
left_on_disk = _token_file(isolated_home).read_text()
|
||||
assert "sk-legacy" not in left_on_disk
|
||||
assert "rt-legacy" not in left_on_disk
|
||||
|
||||
def test_a_repeat_logout_never_answers_its_own_warning_with_an_all_clear(
|
||||
self, isolated_home, secret_vault_factory
|
||||
|
|
|
|||
|
|
@ -11,11 +11,16 @@ All tests are self-contained and require no real OCI credentials or network acce
|
|||
"""
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
import httpx
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.llms.oci.chat.transformation import OCIStreamWrapper
|
||||
|
||||
from litellm import ModelResponse
|
||||
from litellm.llms.oci.chat.cohere import (
|
||||
_extract_text_content,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
to exactly one category, wire values never carry upstream prose, and single-upstream HTTP statuses
|
||||
stay truthful to who failed."""
|
||||
|
||||
import sys
|
||||
|
||||
if sys.version_info < (3, 11): # BaseExceptionGroup is a builtin only from 3.11
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp import McpError
|
||||
|
|
|
|||
|
|
@ -2,6 +2,11 @@
|
|||
links win (the ``raise ... from`` cause subtree, then ExceptionGroup members in raise order,
|
||||
then the incidental ``__context__`` chain last), and adversarial shapes terminate."""
|
||||
|
||||
import sys
|
||||
|
||||
if sys.version_info < (3, 11): # BaseExceptionGroup is a builtin only from 3.11
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.faults import iter_exception_tree
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import hashlib
|
|||
import json
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
|
@ -11,6 +12,11 @@ from fastapi import HTTPException
|
|||
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
from litellm.types.mcp_server.mcp_server_manager import MCPServer
|
||||
|
||||
|
||||
# Fixture to mock IP address check for all MCP tests
|
||||
# This prevents tests from failing due to IP-based access control
|
||||
|
|
|
|||
|
|
@ -1411,6 +1411,8 @@ async def _run_passthrough_connect(
|
|||
):
|
||||
"""Drive handle_streamable_http_mcp through the preemptive-401 gate and report whether it
|
||||
challenged (raised) or forwarded to the session manager. Returns (challenged, www_authenticate)."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
from litellm.proxy._experimental.mcp_server.server import (
|
||||
handle_streamable_http_mcp,
|
||||
session_manager_stateless,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
if sys.version_info < (3, 11): # BaseExceptionGroup is a builtin only from 3.11
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ from unittest.mock import AsyncMock, Mock, patch
|
|||
|
||||
import pytest
|
||||
|
||||
if sys.version_info < (3, 11): # BaseExceptionGroup is a builtin only from 3.11
|
||||
from exceptiongroup import BaseExceptionGroup
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../.."))
|
||||
|
||||
from mcp.types import Tool as MCPTool
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ from datetime import datetime, timedelta, timezone
|
|||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import status
|
||||
from fastapi import Request, status
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import (
|
||||
|
|
@ -2760,11 +2760,9 @@ async def test_common_checks_metadata_route_keeps_key_tags_out_of_provider_metad
|
|||
assert "metadata" not in request_body
|
||||
|
||||
|
||||
def _pass_through_request() -> "Request":
|
||||
def _pass_through_request() -> Request:
|
||||
"""A Request whose FastAPI-resolved endpoint carries the pass-through marker,
|
||||
i.e. the request was dispatched to a user-defined pass-through handler."""
|
||||
from fastapi import Request
|
||||
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
|
||||
)
|
||||
|
|
@ -2776,10 +2774,9 @@ def _pass_through_request() -> "Request":
|
|||
return Request(scope={"type": "http", "headers": [], "endpoint": pass_through_endpoint})
|
||||
|
||||
|
||||
def _builtin_request() -> "Request":
|
||||
def _builtin_request() -> Request:
|
||||
"""A Request dispatched to a built-in (non-pass-through) handler, e.g. what a
|
||||
custom path colliding with a core route actually resolves to."""
|
||||
from fastapi import Request
|
||||
|
||||
def chat_completions():
|
||||
...
|
||||
|
|
|
|||
|
|
@ -3379,3 +3379,52 @@ def test_organization_daily_activity_not_granted_by_org_admin_request_data_branc
|
|||
route="/organization/daily/activity",
|
||||
allowed_routes=LiteLLMRoutes.org_admin_only_routes.value,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_role",
|
||||
[
|
||||
LitellmUserRoles.INTERNAL_USER.value,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
||||
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
LitellmUserRoles.TEAM.value,
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"dry_run_route",
|
||||
["/auto_router/test_routing", "/auto_router/validate_complexity_router_config"],
|
||||
)
|
||||
def test_auto_router_dry_runs_share_model_new_audience(user_role, dry_run_route):
|
||||
"""The dry runs serve whoever can draft a save on /model/new, no one else: a role
|
||||
must get the same allow-or-403 from both layers' route check, or the form's
|
||||
pre-save call 403s for an operator whose save would have been accepted."""
|
||||
|
||||
def outcome(route: str) -> str:
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="test_user",
|
||||
user_email="test@example.com",
|
||||
user_role=user_role,
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=user_role,
|
||||
)
|
||||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
try:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=user_role,
|
||||
route=route,
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
return "allowed"
|
||||
except HTTPException:
|
||||
return "rejected"
|
||||
|
||||
assert outcome(dry_run_route) == outcome("/model/new")
|
||||
# Anchor so parity cannot be satisfied by both routes 403ing for everyone
|
||||
if user_role == LitellmUserRoles.INTERNAL_USER.value:
|
||||
assert outcome(dry_run_route) == "allowed"
|
||||
|
|
|
|||
|
|
@ -1067,3 +1067,29 @@ async def test_anthropic_non_streaming_response_reports_usage():
|
|||
payload = _posted_payload(g)
|
||||
assert payload["usage"] == {"input_tokens": 10, "output_tokens": 5}
|
||||
assert payload["response"]["finish_reason"] == "end_turn"
|
||||
|
||||
|
||||
def test_fail_closed_backend_failure_is_not_reported_as_a_content_verdict():
|
||||
"""A drop-one-record consumer must be able to tell a verdict from an outage; _fail is not a verdict."""
|
||||
from litellm.exceptions import GuardrailRaisedException
|
||||
|
||||
guardrail = _make_guardrail()
|
||||
|
||||
with pytest.raises(GuardrailRaisedException) as unreachable:
|
||||
guardrail._fail(
|
||||
inputs={},
|
||||
request_data={"model": "m"},
|
||||
input_type="request",
|
||||
error="connection refused",
|
||||
is_unreachable=True,
|
||||
)
|
||||
assert unreachable.value.blocked_content is False
|
||||
|
||||
with pytest.raises(GuardrailRaisedException) as verdict:
|
||||
guardrail._block(
|
||||
request_data={"model": "m"},
|
||||
input_type="request",
|
||||
message="blocked",
|
||||
blocked_content=True,
|
||||
)
|
||||
assert verdict.value.blocked_content is True
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ and following LiteLLM testing patterns and best practices.
|
|||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from typing import Dict
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
# Add parent directory to path for imports
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ Unit tests for auto router management endpoints
|
|||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -1188,6 +1189,149 @@ async def test_stop_shadow_eval_stops_every_unstopped_leg_and_rejects_non_runnin
|
|||
assert forbidden.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_config_returns_the_write_gates_verdict_without_saving():
|
||||
"""The dry-run endpoint must agree with the write gate exactly, so a form showing its
|
||||
verdict inline can never pass a config the save would then reject."""
|
||||
from litellm.proxy.management_endpoints.auto_router_endpoints import (
|
||||
validate_complexity_router_config,
|
||||
)
|
||||
from litellm.types.management_endpoints.auto_router_endpoints import (
|
||||
ComplexityRouterConfigValidationRequest,
|
||||
)
|
||||
|
||||
valid = await validate_complexity_router_config(
|
||||
ComplexityRouterConfigValidationRequest(
|
||||
complexity_router_config={
|
||||
"tiers": {"CASUAL": "m1", "AUDIT": "m2"},
|
||||
"tier_definitions": [
|
||||
{"name": "CASUAL", "description": "casual chat"},
|
||||
{"name": "AUDIT", "description": "security audits"},
|
||||
],
|
||||
"fallback_tier": "AUDIT",
|
||||
"classifier_type": "llm",
|
||||
"classifier_llm_config": {"model": "clf"},
|
||||
}
|
||||
),
|
||||
ADMIN,
|
||||
)
|
||||
assert valid.valid is True
|
||||
assert valid.error is None
|
||||
|
||||
rejected = await validate_complexity_router_config(
|
||||
ComplexityRouterConfigValidationRequest(
|
||||
complexity_router_config={
|
||||
"tiers": {"CASUAL": "m1", "AUDIT": "m2"},
|
||||
"tier_definitions": [
|
||||
{"name": "CASUAL", "description": "casual chat"},
|
||||
{"name": "AUDIT", "description": "security\naudits"},
|
||||
],
|
||||
"fallback_tier": "AUDIT",
|
||||
"classifier_type": "llm",
|
||||
"classifier_llm_config": {"model": "clf"},
|
||||
}
|
||||
),
|
||||
ADMIN,
|
||||
)
|
||||
assert rejected.valid is False
|
||||
assert rejected.error is not None and "newline" in rejected.error
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_test_never_confirms_models_the_caller_cannot_use(monkeypatch: pytest.MonkeyPatch):
|
||||
"""routed_model_configured must not be an existence oracle for the whole proxy: a team
|
||||
admin probing a guessed global model name reads False unless the named team could
|
||||
actually use that model, and True once the team grants it."""
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
def _team_prisma(team_id: str, models: list[str]) -> MagicMock:
|
||||
row_data = {
|
||||
"team_id": team_id,
|
||||
"members_with_roles": [{"role": "admin", "user_id": "team-admin"}],
|
||||
"models": models,
|
||||
}
|
||||
team_row = MagicMock()
|
||||
team_row.model_dump.return_value = row_data
|
||||
team_row.dict.return_value = row_data
|
||||
prisma = MagicMock()
|
||||
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
|
||||
return prisma
|
||||
|
||||
def _request(team_id: str) -> AutoRouterRoutingTestRequest:
|
||||
return AutoRouterRoutingTestRequest.model_validate(
|
||||
{
|
||||
"prompt": "what is 2+2",
|
||||
"complexity_router_config": {"tiers": TIERS, "classifier_type": "heuristic"},
|
||||
"team_id": team_id,
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(proxy_server, "premium_user", True)
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _router())
|
||||
|
||||
team_admin: Final = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-team", user_id="team-admin"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", _team_prisma("team-probe", models=["mid-model"]))
|
||||
probing = await preview_auto_router_routing(data=_request("team-probe"), user_api_key_dict=team_admin)
|
||||
assert probing.routed_model == "cheap-model"
|
||||
assert probing.routed_model_configured is False
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", _team_prisma("team-grant", models=["cheap-model"]))
|
||||
granted = await preview_auto_router_routing(data=_request("team-grant"), user_api_key_dict=team_admin)
|
||||
assert granted.routed_model == "cheap-model"
|
||||
assert granted.routed_model_configured is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_config_gates_like_the_write_it_rehearses(monkeypatch: pytest.MonkeyPatch):
|
||||
"""A caller who could not save the router must not get the dry run either: matching
|
||||
/model/new, a team admin passes only when naming their own team, and a caller who is
|
||||
neither proxy admin nor team admin is rejected before validation runs."""
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy.management_endpoints.auto_router_endpoints import (
|
||||
validate_complexity_router_config,
|
||||
)
|
||||
from litellm.types.management_endpoints.auto_router_endpoints import (
|
||||
ComplexityRouterConfigValidationRequest,
|
||||
)
|
||||
|
||||
config: Final = {"tiers": {"SIMPLE": "m1"}, "classifier_type": "heuristic"}
|
||||
|
||||
with pytest.raises(HTTPException) as forbidden:
|
||||
await validate_complexity_router_config(
|
||||
ComplexityRouterConfigValidationRequest(complexity_router_config=config), VIEWER
|
||||
)
|
||||
assert forbidden.value.status_code == 403
|
||||
|
||||
team_row: Final = MagicMock()
|
||||
team_row.model_dump.return_value = {
|
||||
"team_id": "team-1",
|
||||
"members_with_roles": [{"role": "admin", "user_id": "team-admin"}],
|
||||
}
|
||||
prisma: Final = MagicMock()
|
||||
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_row)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
|
||||
monkeypatch.setattr(proxy_server, "premium_user", True)
|
||||
|
||||
team_admin: Final = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-team", user_id="team-admin"
|
||||
)
|
||||
verdict = await validate_complexity_router_config(
|
||||
ComplexityRouterConfigValidationRequest(complexity_router_config=config, team_id="team-1"),
|
||||
team_admin,
|
||||
)
|
||||
assert verdict.valid is True
|
||||
|
||||
with pytest.raises(HTTPException) as not_their_team:
|
||||
await validate_complexity_router_config(
|
||||
ComplexityRouterConfigValidationRequest(complexity_router_config=config, team_id="team-1"),
|
||||
UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, api_key="sk-other", user_id="someone-else"),
|
||||
)
|
||||
assert not_their_team.value.status_code == 403
|
||||
|
||||
|
||||
def test_every_shadow_eval_sql_constant_speaks_naive_utc():
|
||||
"""The tables store naive UTC wall time (prisma's convention), so SQL-side time must be
|
||||
NOW() AT TIME ZONE 'utc' and python-side params must cast ::timestamp; a bare NOW() or a
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3516,3 +3516,245 @@ def test_create_file_non_batch_purpose_skips_batch_validation(monkeypatch, llm_r
|
|||
|
||||
assert response.status_code == 200, response.text
|
||||
assert len(forwarded_calls) == 1
|
||||
|
||||
|
||||
def _batch_upload(client_, content: bytes, purpose: str = "batch"):
|
||||
return client_.post(
|
||||
"/v1/files",
|
||||
files={"file": ("batch.jsonl", content, "application/jsonl")},
|
||||
data={"purpose": purpose},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content, purpose, expected_status, expected_fragment",
|
||||
[
|
||||
(
|
||||
b'{"custom_id":"r-0","method":"POST","url":"/v1/chat/completions",'
|
||||
b'"body":{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"hi"}]}}\n',
|
||||
"batch",
|
||||
200,
|
||||
None,
|
||||
),
|
||||
(
|
||||
b'{"custom_id":"r-0","method":"POST","url":"/v1/chat/completions",'
|
||||
b'"body":{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"leak me"}]}}\n',
|
||||
"batch",
|
||||
200,
|
||||
None,
|
||||
),
|
||||
(
|
||||
b'{"custom_id":"r-0","method":"POST","url":"/v1/chat/completions",'
|
||||
b'"body":{"model":"gpt-3.5-turbo","messages":[{"role":"user","content":"leak me"}]}}\n',
|
||||
"assistants",
|
||||
200,
|
||||
None,
|
||||
),
|
||||
(b"{ not json\n", "batch", 400, "line 1"),
|
||||
],
|
||||
)
|
||||
def test_batch_upload_runs_guardrails_on_each_record(
|
||||
monkeypatch, llm_router: Router, content, purpose, expected_status, expected_fragment
|
||||
):
|
||||
"""POST /v1/files with purpose=batch must reach the guardrail chain; other purposes must not."""
|
||||
import litellm
|
||||
import litellm.proxy.openai_files_endpoints.files_endpoints as fe
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
class _Redactor(CustomGuardrail):
|
||||
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
|
||||
for message in data.get("messages") or []:
|
||||
if isinstance(message.get("content"), str) and "leak" in message["content"]:
|
||||
message["content"] = "***"
|
||||
return data
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router)
|
||||
setup_proxy_logging_object(monkeypatch, llm_router)
|
||||
monkeypatch.setattr(litellm, "callbacks", [_Redactor(guardrail_name="g", default_on=True)])
|
||||
ProxyLogging._callback_capabilities_cache.clear()
|
||||
|
||||
async def fake_route_create_file(**kwargs):
|
||||
return OpenAIFileObject(
|
||||
id="dummy-id",
|
||||
object="file",
|
||||
bytes=0,
|
||||
created_at=1234567890,
|
||||
filename="batch.jsonl",
|
||||
purpose="batch",
|
||||
status="uploaded",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(fe, "route_create_file", fake_route_create_file)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user"
|
||||
)
|
||||
try:
|
||||
resp = _batch_upload(client, content, purpose)
|
||||
assert resp.status_code == expected_status, resp.text
|
||||
if expected_fragment is not None:
|
||||
assert expected_fragment in resp.text
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
ProxyLogging._callback_capabilities_cache.clear()
|
||||
|
||||
|
||||
def test_batch_upload_redacts_per_record(monkeypatch, llm_router: Router):
|
||||
"""An offending record is submitted masked, matching what the online path does per request."""
|
||||
expected_custom_ids = ["keep-1", "dirty", "keep-2"]
|
||||
import json as _json
|
||||
|
||||
import litellm
|
||||
import litellm.proxy.openai_files_endpoints.files_endpoints as fe
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
class _Redactor(CustomGuardrail):
|
||||
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
|
||||
for message in data.get("messages") or []:
|
||||
if isinstance(message.get("content"), str) and "leak" in message["content"]:
|
||||
message["content"] = message["content"].replace("leak", "***")
|
||||
return data
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router)
|
||||
setup_proxy_logging_object(monkeypatch, llm_router)
|
||||
monkeypatch.setattr(litellm, "callbacks", [_Redactor(guardrail_name="g", default_on=True)])
|
||||
ProxyLogging._callback_capabilities_cache.clear()
|
||||
|
||||
uploaded = {}
|
||||
|
||||
async def fake_route_create_file(**kwargs):
|
||||
handle = kwargs["_create_file_request"]["file"][1]
|
||||
uploaded["body"] = handle.read() if hasattr(handle, "read") else handle
|
||||
return OpenAIFileObject(
|
||||
id="dummy-id",
|
||||
object="file",
|
||||
bytes=0,
|
||||
created_at=1234567890,
|
||||
filename="batch.jsonl",
|
||||
purpose="batch",
|
||||
status="uploaded",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(fe, "route_create_file", fake_route_create_file)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user"
|
||||
)
|
||||
|
||||
def _row(custom_id, content):
|
||||
return _json.dumps(
|
||||
{
|
||||
"custom_id": custom_id,
|
||||
"method": "POST",
|
||||
"url": "/v1/chat/completions",
|
||||
"body": {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": content}]},
|
||||
}
|
||||
)
|
||||
|
||||
content = ("\n".join([_row("keep-1", "fine"), _row("dirty", "please leak this"), _row("keep-2", "fine")])).encode()
|
||||
try:
|
||||
resp = client.post(
|
||||
"/v1/files",
|
||||
files={"file": ("batch.jsonl", content, "application/jsonl")},
|
||||
data={"purpose": "batch"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
rows = [_json.loads(line) for line in uploaded["body"].decode().splitlines()]
|
||||
assert [row["custom_id"] for row in rows] == expected_custom_ids
|
||||
assert rows[1]["body"]["messages"][0]["content"] == "please *** this"
|
||||
report = resp.json()["litellm_batch_guardrail"]
|
||||
assert report["submitted_records"] == 3
|
||||
assert report["modified_records"] == [
|
||||
{"line": 2, "custom_id": "dirty", "action": "redacted", "guardrail": None}
|
||||
]
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
ProxyLogging._callback_capabilities_cache.clear()
|
||||
|
||||
|
||||
def test_batch_upload_closes_the_spools_it_opened(monkeypatch, llm_router: Router):
|
||||
"""The scan and the rewrite each open a spool; the request owns both and must not leak them."""
|
||||
import json as _json
|
||||
|
||||
import litellm
|
||||
import litellm.proxy.openai_files_endpoints.batch_guardrails as bg
|
||||
import litellm.proxy.openai_files_endpoints.files_endpoints as fe
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
from litellm.proxy.utils import ProxyLogging
|
||||
|
||||
class _Redactor(CustomGuardrail):
|
||||
async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type):
|
||||
for message in data.get("messages") or []:
|
||||
if "leak" in (message.get("content") or ""):
|
||||
message["content"] = message["content"].replace("leak", "***")
|
||||
return data
|
||||
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", llm_router)
|
||||
setup_proxy_logging_object(monkeypatch, llm_router)
|
||||
monkeypatch.setattr(litellm, "callbacks", [_Redactor(guardrail_name="g", default_on=True)])
|
||||
ProxyLogging._callback_capabilities_cache.clear()
|
||||
|
||||
spools = []
|
||||
real = bg.tempfile.SpooledTemporaryFile
|
||||
|
||||
def _tracking(*args, **kwargs):
|
||||
handle = real(*args, **kwargs)
|
||||
spools.append(handle)
|
||||
return handle
|
||||
|
||||
monkeypatch.setattr(bg.tempfile, "SpooledTemporaryFile", _tracking)
|
||||
|
||||
async def fake_route_create_file(**kwargs):
|
||||
return OpenAIFileObject(
|
||||
id="dummy-id",
|
||||
object="file",
|
||||
bytes=0,
|
||||
created_at=1234567890,
|
||||
filename="batch.jsonl",
|
||||
purpose="batch",
|
||||
status="uploaded",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(fe, "route_create_file", fake_route_create_file)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test-user"
|
||||
)
|
||||
|
||||
def _row(custom_id, content):
|
||||
return _json.dumps(
|
||||
{
|
||||
"custom_id": custom_id,
|
||||
"method": "POST",
|
||||
"url": "/v1/chat/completions",
|
||||
"body": {"model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": content}]},
|
||||
}
|
||||
)
|
||||
|
||||
content = ("\n".join([_row("keep", "fine"), _row("dirty", "please leak this")])).encode()
|
||||
try:
|
||||
resp = client.post(
|
||||
"/v1/files",
|
||||
files={"file": ("batch.jsonl", content, "application/jsonl")},
|
||||
data={"purpose": "batch"},
|
||||
headers={"Authorization": "Bearer test-key"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert len(spools) == 2, f"expected a scan spool and a rewrite spool, saw {len(spools)}"
|
||||
assert all(handle.closed for handle in spools), "the request must close every spool it opened"
|
||||
finally:
|
||||
app.dependency_overrides.pop(ps.user_api_key_auth, None)
|
||||
ProxyLogging._callback_capabilities_cache.clear()
|
||||
|
|
|
|||
|
|
@ -225,8 +225,8 @@ def test_get_image_without_theme_still_serves_the_light_jpeg(client, monkeypatch
|
|||
|
||||
|
||||
def test_get_image_dark_theme_keeps_serving_a_custom_ui_logo(client, monkeypatch, tmp_path):
|
||||
"""A custom UI_LOGO_PATH has no dark variant yet, so dark mode must fall back to the
|
||||
admin's own logo rather than replacing it with LiteLLM's."""
|
||||
"""With no UI_LOGO_PATH_DARK set, dark mode falls back to the admin's own light logo
|
||||
rather than replacing their branding with LiteLLM's."""
|
||||
custom_logo = tmp_path / "custom.png"
|
||||
custom_logo.write_bytes(PNG_SIGNATURE + b"custom-logo-marker")
|
||||
monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo))
|
||||
|
|
@ -235,6 +235,64 @@ def test_get_image_dark_theme_keeps_serving_a_custom_ui_logo(client, monkeypatch
|
|||
assert shape == {"status": 200, "body": PNG_SIGNATURE + b"custom-logo-marker"}
|
||||
|
||||
|
||||
def test_get_image_dark_theme_prefers_the_dark_custom_logo(client, monkeypatch, tmp_path):
|
||||
"""UI_LOGO_PATH_DARK outranks UI_LOGO_PATH when the dark logo is requested."""
|
||||
light_logo = tmp_path / "light.png"
|
||||
light_logo.write_bytes(PNG_SIGNATURE + b"light-marker")
|
||||
dark_logo = tmp_path / "dark.png"
|
||||
dark_logo.write_bytes(PNG_SIGNATURE + b"dark-marker")
|
||||
monkeypatch.setenv("UI_LOGO_PATH", str(light_logo))
|
||||
monkeypatch.setenv("UI_LOGO_PATH_DARK", str(dark_logo))
|
||||
|
||||
response = client.get("/get_image", params={"theme": "dark"})
|
||||
|
||||
shape = {"status": response.status_code, "body": response.content}
|
||||
assert shape == {"status": 200, "body": PNG_SIGNATURE + b"dark-marker"}
|
||||
|
||||
|
||||
def test_get_image_unusable_dark_logo_falls_back_to_the_light_custom_logo(client, monkeypatch, tmp_path):
|
||||
"""A broken UI_LOGO_PATH_DARK must not drop the admin all the way to LiteLLM's own
|
||||
logo while their light logo is still perfectly serviceable."""
|
||||
light_logo = tmp_path / "light.png"
|
||||
light_logo.write_bytes(PNG_SIGNATURE + b"light-marker")
|
||||
monkeypatch.setenv("UI_LOGO_PATH", str(light_logo))
|
||||
monkeypatch.setenv("UI_LOGO_PATH_DARK", str(tmp_path / "missing.png"))
|
||||
|
||||
response = client.get("/get_image", params={"theme": "dark"})
|
||||
|
||||
shape = {"status": response.status_code, "body": response.content}
|
||||
assert shape == {"status": 200, "body": PNG_SIGNATURE + b"light-marker"}
|
||||
|
||||
|
||||
def test_get_image_light_theme_ignores_the_dark_custom_logo(client, monkeypatch, tmp_path):
|
||||
"""The dark logo must never leak into a light-mode request."""
|
||||
light_logo = tmp_path / "light.png"
|
||||
light_logo.write_bytes(PNG_SIGNATURE + b"light-marker")
|
||||
dark_logo = tmp_path / "dark.png"
|
||||
dark_logo.write_bytes(PNG_SIGNATURE + b"dark-marker")
|
||||
monkeypatch.setenv("UI_LOGO_PATH", str(light_logo))
|
||||
monkeypatch.setenv("UI_LOGO_PATH_DARK", str(dark_logo))
|
||||
|
||||
response = client.get("/get_image")
|
||||
|
||||
shape = {"status": response.status_code, "body": response.content}
|
||||
assert shape == {"status": 200, "body": PNG_SIGNATURE + b"light-marker"}
|
||||
|
||||
|
||||
def test_get_image_dark_logo_alone_still_serves_the_bundled_light_logo_in_light_mode(client, monkeypatch):
|
||||
"""Setting only UI_LOGO_PATH_DARK leaves light mode on the bundled default."""
|
||||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||||
monkeypatch.setenv("UI_LOGO_PATH_DARK", "https://cdn.example.invalid/logo-dark.png")
|
||||
|
||||
response = client.get("/get_image")
|
||||
|
||||
shape = {
|
||||
"status": response.status_code,
|
||||
"media_type": response.headers.get("content-type", "").split(";")[0],
|
||||
}
|
||||
assert shape == {"status": 200, "media_type": "image/jpeg"}
|
||||
|
||||
|
||||
def test_get_image_redirects_remote_url(client, monkeypatch):
|
||||
"""Remote logo URLs are served via redirect — the proxy never fetches them server-side."""
|
||||
monkeypatch.setenv("UI_LOGO_PATH", "https://example.invalid/logo.png")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import copy
|
|||
import datetime
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from typing import AsyncGenerator, Callable, Optional
|
||||
from typing import AsyncGenerator, Callable, Final, Optional
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
|
|
|||
|
|
@ -1064,11 +1064,15 @@ class TestProxySettingEndpoints:
|
|||
assert mock_proxy_config["save_call_count"]() == 1
|
||||
|
||||
# env vars are persisted through the dedicated per-key path, and ONLY
|
||||
# the two keys this endpoint owns are touched. The unrelated SSO env
|
||||
# the keys this endpoint owns are touched. The unrelated SSO env
|
||||
# vars in the merged config are never snapshotted.
|
||||
env_updates = mock_proxy_config["env_updates"]()
|
||||
assert env_updates == [
|
||||
{"UI_LOGO_PATH": "https://example.com/new-logo.png", "LITELLM_FAVICON_URL": None}
|
||||
{
|
||||
"UI_LOGO_PATH": "https://example.com/new-logo.png",
|
||||
"UI_LOGO_PATH_DARK": None,
|
||||
"LITELLM_FAVICON_URL": None,
|
||||
}
|
||||
]
|
||||
|
||||
def test_update_ui_theme_settings_with_favicon(
|
||||
|
|
@ -1097,14 +1101,90 @@ class TestProxySettingEndpoints:
|
|||
|
||||
assert os.environ["UI_LOGO_PATH"] == "https://example.com/new-logo.png"
|
||||
assert os.environ["LITELLM_FAVICON_URL"] == "https://example.com/custom-favicon.ico"
|
||||
# Only the two owned keys are persisted, both with their new values
|
||||
# Only the owned keys are persisted, each with its new value
|
||||
assert mock_proxy_config["env_updates"]() == [
|
||||
{
|
||||
"UI_LOGO_PATH": "https://example.com/new-logo.png",
|
||||
"UI_LOGO_PATH_DARK": None,
|
||||
"LITELLM_FAVICON_URL": "https://example.com/custom-favicon.ico",
|
||||
}
|
||||
]
|
||||
|
||||
def test_update_ui_theme_settings_with_dark_logo(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""A dark-mode logo is stored and applied to the live process like the light one."""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
|
||||
new_theme = {
|
||||
"logo_url": "https://example.com/logo.png",
|
||||
"logo_url_dark": "https://example.com/logo-dark.png",
|
||||
}
|
||||
|
||||
response = client.patch("/update/ui_theme_settings", json=new_theme)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["theme_config"]["logo_url_dark"] == "https://example.com/logo-dark.png"
|
||||
assert os.environ["UI_LOGO_PATH_DARK"] == "https://example.com/logo-dark.png"
|
||||
assert mock_proxy_config["env_updates"]() == [
|
||||
{
|
||||
"UI_LOGO_PATH": "https://example.com/logo.png",
|
||||
"UI_LOGO_PATH_DARK": "https://example.com/logo-dark.png",
|
||||
"LITELLM_FAVICON_URL": None,
|
||||
}
|
||||
]
|
||||
|
||||
def test_update_ui_theme_settings_rejects_local_path_dark_logo(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""The dark logo is served by the unauthenticated /get_image, so a local
|
||||
filesystem path must be refused exactly as it is for the light logo."""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
|
||||
response = client.patch(
|
||||
"/update/ui_theme_settings",
|
||||
json={"logo_url_dark": "/etc/passwd"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "logo_url_dark" in str(response.json())
|
||||
|
||||
def test_update_ui_theme_settings_persists_every_env_var_it_resolves(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
"""Read and write must cover the same env vars.
|
||||
|
||||
/get/ui_theme_settings resolves each field through _UI_THEME_FIELD_ENV_VARS,
|
||||
so a var missing from the update path would read back from an env value the
|
||||
save never cleared, and the settings page would show a field it cannot unset.
|
||||
"""
|
||||
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
|
||||
_UI_THEME_FIELD_ENV_VARS,
|
||||
)
|
||||
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test_salt_key")
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||||
|
||||
response = client.patch("/update/ui_theme_settings", json={})
|
||||
|
||||
assert response.status_code == 200
|
||||
persisted = mock_proxy_config["env_updates"]()
|
||||
assert len(persisted) == 1
|
||||
assert set(persisted[0]) == set(_UI_THEME_FIELD_ENV_VARS.values())
|
||||
|
||||
def test_get_ui_theme_settings_surfaces_dark_logo_from_process_env(
|
||||
self, mock_proxy_config, monkeypatch
|
||||
):
|
||||
"""A dark logo supplied only as a process env var must surface in the read."""
|
||||
monkeypatch.setenv("UI_LOGO_PATH_DARK", "https://cdn.example.com/logo-dark.png")
|
||||
|
||||
response = client.get("/get/ui_theme_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["values"]["logo_url_dark"] == "https://cdn.example.com/logo-dark.png"
|
||||
|
||||
def test_update_ui_theme_settings_clear_favicon(
|
||||
self, mock_proxy_config, mock_auth, monkeypatch
|
||||
):
|
||||
|
|
|
|||
|
|
@ -166,3 +166,123 @@ async def test_pre_call_hook_processes_guardrail_metadata_when_no_overrides(prox
|
|||
)
|
||||
assert out is data
|
||||
assert invoked["data"] is data
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guardrails_only_skips_non_guardrail_pre_call_callbacks(
|
||||
proxy_logging, make_user_api_key_auth, monkeypatch
|
||||
):
|
||||
"""Rate limiters and budget hooks ride this same loop; a content scan must not trip them."""
|
||||
calls: list[str] = []
|
||||
|
||||
class _RateLimiterLike(CustomLogger):
|
||||
async def async_pre_call_hook(self, **kwargs): # type: ignore[override]
|
||||
calls.append("ran")
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(litellm, "callbacks", [_RateLimiterLike()])
|
||||
proxy_logging.slack_alerting_instance = MagicMock(alerting=None)
|
||||
|
||||
await proxy_logging.pre_call_hook(
|
||||
user_api_key_dict=make_user_api_key_auth(),
|
||||
data={"messages": [{"x": "input"}], "model": "m"},
|
||||
call_type="completion",
|
||||
guardrails_only=True,
|
||||
)
|
||||
assert calls == []
|
||||
|
||||
await proxy_logging.pre_call_hook(
|
||||
user_api_key_dict=make_user_api_key_auth(),
|
||||
data={"messages": [{"x": "input"}], "model": "m"},
|
||||
call_type="completion",
|
||||
)
|
||||
assert calls == ["ran"], "the default path must still run non-guardrail pre-call callbacks"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guardrails_only_skips_the_hanging_request_alert(proxy_logging, make_user_api_key_auth, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
alerting = MagicMock(alerting=True)
|
||||
proxy_logging.slack_alerting_instance = alerting
|
||||
|
||||
await proxy_logging.pre_call_hook(
|
||||
user_api_key_dict=make_user_api_key_auth(),
|
||||
data={"messages": [{"x": "input"}], "model": "m"},
|
||||
call_type="completion",
|
||||
guardrails_only=True,
|
||||
)
|
||||
alerting.response_taking_too_long.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_guardrails_only_skips_prompt_template_rewriting(proxy_logging, make_user_api_key_auth, monkeypatch):
|
||||
"""A prompt template would rewrite messages, which a per-record content diff would misread."""
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
proxy_logging.slack_alerting_instance = MagicMock(alerting=None)
|
||||
process = AsyncMock()
|
||||
monkeypatch.setattr(proxy_logging, "_process_prompt_template", process)
|
||||
|
||||
await proxy_logging.pre_call_hook(
|
||||
user_api_key_dict=make_user_api_key_auth(),
|
||||
data={"messages": [{"x": "input"}], "model": "m", "prompt_id": "p1", "litellm_logging_obj": MagicMock()},
|
||||
call_type="acompletion",
|
||||
guardrails_only=True,
|
||||
)
|
||||
process.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event_hook, expected",
|
||||
[("pre_call", True), ("post_call", False), ("during_call", False)],
|
||||
)
|
||||
def test_has_pre_call_guardrails_follows_the_guardrail_event_hook(proxy_logging, monkeypatch, event_hook, expected):
|
||||
"""A post-call-only guardrail must not make callers pay for pre-call work."""
|
||||
from litellm.integrations.custom_guardrail import CustomGuardrail
|
||||
|
||||
guardrail = CustomGuardrail(guardrail_name="g", event_hook=event_hook, default_on=True)
|
||||
monkeypatch.setattr(litellm, "callbacks", [guardrail])
|
||||
|
||||
assert proxy_logging.has_pre_call_guardrails({}) is expected
|
||||
|
||||
|
||||
def test_has_pre_call_guardrails_is_false_without_callbacks(proxy_logging, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
|
||||
assert proxy_logging.has_pre_call_guardrails({}) is False
|
||||
|
||||
|
||||
def test_has_pre_call_guardrails_is_true_for_a_configured_pipeline(proxy_logging, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
|
||||
assert proxy_logging.has_pre_call_guardrails({"_guardrail_pipelines": ["p1"]}) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_path_still_arms_the_hanging_request_alert(proxy_logging, make_user_api_key_auth, monkeypatch):
|
||||
"""Pins the other side of the gate: without the flag, the alert must still fire."""
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
alerting = MagicMock(alerting=True)
|
||||
alerting.response_taking_too_long = AsyncMock()
|
||||
proxy_logging.slack_alerting_instance = alerting
|
||||
|
||||
await proxy_logging.pre_call_hook(
|
||||
user_api_key_dict=make_user_api_key_auth(),
|
||||
data={"messages": [{"x": "input"}], "model": "m"},
|
||||
call_type="completion",
|
||||
)
|
||||
alerting.response_taking_too_long.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_path_still_applies_prompt_templates(proxy_logging, make_user_api_key_auth, monkeypatch):
|
||||
monkeypatch.setattr(litellm, "callbacks", [])
|
||||
proxy_logging.slack_alerting_instance = MagicMock(alerting=None)
|
||||
process = AsyncMock()
|
||||
monkeypatch.setattr(proxy_logging, "_process_prompt_template", process)
|
||||
|
||||
await proxy_logging.pre_call_hook(
|
||||
user_api_key_dict=make_user_api_key_auth(),
|
||||
data={"messages": [{"x": "input"}], "model": "m", "prompt_id": "p1", "litellm_logging_obj": MagicMock()},
|
||||
call_type="acompletion",
|
||||
)
|
||||
process.assert_awaited_once()
|
||||
|
|
|
|||
11
ui/litellm-dashboard/package-lock.json
generated
11
ui/litellm-dashboard/package-lock.json
generated
|
|
@ -24,6 +24,7 @@
|
|||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.9.4",
|
||||
"openai": "4.104.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
|
|
@ -9805,6 +9806,16 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/next-themes": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
|
||||
"integrity": "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/node-domexception": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@
|
|||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.2.11",
|
||||
"next-themes": "^0.4.6",
|
||||
"nuqs": "^2.9.4",
|
||||
"openai": "4.104.0",
|
||||
"openapi-fetch": "^0.17.0",
|
||||
|
|
|
|||
|
|
@ -7,10 +7,18 @@ import { toast } from "@/lib/toast";
|
|||
import UIThemeSettings from "./UIThemeSettings";
|
||||
|
||||
const setLogoUrl = vi.fn();
|
||||
const setLogoUrlDark = vi.fn();
|
||||
const setFaviconUrl = vi.fn();
|
||||
|
||||
vi.mock("@/contexts/ThemeContext", () => ({
|
||||
useTheme: () => ({ logoUrl: null, setLogoUrl, faviconUrl: null, setFaviconUrl }),
|
||||
useTheme: () => ({
|
||||
logoUrl: null,
|
||||
setLogoUrl,
|
||||
logoUrlDark: null,
|
||||
setLogoUrlDark,
|
||||
faviconUrl: null,
|
||||
setFaviconUrl,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
|
|
@ -19,6 +27,7 @@ vi.mock("@/components/networking", () => ({
|
|||
}));
|
||||
|
||||
const LOGO_PLACEHOLDER = "https://example.com/logo.png";
|
||||
const LOGO_DARK_PLACEHOLDER = "https://example.com/logo-dark.png";
|
||||
const FAVICON_PLACEHOLDER = "https://example.com/favicon.ico";
|
||||
|
||||
const okResponse = (values: Record<string, string | null> = {}) =>
|
||||
|
|
@ -76,11 +85,32 @@ describe("UIThemeSettings", () => {
|
|||
await waitFor(() => expect(patchCalls()).toHaveLength(1));
|
||||
expect(bodyOf(patchCalls()[0])).toEqual({
|
||||
logo_url: "https://a.test/logo.png",
|
||||
logo_url_dark: null,
|
||||
favicon_url: "https://a.test/fav.ico",
|
||||
});
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Theme settings updated successfully!"));
|
||||
});
|
||||
|
||||
it("should load and save a separate dark-mode logo url", async () => {
|
||||
const user = userEvent.setup();
|
||||
fetchMock.mockImplementation(() => okResponse({ logo_url_dark: "https://cdn.example.com/logo-dark.svg" }));
|
||||
|
||||
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText(LOGO_DARK_PLACEHOLDER)).toHaveValue("https://cdn.example.com/logo-dark.svg");
|
||||
});
|
||||
expect(setLogoUrlDark).toHaveBeenCalledWith("https://cdn.example.com/logo-dark.svg");
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(LOGO_DARK_PLACEHOLDER), {
|
||||
target: { value: "https://a.test/logo-dark.png" },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Save Changes" }));
|
||||
|
||||
await waitFor(() => expect(patchCalls()).toHaveLength(1));
|
||||
expect(bodyOf(patchCalls()[0]).logo_url_dark).toBe("https://a.test/logo-dark.png");
|
||||
});
|
||||
|
||||
it("should surface a backend failure when saving fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<UIThemeSettings userID="user-1" userRole="Admin" accessToken="sk-test" />);
|
||||
|
|
@ -109,10 +139,12 @@ describe("UIThemeSettings", () => {
|
|||
await user.click(screen.getByRole("button", { name: "Reset to Default" }));
|
||||
|
||||
await waitFor(() => expect(patchCalls()).toHaveLength(1));
|
||||
expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, favicon_url: null });
|
||||
expect(bodyOf(patchCalls()[0])).toEqual({ logo_url: null, logo_url_dark: null, favicon_url: null });
|
||||
expect(screen.getByPlaceholderText(LOGO_PLACEHOLDER)).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText(LOGO_DARK_PLACEHOLDER)).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText(FAVICON_PLACEHOLDER)).toHaveValue("");
|
||||
expect(setLogoUrl).toHaveBeenLastCalledWith(null);
|
||||
expect(setLogoUrlDark).toHaveBeenLastCalledWith(null);
|
||||
expect(setFaviconUrl).toHaveBeenLastCalledWith(null);
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Theme settings reset to default!"));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,8 +15,9 @@ interface UIThemeSettingsProps {
|
|||
}
|
||||
|
||||
const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, accessToken }) => {
|
||||
const { setLogoUrl, setFaviconUrl } = useTheme();
|
||||
const { setLogoUrl, setLogoUrlDark, setFaviconUrl } = useTheme();
|
||||
const [logoUrlInput, setLogoUrlInput] = useState<string>("");
|
||||
const [logoUrlDarkInput, setLogoUrlDarkInput] = useState<string>("");
|
||||
const [faviconUrlInput, setFaviconUrlInput] = useState<string>("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
|
|
@ -40,8 +41,10 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
|
|||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setLogoUrlInput(data.values?.logo_url || "");
|
||||
setLogoUrlDarkInput(data.values?.logo_url_dark || "");
|
||||
setFaviconUrlInput(data.values?.favicon_url || "");
|
||||
setLogoUrl(data.values?.logo_url || null);
|
||||
setLogoUrlDark(data.values?.logo_url_dark || null);
|
||||
setFaviconUrl(data.values?.favicon_url || null);
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -62,12 +65,14 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
|
|||
},
|
||||
body: JSON.stringify({
|
||||
logo_url: logoUrlInput || null,
|
||||
logo_url_dark: logoUrlDarkInput || null,
|
||||
favicon_url: faviconUrlInput || null,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success("Theme settings updated successfully!");
|
||||
setLogoUrl(logoUrlInput || null);
|
||||
setLogoUrlDark(logoUrlDarkInput || null);
|
||||
setFaviconUrl(faviconUrlInput || null);
|
||||
} else {
|
||||
throw new Error("Failed to update settings");
|
||||
|
|
@ -82,8 +87,10 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
|
|||
|
||||
const handleReset = async () => {
|
||||
setLogoUrlInput("");
|
||||
setLogoUrlDarkInput("");
|
||||
setFaviconUrlInput("");
|
||||
setLogoUrl(null);
|
||||
setLogoUrlDark(null);
|
||||
setFaviconUrl(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
|
|
@ -95,7 +102,7 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
|
|||
[getGlobalLitellmHeaderName()]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ logo_url: null, favicon_url: null }),
|
||||
body: JSON.stringify({ logo_url: null, logo_url_dark: null, favicon_url: null }),
|
||||
});
|
||||
if (response.ok) {
|
||||
toast.success("Theme settings reset to default!");
|
||||
|
|
@ -141,6 +148,23 @@ const UIThemeSettings: React.FC<UIThemeSettingsProps> = ({ userID, userRole, acc
|
|||
Enter a URL for your custom logo or leave empty for default
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ui-theme-logo-url-dark" className="mb-2">
|
||||
Custom Logo URL (dark mode)
|
||||
</Label>
|
||||
<Input
|
||||
id="ui-theme-logo-url-dark"
|
||||
placeholder="https://example.com/logo-dark.png"
|
||||
value={logoUrlDarkInput}
|
||||
onChange={(event) => {
|
||||
setLogoUrlDarkInput(event.target.value);
|
||||
setLogoUrlDark(event.target.value || null);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Enter a URL for a logo suited to dark backgrounds, or leave empty to reuse the logo above
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ui-theme-favicon-url" className="mb-2">
|
||||
Custom Favicon URL
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
|
|||
import "./globals.css";
|
||||
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import ReactQueryProvider from "@/contexts/ReactQueryProvider";
|
||||
|
|
@ -22,14 +23,18 @@ export default function RootLayout({
|
|||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
// next-themes stamps the theme class on <html> before paint, which the exported markup
|
||||
// cannot predict; suppressHydrationWarning confines that mismatch to this element.
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<NuqsAdapter>
|
||||
<ReactQueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<Toaster />
|
||||
</ReactQueryProvider>
|
||||
</NuqsAdapter>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
|
||||
<NuqsAdapter>
|
||||
<ReactQueryProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
<Toaster />
|
||||
</ReactQueryProvider>
|
||||
</NuqsAdapter>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { BlogDropdown } from "@/components/Navbar/BlogDropdown/BlogDropdown";
|
|||
import { CommunityEngagementButtons } from "@/components/Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
|
||||
import { NotificationsBell } from "@/components/Navbar/NotificationsBell/NotificationsBell";
|
||||
import ViewSwitcher from "@/components/Navbar/ViewSwitcher";
|
||||
import ThemeToggle from "@/components/ThemeToggle/ThemeToggle";
|
||||
import WorkerDropdown from "@/components/Navbar/WorkerDropdown/WorkerDropdown";
|
||||
import { useWorker } from "@/hooks/useWorker";
|
||||
import { useDisableShowPrompts } from "@/app/(dashboard)/hooks/useDisableShowPrompts";
|
||||
|
|
@ -73,6 +74,7 @@ export function DashboardHeader({ page }: DashboardHeaderProps) {
|
|||
<BlogDropdown />
|
||||
{!hideCommunityLinks && <CommunityEngagementButtons />}
|
||||
<ToolbarSeparator />
|
||||
<ThemeToggle />
|
||||
<NotificationsBell />
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
|
||||
const renderToggle = () =>
|
||||
render(
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
|
||||
<ThemeToggle />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
|
||||
const openMenu = async () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: "Theme" }));
|
||||
await screen.findByRole("menu");
|
||||
};
|
||||
|
||||
const pick = async (label: string | RegExp) => userEvent.click(screen.getByRole("menuitemradio", { name: label }));
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark", "light");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
document.documentElement.classList.remove("dark", "light");
|
||||
});
|
||||
|
||||
describe("ThemeToggle", () => {
|
||||
it("starts on light rather than following the system preference", async () => {
|
||||
renderToggle();
|
||||
await openMenu();
|
||||
|
||||
expect(screen.getByRole("menuitemradio", { name: "Light" })).toBeChecked();
|
||||
expect(screen.getByRole("menuitemradio", { name: /^Dark/ })).not.toBeChecked();
|
||||
expect(screen.getByRole("menuitemradio", { name: "System" })).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("puts the dark class on the document and remembers the choice", async () => {
|
||||
renderToggle();
|
||||
await openMenu();
|
||||
|
||||
await pick(/^Dark/);
|
||||
|
||||
expect(document.documentElement).toHaveClass("dark");
|
||||
expect(localStorage.getItem("theme")).toBe("dark");
|
||||
});
|
||||
|
||||
it("hands control back to the system preference when asked", async () => {
|
||||
renderToggle();
|
||||
await openMenu();
|
||||
await pick(/^Dark/);
|
||||
|
||||
await pick("System");
|
||||
|
||||
expect(localStorage.getItem("theme")).toBe("system");
|
||||
expect(document.documentElement).not.toHaveClass("dark");
|
||||
});
|
||||
|
||||
it("marks dark as beta in the menu, and leaves the other choices unmarked", async () => {
|
||||
renderToggle();
|
||||
await openMenu();
|
||||
|
||||
expect(screen.getByRole("menuitemradio", { name: /^Dark/ })).toHaveTextContent("Beta");
|
||||
expect(screen.getByRole("menuitemradio", { name: "Light" })).not.toHaveTextContent("Beta");
|
||||
expect(screen.getByRole("menuitemradio", { name: "System" })).not.toHaveTextContent("Beta");
|
||||
});
|
||||
|
||||
it("keeps the beta marker inside the menu rather than in the toolbar", async () => {
|
||||
renderToggle();
|
||||
await openMenu();
|
||||
await pick(/^Dark/);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Theme" })).not.toHaveTextContent("Beta");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import React from "react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const THEMES = [
|
||||
{ value: "system", label: "System", Icon: Monitor, beta: false },
|
||||
{ value: "light", label: "Light", Icon: Sun, beta: false },
|
||||
{ value: "dark", label: "Dark", Icon: Moon, beta: true },
|
||||
] as const;
|
||||
|
||||
const ThemeToggle: React.FC = () => {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon-sm" aria-label="Theme" title="Theme" className="text-muted-foreground" />
|
||||
}
|
||||
>
|
||||
{resolvedTheme === "dark" ? <Moon /> : <Sun />}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuRadioGroup value={theme ?? "light"} onValueChange={setTheme}>
|
||||
{THEMES.map(({ value, label, Icon, beta }) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
<Icon />
|
||||
{label}
|
||||
{beta && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1 py-0 text-[10px] font-medium text-muted-foreground"
|
||||
title="Dark mode is still being rolled out, so some surfaces may not be styled yet"
|
||||
>
|
||||
Beta
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThemeToggle;
|
||||
|
|
@ -14,7 +14,8 @@ export type ComplexityTier = "SIMPLE" | "MEDIUM" | "COMPLEX" | "REASONING";
|
|||
export interface KeywordTierRule {
|
||||
id: string;
|
||||
keywords: string[];
|
||||
tier: ComplexityTier;
|
||||
/** A built-in tier name, or with a custom tier set, one of the defined tier names. */
|
||||
tier: string;
|
||||
}
|
||||
|
||||
interface KeywordTierRulesProps {
|
||||
|
|
@ -99,7 +100,7 @@ const KeywordTierRules: React.FC<KeywordTierRulesProps> = ({ rules, onChange, ti
|
|||
<Select
|
||||
items={tierOptions(tierLabels)}
|
||||
value={rule.tier}
|
||||
onValueChange={(tier: ComplexityTier | null) => tier && updateRule(rule.id, { tier })}
|
||||
onValueChange={(tier: string | null) => tier && updateRule(rule.id, { tier })}
|
||||
>
|
||||
<SelectTrigger aria-label={`Route keyword rule ${index + 1} to tier`} className="w-full">
|
||||
<SelectValue />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hydrateKeywordTierRules, serializeKeywordTierRules } from "./complexity_router_keywords";
|
||||
|
||||
describe("hydrateKeywordTierRules", () => {
|
||||
it("keeps a rule whose tier is operator-defined instead of silently deleting it on edit", () => {
|
||||
const stored = [
|
||||
{ keywords: ["invoice"], tier: "MEDIUM" },
|
||||
{ keywords: ["pentest", "vulnerability"], tier: "SECURITY_REVIEW" },
|
||||
];
|
||||
expect(hydrateKeywordTierRules(stored)).toEqual([
|
||||
{ id: "stored-0", keywords: ["invoice"], tier: "MEDIUM" },
|
||||
{ id: "stored-1", keywords: ["pentest", "vulnerability"], tier: "SECURITY_REVIEW" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips through serialize without loss", () => {
|
||||
const stored = [{ keywords: ["pentest"], tier: "SECURITY_REVIEW" }];
|
||||
expect(serializeKeywordTierRules(hydrateKeywordTierRules(stored))).toEqual(stored);
|
||||
});
|
||||
|
||||
it("still drops rows that are not rules at all", () => {
|
||||
expect(hydrateKeywordTierRules([{ keywords: [], tier: "MEDIUM" }, { keywords: ["x"] }, "junk", null])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,18 +1,18 @@
|
|||
import { ComplexityTier, KeywordTierRule } from "./KeywordTierRules";
|
||||
import { KeywordTierRule } from "./KeywordTierRules";
|
||||
|
||||
/**
|
||||
* Stored shape of a keyword tier rule inside `complexity_router_config`. The UI's
|
||||
* KeywordTierRule carries an extra `id` used only as a React key, so it is stripped on the
|
||||
* way out and synthesized on the way back in. Both the create form and the edit modal go
|
||||
* through here so the two directions cannot drift.
|
||||
* through here so the two directions cannot drift. The tier is any active tier name: a
|
||||
* built-in one, or with tier_definitions, an operator-defined one, so hydration must not
|
||||
* filter on the built-in set or an edit would silently delete a custom tier's rules.
|
||||
*/
|
||||
export interface StoredKeywordTierRule {
|
||||
keywords: string[];
|
||||
tier: ComplexityTier;
|
||||
tier: string;
|
||||
}
|
||||
|
||||
const TIERS: ReadonlySet<string> = new Set<ComplexityTier>(["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]);
|
||||
|
||||
const asKeywords = (value: unknown): string[] =>
|
||||
Array.isArray(value)
|
||||
? value.filter((keyword): keyword is string => typeof keyword === "string").map((keyword) => keyword.trim())
|
||||
|
|
@ -40,7 +40,7 @@ export const hydrateKeywordTierRules = (value: unknown): KeywordTierRule[] => {
|
|||
const record = entry as Record<string, unknown>;
|
||||
const keywords = asKeywords(record.keywords).filter(Boolean);
|
||||
const tier = record.tier;
|
||||
if (keywords.length === 0 || typeof tier !== "string" || !TIERS.has(tier)) return [];
|
||||
return [{ id: `stored-${index}`, keywords, tier: tier as ComplexityTier }];
|
||||
if (keywords.length === 0 || typeof tier !== "string" || !tier.trim()) return [];
|
||||
return [{ id: `stored-${index}`, keywords, tier }];
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -62,8 +62,17 @@ vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => {
|
|||
|
||||
// The redesigned sidebar reads the custom logo from ThemeContext; the test tree
|
||||
// has no ThemeProvider, so stub the hook.
|
||||
const unbrandedTheme = () => ({
|
||||
logoUrl: null as string | null,
|
||||
logoUrlDark: null as string | null,
|
||||
faviconUrl: null as string | null,
|
||||
setLogoUrl: vi.fn(),
|
||||
setLogoUrlDark: vi.fn(),
|
||||
setFaviconUrl: vi.fn(),
|
||||
});
|
||||
let mockUseThemeImpl = unbrandedTheme;
|
||||
vi.mock("@/contexts/ThemeContext", () => ({
|
||||
useTheme: () => ({ logoUrl: null, faviconUrl: null, setLogoUrl: vi.fn(), setFaviconUrl: vi.fn() }),
|
||||
useTheme: () => mockUseThemeImpl(),
|
||||
}));
|
||||
|
||||
// Version tag + logout target come from network hooks; keep them inert in unit tests.
|
||||
|
|
@ -97,6 +106,7 @@ describe("Sidebar (leftnav)", () => {
|
|||
afterEach(() => {
|
||||
mockUseAuthorized.mockReset();
|
||||
mockUseOrganizations.mockReset();
|
||||
mockUseThemeImpl = unbrandedTheme;
|
||||
});
|
||||
|
||||
it("should link the logo to the UI home route rather than the proxy origin", () => {
|
||||
|
|
@ -120,6 +130,46 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(classesOf(dark).has("dark:block")).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers a configured dark logo over the light one in dark mode", () => {
|
||||
mockUseThemeImpl = () => ({
|
||||
...unbrandedTheme(),
|
||||
logoUrl: "https://cdn.example.com/logo.png",
|
||||
logoUrlDark: "https://cdn.example.com/logo-dark.png",
|
||||
});
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
const [light, dark] = Array.from(screen.getByRole("link", { name: /litellm home/i }).querySelectorAll("img"));
|
||||
|
||||
expect(light).toHaveAttribute("src", "https://cdn.example.com/logo.png");
|
||||
expect(dark).toHaveAttribute("src", "https://cdn.example.com/logo-dark.png");
|
||||
});
|
||||
|
||||
it("reuses the light custom logo in dark mode when no dark one is configured", () => {
|
||||
mockUseThemeImpl = () => ({ ...unbrandedTheme(), logoUrl: "https://cdn.example.com/logo.png" });
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
const [light, dark] = Array.from(screen.getByRole("link", { name: /litellm home/i }).querySelectorAll("img"));
|
||||
|
||||
expect(light).toHaveAttribute("src", "https://cdn.example.com/logo.png");
|
||||
expect(dark).toHaveAttribute("src", "https://cdn.example.com/logo.png");
|
||||
});
|
||||
|
||||
it("falls back to the light logo when a configured dark logo fails to load", () => {
|
||||
mockUseThemeImpl = () => ({
|
||||
...unbrandedTheme(),
|
||||
logoUrl: "https://cdn.example.com/logo.png",
|
||||
logoUrlDark: "https://cdn.example.com/gone.png",
|
||||
});
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
const [, dark] = Array.from(screen.getByRole("link", { name: /litellm home/i }).querySelectorAll("img"));
|
||||
expect(dark).toHaveAttribute("src", "https://cdn.example.com/gone.png");
|
||||
|
||||
fireEvent.error(dark);
|
||||
|
||||
expect(dark).toHaveAttribute("src", "https://cdn.example.com/logo.png");
|
||||
});
|
||||
|
||||
it("renders all top-level (non-nested) tabs for admin", () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
|
|
|
|||
|
|
@ -435,7 +435,8 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
const { userId, accessToken, userRole, isViewOnly } = useAuthorized();
|
||||
const isOrgAdmin = useIsOrgAdmin();
|
||||
const { data: teams } = useTeams();
|
||||
const { logoUrl } = useTheme();
|
||||
const { logoUrl, logoUrlDark } = useTheme();
|
||||
const [erroredDarkLogo, setErroredDarkLogo] = useState<string | null>(null);
|
||||
const { data: healthData } = useHealthReadinessDetails(accessToken);
|
||||
const logout = useLogout(accessToken);
|
||||
|
||||
|
|
@ -605,7 +606,8 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
};
|
||||
|
||||
const logoSrc = logoUrl || `${baseUrl}/get_image`;
|
||||
const darkLogoSrc = logoUrl || `${baseUrl}/get_image?theme=dark`;
|
||||
const reachableDarkLogo = logoUrlDark === erroredDarkLogo ? null : logoUrlDark;
|
||||
const darkLogoSrc = reachableDarkLogo || logoUrl || `${baseUrl}/get_image?theme=dark`;
|
||||
|
||||
return (
|
||||
<Sidebar collapsed={collapsed}>
|
||||
|
|
@ -614,7 +616,13 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Link href={migratedHref("")} className="flex min-w-0 items-center" aria-label="LiteLLM home">
|
||||
<img src={logoSrc} alt="LiteLLM" className={cn(LOGO_CLASS_NAME, "dark:hidden")} />
|
||||
<img src={darkLogoSrc} alt="" aria-hidden className={cn(LOGO_CLASS_NAME, "hidden dark:block")} />
|
||||
<img
|
||||
src={darkLogoSrc}
|
||||
alt=""
|
||||
aria-hidden
|
||||
onError={() => setErroredDarkLogo(logoUrlDark)}
|
||||
className={cn(LOGO_CLASS_NAME, "hidden dark:block")}
|
||||
/>
|
||||
</Link>
|
||||
{version && (
|
||||
<Badge
|
||||
|
|
|
|||
|
|
@ -157,6 +157,21 @@ describe("Navbar", () => {
|
|||
expect(screen.getByRole("link", { name: /litellm brand/i })).toHaveAttribute("href", "/ui");
|
||||
});
|
||||
|
||||
it("pairs the logo with a dark-mode variant that swaps on the dark class", () => {
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
||||
const [light, dark] = Array.from(screen.getByRole("link", { name: /litellm brand/i }).querySelectorAll("img"));
|
||||
const classesOf = (el: Element) => new Set(el.className.split(/\s+/));
|
||||
|
||||
const lightSrc = light.getAttribute("src") ?? "";
|
||||
expect(light).toHaveAttribute("src", expect.stringMatching(/\/get_image$/));
|
||||
expect(dark).toHaveAttribute("src", `${lightSrc}?theme=dark`);
|
||||
expect(classesOf(light).has("dark:hidden")).toBe(true);
|
||||
expect(classesOf(light).has("hidden")).toBe(false);
|
||||
expect(classesOf(dark).has("hidden")).toBe(true);
|
||||
expect(classesOf(dark).has("dark:block")).toBe(true);
|
||||
});
|
||||
|
||||
it("should display user information in dropdown", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<Navbar {...defaultProps} />);
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ import React from "react";
|
|||
import { BlogDropdown } from "./Navbar/BlogDropdown/BlogDropdown";
|
||||
import { CommunityEngagementButtons } from "./Navbar/CommunityEngagementButtons/CommunityEngagementButtons";
|
||||
import { NAV_PRODUCT_LINK_CLASS } from "./Navbar/navProductLinkClass";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { NotificationsBell } from "./Navbar/NotificationsBell/NotificationsBell";
|
||||
import UserDropdown from "./Navbar/UserDropdown/UserDropdown";
|
||||
import ThemeToggle from "./ThemeToggle/ThemeToggle";
|
||||
import ViewSwitcher from "./Navbar/ViewSwitcher";
|
||||
import WorkerDropdown from "./Navbar/WorkerDropdown/WorkerDropdown";
|
||||
|
||||
|
|
@ -27,6 +29,8 @@ interface NavbarProps {
|
|||
onToggleSidebar?: () => void;
|
||||
}
|
||||
|
||||
const NAV_LOGO_CLASS_NAME = "h-auto max-h-full w-auto max-w-full object-contain";
|
||||
|
||||
const Navbar: React.FC<NavbarProps> = ({
|
||||
accessToken,
|
||||
isPublicPage = false,
|
||||
|
|
@ -44,6 +48,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
const showWorkerSwitch = isControlPlane && selectedWorker !== null;
|
||||
|
||||
const imageUrl = logoUrl || `${baseUrl}/get_image`;
|
||||
const darkImageUrl = logoUrl || `${baseUrl}/get_image?theme=dark`;
|
||||
|
||||
const handleLogout = () => {
|
||||
clearTokenCookies();
|
||||
|
|
@ -85,10 +90,12 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<Link href={migratedHref("")} className="flex items-center">
|
||||
<div className="relative">
|
||||
<div className="flex h-10 max-w-48 items-center justify-center overflow-hidden">
|
||||
<img src={imageUrl} alt="LiteLLM Brand" className={cn(NAV_LOGO_CLASS_NAME, "dark:hidden")} />
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="LiteLLM Brand"
|
||||
className="h-auto max-h-full w-auto max-w-full object-contain"
|
||||
src={darkImageUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
className={cn(NAV_LOGO_CLASS_NAME, "hidden dark:block")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -158,6 +165,8 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
{!isPublicPage && (
|
||||
<div className="flex shrink-0 items-center border-l border-border pl-4">
|
||||
<div className="flex items-center gap-0.5 rounded-lg bg-muted px-1 py-0 transition-colors hover:bg-accent">
|
||||
<ThemeToggle />
|
||||
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
|
||||
<NotificationsBell />
|
||||
<span className="mx-0.5 h-6 w-px shrink-0 bg-border" aria-hidden />
|
||||
<UserDropdown onLogout={handleLogout} />
|
||||
|
|
@ -165,7 +174,6 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Dark mode toggle: keep disabled until the dashboard supports dark styles end-to-end. */}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"use client";
|
||||
|
||||
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
|
||||
function Toaster({ ...props }: ToasterProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
theme={resolvedTheme === "dark" ? "dark" : "light"}
|
||||
position="top-right"
|
||||
closeButton
|
||||
className="toaster group"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { getProxyBaseUrl } from "@/components/networking";
|
|||
interface ThemeContextType {
|
||||
logoUrl: string | null;
|
||||
setLogoUrl: (url: string | null) => void;
|
||||
logoUrlDark: string | null;
|
||||
setLogoUrlDark: (url: string | null) => void;
|
||||
faviconUrl: string | null;
|
||||
setFaviconUrl: (url: string | null) => void;
|
||||
}
|
||||
|
|
@ -25,6 +27,7 @@ interface ThemeProviderProps {
|
|||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, accessToken }) => {
|
||||
const [logoUrl, setLogoUrl] = useState<string | null>(null);
|
||||
const [logoUrlDark, setLogoUrlDark] = useState<string | null>(null);
|
||||
const [faviconUrl, setFaviconUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -42,6 +45,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, accessTo
|
|||
if (data.values?.logo_url) {
|
||||
setLogoUrl(data.values.logo_url);
|
||||
}
|
||||
if (data.values?.logo_url_dark) {
|
||||
setLogoUrlDark(data.values.logo_url_dark);
|
||||
}
|
||||
if (data.values?.favicon_url) {
|
||||
setFaviconUrl(data.values.favicon_url);
|
||||
}
|
||||
|
|
@ -71,6 +77,8 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children, accessTo
|
|||
}, [faviconUrl]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ logoUrl, setLogoUrl, faviconUrl, setFaviconUrl }}>{children}</ThemeContext.Provider>
|
||||
<ThemeContext.Provider value={{ logoUrl, setLogoUrl, logoUrlDark, setLogoUrlDark, faviconUrl, setFaviconUrl }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useIsDarkMode } from "./useIsDarkMode";
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
});
|
||||
|
||||
describe("useIsDarkMode", () => {
|
||||
it("reports the dark class already on the root element at mount", () => {
|
||||
document.documentElement.classList.add("dark");
|
||||
|
||||
const { result } = renderHook(() => useIsDarkMode());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("follows the root element's dark class as it is toggled", async () => {
|
||||
const { result } = renderHook(() => useIsDarkMode());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
document.documentElement.classList.add("dark");
|
||||
await waitFor(() => expect(result.current).toBe(true));
|
||||
|
||||
document.documentElement.classList.remove("dark");
|
||||
await waitFor(() => expect(result.current).toBe(false));
|
||||
});
|
||||
|
||||
it("stops observing the root element once unmounted", () => {
|
||||
const disconnect = vi.spyOn(MutationObserver.prototype, "disconnect");
|
||||
|
||||
const { unmount } = renderHook(() => useIsDarkMode());
|
||||
unmount();
|
||||
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
disconnect.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const subscribe = (onStoreChange: () => void): (() => void) => {
|
||||
const observer = new MutationObserver(onStoreChange);
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
|
||||
return () => observer.disconnect();
|
||||
};
|
||||
|
||||
const getSnapshot = (): boolean => document.documentElement.classList.contains("dark");
|
||||
|
||||
const getServerSnapshot = (): boolean => false;
|
||||
|
||||
export const useIsDarkMode = (): boolean => useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
|
@ -1,47 +1,50 @@
|
|||
import { act, renderHook } from "@testing-library/react";
|
||||
import { ThemeProvider, useTheme } from "next-themes";
|
||||
import type { ReactNode } from "react";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { useSyntaxTheme, type SyntaxTheme } from "./useSyntaxTheme";
|
||||
|
||||
const callerLightTheme: SyntaxTheme = { 'code[class*="language-"]': { color: "rebeccapurple" } };
|
||||
|
||||
const setRootDark = async (enabled: boolean) => {
|
||||
await act(async () => {
|
||||
document.documentElement.classList.toggle("dark", enabled);
|
||||
await Promise.resolve();
|
||||
const renderSyntaxTheme = (defaultTheme: string) =>
|
||||
renderHook(() => ({ syntax: useSyntaxTheme(callerLightTheme), setTheme: useTheme().setTheme }), {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<ThemeProvider attribute="class" enableSystem={false} defaultTheme={defaultTheme}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
localStorage.clear();
|
||||
document.documentElement.classList.remove("dark", "light");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
document.documentElement.classList.remove("dark");
|
||||
document.documentElement.classList.remove("dark", "light");
|
||||
});
|
||||
|
||||
describe("useSyntaxTheme", () => {
|
||||
it("keeps the caller's own stylesheet in light mode", () => {
|
||||
const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
|
||||
const { result } = renderSyntaxTheme("light");
|
||||
|
||||
expect(result.current).toBe(callerLightTheme);
|
||||
expect(result.current.syntax).toBe(callerLightTheme);
|
||||
});
|
||||
|
||||
it("swaps to oneDark when the root element turns dark", async () => {
|
||||
const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
|
||||
it("serves oneDark when the resolved theme is dark", () => {
|
||||
const { result } = renderSyntaxTheme("dark");
|
||||
|
||||
await setRootDark(true);
|
||||
|
||||
expect(result.current).toBe(oneDark);
|
||||
expect(result.current.syntax).toBe(oneDark);
|
||||
});
|
||||
|
||||
it("restores the caller's stylesheet when dark mode is turned back off", async () => {
|
||||
document.documentElement.classList.add("dark");
|
||||
const { result } = renderHook(() => useSyntaxTheme(callerLightTheme));
|
||||
expect(result.current).toBe(oneDark);
|
||||
it("swaps stylesheets when the theme is changed at runtime", () => {
|
||||
const { result } = renderSyntaxTheme("light");
|
||||
|
||||
await setRootDark(false);
|
||||
act(() => result.current.setTheme("dark"));
|
||||
expect(result.current.syntax).toBe(oneDark);
|
||||
|
||||
expect(result.current).toBe(callerLightTheme);
|
||||
act(() => result.current.setTheme("light"));
|
||||
expect(result.current.syntax).toBe(callerLightTheme);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
|
||||
import { useIsDarkMode } from "./useIsDarkMode";
|
||||
|
||||
export type SyntaxTheme = Record<string, CSSProperties>;
|
||||
|
||||
export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme => (useIsDarkMode() ? oneDark : light);
|
||||
export const useSyntaxTheme = (light: SyntaxTheme): SyntaxTheme =>
|
||||
useTheme().resolvedTheme === "dark" ? oneDark : light;
|
||||
|
|
|
|||
88
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
88
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -945,6 +945,31 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auto_router/validate_complexity_router_config": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Validate Complexity Router Config
|
||||
* @description Validate a complexity-router config without saving it.
|
||||
*
|
||||
* Runs the same check every write path runs (the router's own pydantic model), so a form can
|
||||
* show the backend's exact verdict while the operator is still editing rather than after a
|
||||
* rejected save. Gated exactly like the save it rehearses: a proxy admin, or a team admin
|
||||
* naming their own team. Nothing is created, routed, or billed.
|
||||
*/
|
||||
post: operations["validate_complexity_router_config_auto_router_validate_complexity_router_config_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/azure/{endpoint}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -22058,7 +22083,7 @@ export interface components {
|
|||
routed_model: string;
|
||||
/**
|
||||
* Routed Model Configured
|
||||
* @description Whether routed_model is a model group this proxy actually serves
|
||||
* @description Whether routed_model is a model group available to the caller, scoped to team_id when given. Never confirms models the caller could not use
|
||||
*/
|
||||
routed_model_configured: boolean;
|
||||
/** @description The decision record this request would have written to its log row */
|
||||
|
|
@ -23773,6 +23798,29 @@ export interface components {
|
|||
*/
|
||||
timezone?: string | null;
|
||||
};
|
||||
/**
|
||||
* ComplexityRouterConfigValidationRequest
|
||||
* @description A complexity-router config to validate without saving, so a form can surface the
|
||||
* backend's own verdict inline instead of a raw 400 at write time.
|
||||
*/
|
||||
ComplexityRouterConfigValidationRequest: {
|
||||
/** Complexity Router Config */
|
||||
complexity_router_config: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
/**
|
||||
* Team Id
|
||||
* @description Team the router is being created for. Required for a team admin, who may only validate their own team's routers
|
||||
*/
|
||||
team_id?: string | null;
|
||||
};
|
||||
/** ComplexityRouterConfigValidationResponse */
|
||||
ComplexityRouterConfigValidationResponse: {
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
/** Valid */
|
||||
valid: boolean;
|
||||
};
|
||||
/**
|
||||
* ComplexityScorerDefaults
|
||||
* @description The complexity router's shipped heuristic scorer defaults.
|
||||
|
|
@ -34691,6 +34739,11 @@ export interface components {
|
|||
* @description URL or path to custom logo image. Can be a local file path or HTTP/HTTPS URL
|
||||
*/
|
||||
logo_url?: string | null;
|
||||
/**
|
||||
* Logo Url Dark
|
||||
* @description URL or path to a custom logo image for dark mode. Can be a local file path or HTTP/HTTPS URL. Leave unset to reuse logo_url in dark mode
|
||||
*/
|
||||
logo_url_dark?: string | null;
|
||||
};
|
||||
/**
|
||||
* UIThemeSettingsResponse
|
||||
|
|
@ -38165,6 +38218,39 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
validate_complexity_router_config_auto_router_validate_complexity_router_config_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ComplexityRouterConfigValidationRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ComplexityRouterConfigValidationResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
azure_proxy_route_azure__endpoint__get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue