diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json
index 100816cd89c..7047b74f71a 100644
--- a/litellm/proxy/_lazy_openapi_snapshot.json
+++ b/litellm/proxy/_lazy_openapi_snapshot.json
@@ -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": [
{
diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py
index 13891576a06..5dfc1f6d3f4 100644
--- a/litellm/proxy/_types.py
+++ b/litellm/proxy/_types.py
@@ -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.",
diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
index e7f0e32f8b7..18c7071254d 100644
--- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
+++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py
@@ -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
diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py
index c036c0a3060..3ffd70a8af6 100644
--- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py
+++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/transcribe_passthrough_logging_handler.py
@@ -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:
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index d7d8413d2ce..7f3ad1573d7 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -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",
}
)
diff --git a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py
index 85dd5fb89e9..38fdbf6a1ac 100644
--- a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py
+++ b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_transcribe_passthrough_logging_handler.py
@@ -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"}]}
diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py
index 8c0c79e25cc..5a211a62220 100644
--- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py
+++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py
@@ -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)
diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py
index c3660b5c880..65e938ac5a1 100644
--- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py
+++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py
@@ -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()
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx
index b4df567e250..f08c09ded85 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx
@@ -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();
+ 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([
{
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
index 9a718cbe9b8..9aa79b36a98 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx
@@ -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<{
);
}
+ if (setting.field_type === "List") {
+ return (
+ onChange(setting.field_name, toListValue(event.target.value))}
+ />
+ );
+ }
if (setting.field_type === "Select") {
return (