mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
feat(proxy): restrict Transcribe media and output buckets per operator allowlist
Non-admin keys may only start transcription jobs whose media and transcript output live in the S3 buckets listed in general_settings.transcribe_media_buckets, and may not supply DataAccessRoleArn or JobExecutionSettings. The setting is editable from the Admin UI general settings table (new List editor) and DB values load into the running proxy when config.yaml does not set it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
4f8a5b5e12
commit
393d084db7
11 changed files with 296 additions and 10 deletions
|
|
@ -20400,7 +20400,7 @@
|
|||
},
|
||||
"/transcribe/{operation}": {
|
||||
"post": {
|
||||
"description": "Pass-through for the Amazon Transcribe API, e.g. `POST /transcribe/StartTranscriptionJob`.\n\nThe request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the\nproxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that\nonly that owner (or a proxy admin) can read or delete them; account-wide operations\nsuch as ListTranscriptionJobs are limited to proxy admins. Streaming transcription\n(`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served\nby this route.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/transcribe)",
|
||||
"description": "Pass-through for the Amazon Transcribe API, e.g. `POST /transcribe/StartTranscriptionJob`.\n\nThe request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the\nproxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that\nonly that owner (or a proxy admin) can read or delete them, and keys other than proxy\nadmins may only read media from and write transcripts to the S3 buckets listed in\n`general_settings.transcribe_media_buckets`; account-wide operations\nsuch as ListTranscriptionJobs are limited to proxy admins. Streaming transcription\n(`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served\nby this route.\n\n[Docs](https://docs.litellm.ai/docs/pass_through/transcribe)",
|
||||
"operationId": "transcribe_proxy_route_transcribe__operation__post",
|
||||
"parameters": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2784,6 +2784,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
default=None,
|
||||
description="Serve the OpenAI pass-through WebSocket route, which relays frames to OpenAI under the proxy's own provider credential without reading them. Off by default.",
|
||||
)
|
||||
transcribe_media_buckets: list[str] | None = Field(
|
||||
default=None,
|
||||
description="S3 bucket names that keys other than proxy admins may read media from and write transcripts to through the Amazon Transcribe pass-through. Unset means only proxy admins can start transcription jobs.",
|
||||
)
|
||||
user_header_name: str | None = Field(
|
||||
None,
|
||||
description="[DEPRECATED] Use 'user_header_mappings' instead. When set, the header value is treated as the end user id unless overridden by user_header_mappings.",
|
||||
|
|
|
|||
|
|
@ -1235,6 +1235,12 @@ async def bedrock_proxy_route(
|
|||
COMPREHEND_MEDICAL_TARGET_PREFIX: Final = "ComprehendMedical_20181030"
|
||||
|
||||
|
||||
def _proxy_general_settings() -> Mapping[str, object]:
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
return general_settings
|
||||
|
||||
|
||||
def _resolve_aws_passthrough_region() -> str | None:
|
||||
region_candidates: Final = (
|
||||
get_secret_str(secret_name="AWS_REGION_NAME"),
|
||||
|
|
@ -1361,13 +1367,16 @@ async def transcribe_proxy_route(
|
|||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
general_settings: Annotated[Mapping[str, object], Depends(_proxy_general_settings)],
|
||||
):
|
||||
"""
|
||||
Pass-through for the Amazon Transcribe API, e.g. `POST /transcribe/StartTranscriptionJob`.
|
||||
|
||||
The request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the
|
||||
proxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that
|
||||
only that owner (or a proxy admin) can read or delete them; account-wide operations
|
||||
only that owner (or a proxy admin) can read or delete them, and keys other than proxy
|
||||
admins may only read media from and write transcripts to the S3 buckets listed in
|
||||
`general_settings.transcribe_media_buckets`; account-wide operations
|
||||
such as ListTranscriptionJobs are limited to proxy admins. Streaming transcription
|
||||
(`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served
|
||||
by this route.
|
||||
|
|
@ -1384,7 +1393,9 @@ async def transcribe_proxy_route(
|
|||
transcribe_cost_per_second,
|
||||
transcribe_job_access_refusal,
|
||||
transcribe_job_lookup,
|
||||
transcribe_media_buckets,
|
||||
transcribe_owned_start_request,
|
||||
transcribe_storage_refusal,
|
||||
transcribe_supported_operations,
|
||||
transcribe_unpriceable_request_reason,
|
||||
)
|
||||
|
|
@ -1420,6 +1431,13 @@ async def transcribe_proxy_route(
|
|||
admin_only_refusal: Final = transcribe_admin_only_refusal(operation, user_api_key_dict)
|
||||
if admin_only_refusal is not None:
|
||||
raise HTTPException(status_code=admin_only_refusal.status_code, detail=admin_only_refusal.detail)
|
||||
storage_refusal: Final = (
|
||||
transcribe_storage_refusal(data, transcribe_media_buckets(general_settings), user_api_key_dict)
|
||||
if operation == TRANSCRIBE_PRICED_OPERATION
|
||||
else None
|
||||
)
|
||||
if storage_refusal is not None:
|
||||
raise HTTPException(status_code=storage_refusal.status_code, detail=storage_refusal.detail)
|
||||
request_body: Final = (
|
||||
transcribe_owned_start_request(data, user_api_key_dict) if operation == TRANSCRIBE_PRICED_OPERATION else data
|
||||
)
|
||||
|
|
@ -1472,6 +1490,7 @@ async def transcribe_sdk_proxy_route(
|
|||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
general_settings: Annotated[Mapping[str, object], Depends(_proxy_general_settings)],
|
||||
):
|
||||
"""
|
||||
AWS-SDK-shaped pass-through for Amazon Transcribe: point the SDK's `endpoint_url`
|
||||
|
|
@ -1496,6 +1515,7 @@ async def transcribe_sdk_proxy_route(
|
|||
request=request,
|
||||
fastapi_response=fastapi_response,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
general_settings=general_settings,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2770,12 +2790,6 @@ class _OpenAIWebsocketRelay(Protocol):
|
|||
) -> None: ...
|
||||
|
||||
|
||||
def _proxy_general_settings() -> Mapping[str, object]:
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
return general_settings
|
||||
|
||||
|
||||
def _openai_websocket_relay() -> _OpenAIWebsocketRelay:
|
||||
return websocket_passthrough_request
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,9 @@ TRANSCRIBE_TERMINAL_JOB_STATUSES: Final = frozenset({"COMPLETED", "FAILED"})
|
|||
TRANSCRIBE_MISSING_JOB_ERRORS: Final = frozenset({"BadRequestException", "NotFoundException"})
|
||||
TRANSCRIBE_OWNER_TAG: Final = "litellm-owner"
|
||||
TRANSCRIBE_OWNED_JOB_OPERATIONS: Final = frozenset({"GetTranscriptionJob", "DeleteTranscriptionJob"})
|
||||
TRANSCRIBE_MEDIA_BUCKETS_SETTING: Final = "transcribe_media_buckets"
|
||||
TRANSCRIBE_ROLE_MEMBERS: Final = ("DataAccessRoleArn", "JobExecutionSettings")
|
||||
TRANSCRIBE_MEDIA_URI_MEMBERS: Final = ("MediaFileUri", "RedactedMediaFileUri")
|
||||
|
||||
JobLookup: TypeAlias = Callable[[str], Awaitable[Mapping[str, object]]] # mutable-ok: Callable parameter syntax
|
||||
MediaDurationProbe: TypeAlias = Callable[[str, float], Awaitable[float | None]] # mutable-ok: Callable parameter syntax
|
||||
|
|
@ -109,6 +112,7 @@ class _PricedCostMapEntry(BaseModel):
|
|||
|
||||
_JSON_OBJECT: Final = TypeAdapter(Mapping[str, object])
|
||||
_JSON_OBJECTS: Final = TypeAdapter(tuple[Mapping[str, object], ...])
|
||||
_BUCKET_NAMES: Final = TypeAdapter(frozenset[str])
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
|
@ -231,6 +235,68 @@ def transcribe_admin_only_refusal(operation: str, user_api_key_dict: UserAPIKeyA
|
|||
)
|
||||
|
||||
|
||||
def transcribe_media_buckets(general_settings: Mapping[str, object]) -> frozenset[str] | None:
|
||||
try:
|
||||
return _BUCKET_NAMES.validate_python(general_settings.get(TRANSCRIBE_MEDIA_BUCKETS_SETTING))
|
||||
except ValidationError:
|
||||
return None
|
||||
|
||||
|
||||
def s3_bucket_name(uri: object) -> str | None:
|
||||
if not isinstance(uri, str) or not uri.startswith("s3://"):
|
||||
return None
|
||||
bucket, _, _ = uri.removeprefix("s3://").partition("/")
|
||||
return bucket or None
|
||||
|
||||
|
||||
def transcribe_storage_refusal(
|
||||
request_body: Mapping[str, object],
|
||||
allowed_buckets: frozenset[str] | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> TranscribeRefusal | None:
|
||||
"""
|
||||
Transcribe reads the media and writes the transcript with the proxy's own AWS credentials, so a
|
||||
non-admin key may only point a job at buckets the operator listed; otherwise any object those
|
||||
credentials can reach could be transcribed and read back through the caller's own job.
|
||||
"""
|
||||
if is_proxy_admin(user_api_key_dict):
|
||||
return None
|
||||
if allowed_buckets is None:
|
||||
return TranscribeRefusal(
|
||||
403,
|
||||
f"general_settings.{TRANSCRIBE_MEDIA_BUCKETS_SETTING} is not a list of S3 bucket names, so only a proxy"
|
||||
f" admin may {TRANSCRIBE_PRICED_OPERATION}; list the buckets other keys may read media from and write"
|
||||
" transcripts to",
|
||||
)
|
||||
roles: Final = tuple(m for m in TRANSCRIBE_ROLE_MEMBERS if m in request_body)
|
||||
if roles:
|
||||
return TranscribeRefusal(
|
||||
403,
|
||||
f"{', '.join(roles)} would run the job under a role other than the proxy's own AWS credentials, so"
|
||||
" only a proxy admin may set it",
|
||||
)
|
||||
media: Final = request_body.get("Media")
|
||||
media_uris: Final = (
|
||||
tuple((f"Media.{m}", s3_bucket_name(media.get(m))) for m in TRANSCRIBE_MEDIA_URI_MEMBERS if m in media)
|
||||
if isinstance(media, Mapping)
|
||||
else ()
|
||||
)
|
||||
output: Final = request_body.get("OutputBucketName")
|
||||
locations: Final = media_uris + (
|
||||
(("OutputBucketName", output if isinstance(output, str) else None),)
|
||||
if "OutputBucketName" in request_body
|
||||
else ()
|
||||
)
|
||||
offending: Final = tuple(member for member, bucket in locations if bucket not in allowed_buckets)
|
||||
if offending:
|
||||
return TranscribeRefusal(
|
||||
403,
|
||||
f"{', '.join(offending)} must name one of the S3 buckets in general_settings."
|
||||
f"{TRANSCRIBE_MEDIA_BUCKETS_SETTING} ({', '.join(sorted(allowed_buckets))}), as s3://bucket/key for media",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def transcribe_owned_start_request(
|
||||
request_body: Mapping[str, object], user_api_key_dict: UserAPIKeyAuth
|
||||
) -> dict[str, object] | TranscribeRefusal:
|
||||
|
|
|
|||
|
|
@ -7108,6 +7108,9 @@ class ProxyConfig:
|
|||
if "blocked_file_extensions" not in self._yaml_general_settings_keys:
|
||||
general_settings["blocked_file_extensions"] = _general_settings.get("blocked_file_extensions")
|
||||
|
||||
if "transcribe_media_buckets" not in self._yaml_general_settings_keys:
|
||||
general_settings["transcribe_media_buckets"] = _general_settings.get("transcribe_media_buckets")
|
||||
|
||||
## ALERTING ARGS ##
|
||||
if "alerting_args" in _general_settings:
|
||||
general_settings["alerting_args"] = _general_settings["alerting_args"]
|
||||
|
|
@ -17146,6 +17149,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
|
|||
"disable_auto_add_proxy_admin_to_teams": "Boolean",
|
||||
"apply_user_budget_to_team_keys": "Boolean",
|
||||
"user_api_key_cache_max_size": "Integer",
|
||||
"transcribe_media_buckets": "List",
|
||||
}
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ from litellm.proxy.pass_through_endpoints.llm_provider_handlers.transcribe_passt
|
|||
transcribe_admin_only_refusal,
|
||||
transcribe_cost_per_second,
|
||||
transcribe_job_access_refusal,
|
||||
transcribe_media_buckets,
|
||||
transcribe_owned_start_request,
|
||||
transcribe_storage_refusal,
|
||||
transcribe_supported_operations,
|
||||
transcribe_unpriceable_request_reason,
|
||||
write_media_within_limit,
|
||||
|
|
@ -503,6 +505,84 @@ class TestTranscribeAdminOnlyRefusal:
|
|||
assert transcribe_admin_only_refusal(operation, ADMIN_KEY) is None
|
||||
|
||||
|
||||
ALLOWED_BUCKETS = frozenset({"tenant-media", "tenant-transcripts"})
|
||||
|
||||
|
||||
def _start_body(media_uri: str = "s3://tenant-media/call.wav", **members: object) -> dict[str, object]:
|
||||
return {"TranscriptionJobName": "j", "Media": {"MediaFileUri": media_uri}, **members}
|
||||
|
||||
|
||||
class TestTranscribeMediaBuckets:
|
||||
def test_a_list_of_bucket_names_is_read_from_general_settings(self):
|
||||
assert transcribe_media_buckets({"transcribe_media_buckets": ["a", "b"]}) == frozenset({"a", "b"})
|
||||
|
||||
@pytest.mark.parametrize("settings", [{}, {"transcribe_media_buckets": "a"}, {"transcribe_media_buckets": [1]}])
|
||||
def test_a_missing_or_malformed_setting_reads_as_unset(self, settings: dict[str, object]):
|
||||
assert transcribe_media_buckets(settings) is None
|
||||
|
||||
|
||||
class TestTranscribeStorageRefusal:
|
||||
def test_media_and_output_in_listed_buckets_are_allowed(self):
|
||||
body = _start_body(OutputBucketName="tenant-transcripts", OutputKey="out/")
|
||||
|
||||
assert transcribe_storage_refusal(body, ALLOWED_BUCKETS, VIRTUAL_KEY) is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"media_uri",
|
||||
[
|
||||
"s3://other-tenant/call.wav",
|
||||
"https://tenant-media.s3.us-west-2.amazonaws.com/call.wav",
|
||||
"s3://",
|
||||
],
|
||||
)
|
||||
def test_media_outside_the_listed_buckets_is_refused(self, media_uri: str):
|
||||
refusal = transcribe_storage_refusal(_start_body(media_uri), ALLOWED_BUCKETS, VIRTUAL_KEY)
|
||||
|
||||
assert refusal is not None
|
||||
assert refusal.status_code == 403
|
||||
assert "Media.MediaFileUri" in refusal.detail
|
||||
|
||||
def test_redacted_media_outside_the_listed_buckets_is_refused(self):
|
||||
body = {
|
||||
"TranscriptionJobName": "j",
|
||||
"Media": {"MediaFileUri": "s3://tenant-media/call.wav", "RedactedMediaFileUri": "s3://other-tenant/c.wav"},
|
||||
}
|
||||
|
||||
refusal = transcribe_storage_refusal(body, ALLOWED_BUCKETS, VIRTUAL_KEY)
|
||||
|
||||
assert refusal is not None
|
||||
assert "Media.RedactedMediaFileUri" in refusal.detail
|
||||
|
||||
@pytest.mark.parametrize("output", ["other-tenant", 7])
|
||||
def test_an_output_bucket_outside_the_listed_buckets_is_refused(self, output: object):
|
||||
refusal = transcribe_storage_refusal(_start_body(OutputBucketName=output), ALLOWED_BUCKETS, VIRTUAL_KEY)
|
||||
|
||||
assert refusal is not None
|
||||
assert refusal.status_code == 403
|
||||
assert "OutputBucketName" in refusal.detail
|
||||
|
||||
@pytest.mark.parametrize("member", ["DataAccessRoleArn", "JobExecutionSettings"])
|
||||
def test_a_caller_chosen_role_is_refused(self, member: str):
|
||||
refusal = transcribe_storage_refusal(_start_body(**{member: "x"}), ALLOWED_BUCKETS, VIRTUAL_KEY)
|
||||
|
||||
assert refusal is not None
|
||||
assert refusal.status_code == 403
|
||||
assert member in refusal.detail
|
||||
|
||||
def test_an_unset_bucket_list_refuses_virtual_keys(self):
|
||||
refusal = transcribe_storage_refusal(_start_body(), None, VIRTUAL_KEY)
|
||||
|
||||
assert refusal is not None
|
||||
assert refusal.status_code == 403
|
||||
assert "transcribe_media_buckets" in refusal.detail
|
||||
|
||||
@pytest.mark.parametrize("allowed", [None, ALLOWED_BUCKETS])
|
||||
def test_proxy_admins_are_not_restricted(self, allowed: frozenset[str] | None):
|
||||
body = _start_body("s3://other-tenant/call.wav", DataAccessRoleArn="arn:aws:iam::1:role/r")
|
||||
|
||||
assert transcribe_storage_refusal(body, allowed, ADMIN_KEY) is None
|
||||
|
||||
|
||||
class TestTranscribeOwnedStartRequest:
|
||||
def test_the_caller_identity_is_appended_to_the_job_tags(self):
|
||||
body = {"TranscriptionJobName": "j", "Tags": [{"Key": "env", "Value": "qa"}]}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import (
|
|||
BaseOpenAIPassThroughHandler,
|
||||
RouteChecks,
|
||||
_join_url_paths,
|
||||
_proxy_general_settings,
|
||||
anthropic_proxy_route,
|
||||
azure_proxy_route,
|
||||
bedrock_llm_proxy_route,
|
||||
|
|
@ -5292,6 +5293,9 @@ def transcribe_client(monkeypatch: pytest.MonkeyPatch) -> Iterator[TestClient]:
|
|||
monkeypatch.setitem(
|
||||
app.dependency_overrides, user_api_key_auth, lambda: UserAPIKeyAuth(api_key="sk-virtual", user_id="user-a")
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
app.dependency_overrides, _proxy_general_settings, lambda: {"transcribe_media_buckets": ["bucket"]}
|
||||
)
|
||||
yield TestClient(app)
|
||||
|
||||
|
||||
|
|
@ -5333,6 +5337,46 @@ class TestTranscribeProxyRoute:
|
|||
assert "/us-west-2/transcribe/aws4_request" in sent.headers["authorization"]
|
||||
assert "x-amz-date" in sent.headers
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body, member",
|
||||
[
|
||||
({"Media": {"MediaFileUri": "s3://other-tenant/audio.wav"}}, "Media.MediaFileUri"),
|
||||
({"OutputBucketName": "other-tenant"}, "OutputBucketName"),
|
||||
({"DataAccessRoleArn": "arn:aws:iam::123456789012:role/reader"}, "DataAccessRoleArn"),
|
||||
],
|
||||
)
|
||||
def test_storage_outside_the_listed_buckets_is_refused_before_signing(
|
||||
self, transcribe_client: TestClient, body: dict[str, object], member: str
|
||||
) -> None:
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
route = upstream.post(TRANSCRIBE_UPSTREAM)
|
||||
response = transcribe_client.post("/transcribe/StartTranscriptionJob", json={**dict(self.START_JOB_BODY), **body})
|
||||
|
||||
assert response.status_code == 403
|
||||
assert member in response.json()["detail"]
|
||||
assert not route.called
|
||||
|
||||
def test_start_needs_a_bucket_list_unless_the_caller_is_a_proxy_admin(
|
||||
self, transcribe_client: TestClient, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
monkeypatch.setitem(app.dependency_overrides, _proxy_general_settings, lambda: {})
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
route = upstream.post(TRANSCRIBE_UPSTREAM).mock(return_value=httpx.Response(200, json=_owned_job("admin")))
|
||||
refused = transcribe_client.post("/transcribe/StartTranscriptionJob", json=dict(self.START_JOB_BODY))
|
||||
monkeypatch.setitem(
|
||||
app.dependency_overrides,
|
||||
user_api_key_auth,
|
||||
lambda: UserAPIKeyAuth(api_key="sk-admin", user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
allowed = transcribe_client.post("/transcribe/StartTranscriptionJob", json=dict(self.START_JOB_BODY))
|
||||
|
||||
assert refused.status_code == 403
|
||||
assert "transcribe_media_buckets" in refused.json()["detail"]
|
||||
assert allowed.status_code == 200
|
||||
assert route.calls[0].request.headers["x-amz-target"] == "Transcribe.StartTranscriptionJob"
|
||||
|
||||
def test_the_caller_cannot_forge_the_owner_tag(self, transcribe_client: TestClient) -> None:
|
||||
with respx.mock(assert_all_called=False) as upstream:
|
||||
route = upstream.post(TRANSCRIBE_UPSTREAM)
|
||||
|
|
|
|||
|
|
@ -3508,6 +3508,27 @@ async def test_ProxyConfig__update_general_settings_yaml_allowed_file_extensions
|
|||
assert ps.general_settings.get("allowed_file_extensions") == [".pdf"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__update_general_settings_applies_db_transcribe_media_buckets(monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
pc = ProxyConfig()
|
||||
await pc._update_general_settings({"transcribe_media_buckets": ["team-audio"]})
|
||||
from litellm.proxy import proxy_server as ps
|
||||
|
||||
assert ps.general_settings.get("transcribe_media_buckets") == ["team-audio"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__update_general_settings_yaml_transcribe_media_buckets_wins_over_db(monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"transcribe_media_buckets": ["yaml-audio"]})
|
||||
pc = ProxyConfig()
|
||||
pc._yaml_general_settings_keys = {"transcribe_media_buckets"}
|
||||
await pc._update_general_settings({"transcribe_media_buckets": ["team-audio"]})
|
||||
from litellm.proxy import proxy_server as ps
|
||||
|
||||
assert ps.general_settings.get("transcribe_media_buckets") == ["yaml-audio"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ProxyConfig__update_general_settings_none_input_noop():
|
||||
pc = ProxyConfig()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { renderWithProviders, screen, within } from "../../../../../tests/test-utils";
|
||||
import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import GeneralSettings from "./general_settings";
|
||||
|
|
@ -159,6 +159,31 @@ describe("GeneralSettings tabs", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("persists a List setting typed as comma-separated text as a trimmed string array", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{
|
||||
field_name: "transcribe_media_buckets",
|
||||
field_type: "List",
|
||||
field_value: ["old-bucket"],
|
||||
field_description: "buckets",
|
||||
stored_in_db: true,
|
||||
},
|
||||
]);
|
||||
vi.mocked(updateConfigFieldSetting).mockClear();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
|
||||
await user.click(screen.getByRole("tab", { name: "General" }));
|
||||
const input = await screen.findByRole("textbox", { name: "transcribe_media_buckets" });
|
||||
expect(input).toHaveValue("old-bucket");
|
||||
fireEvent.change(input, { target: { value: " team-audio, shared.audio ,, " } });
|
||||
await user.click(
|
||||
within(screen.getByRole("row", { name: /transcribe_media_buckets/ })).getByRole("button", { name: "Update" }),
|
||||
);
|
||||
expect(vi.mocked(updateConfigFieldSetting).mock.calls).toEqual([
|
||||
["token", "transcribe_media_buckets", ["team-audio", "shared.audio"]],
|
||||
]);
|
||||
});
|
||||
|
||||
it("should delete only the Default setting and retain explicit false and zero", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -43,6 +43,16 @@ const NUMERIC_INPUT_WIDTH = "w-36";
|
|||
|
||||
const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw));
|
||||
|
||||
const toListValue = (raw: string): string[] | null => {
|
||||
const items = raw
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item !== "");
|
||||
return items.length === 0 ? null : items;
|
||||
};
|
||||
|
||||
const fromListValue = (value: unknown): string => (Array.isArray(value) ? value.join(", ") : "");
|
||||
|
||||
const SettingValueEditor: React.FC<{
|
||||
setting: generalSettingsItem;
|
||||
onChange: (fieldName: string, newValue: any) => void;
|
||||
|
|
@ -93,6 +103,17 @@ const SettingValueEditor: React.FC<{
|
|||
</InputGroup>
|
||||
);
|
||||
}
|
||||
if (setting.field_type === "List") {
|
||||
return (
|
||||
<Input
|
||||
key={String(setting.stored_in_db)}
|
||||
aria-label={setting.field_name}
|
||||
placeholder="Comma-separated values"
|
||||
defaultValue={fromListValue(setting.field_value)}
|
||||
onChange={(event) => onChange(setting.field_name, toListValue(event.target.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (setting.field_type === "Select") {
|
||||
return (
|
||||
<Select value={setting.field_value ?? null} onValueChange={(newValue) => onChange(setting.field_name, newValue)}>
|
||||
|
|
|
|||
9
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
9
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -16515,7 +16515,9 @@ export interface paths {
|
|||
*
|
||||
* The request body is forwarded to the AWS JSON 1.1 API and signed with SigV4 using the
|
||||
* proxy's AWS credentials. Standard jobs are tagged with the calling key's owner so that
|
||||
* only that owner (or a proxy admin) can read or delete them; account-wide operations
|
||||
* only that owner (or a proxy admin) can read or delete them, and keys other than proxy
|
||||
* admins may only read media from and write transcripts to the S3 buckets listed in
|
||||
* `general_settings.transcribe_media_buckets`; account-wide operations
|
||||
* such as ListTranscriptionJobs are limited to proxy admins. Streaming transcription
|
||||
* (`transcribestreaming`) uses a separate HTTP/2 event-stream protocol and is not served
|
||||
* by this route.
|
||||
|
|
@ -26859,6 +26861,11 @@ export interface components {
|
|||
* @description Fine-grained control over which object types to load from the database when store_model_in_db is True. Available types: 'models', 'mcp', 'guardrails', 'vector_stores', 'pass_through_endpoints', 'prompts', 'model_cost_map', 'tools', 'config_overrides'. If not set, all objects are loaded (default behavior).
|
||||
*/
|
||||
supported_db_objects?: components["schemas"]["SupportedDBObjectType"][] | null;
|
||||
/**
|
||||
* Transcribe Media Buckets
|
||||
* @description S3 bucket names that keys other than proxy admins may read media from and write transcripts to through the Amazon Transcribe pass-through. Unset means only proxy admins can start transcription jobs.
|
||||
*/
|
||||
transcribe_media_buckets?: string[] | null;
|
||||
/**
|
||||
* Trusted Proxy Ranges
|
||||
* @description CIDR ranges of trusted reverse proxies allowed to provide identity headers for header-based auth paths such as enable_oauth2_proxy_auth and custom_ui_sso_sign_in_handler.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue