feat: Models Atomic PR of #20243 (#20369)

* feat: Add read-only access support for Models

- Backend: Add write_access field to ModelAccessResponse
- Backend: Update /models/list to return ModelAccessListResponse
- Frontend: Display Read Only badge in Models list
- Frontend: Disable inputs and save button when no write access
- Frontend: Hide action buttons for read-only models

* fix: Handle ModelAccessListResponse format in getModels API

- Backend returns {items, total} instead of {data}
- Update getModels API to handle both formats for backward compatibility

* fix: Show read-only shared models in workspace list

- Backend: Change search_models permission from 'write' to 'read' to include shared models
- Backend: Keep user_id filter to only show owned/shared models (not all public)
- Frontend: Handle ModelAccessListResponse format in getModels API

* fix: Align Read Only badge inline with model name

* fix: Correct badge placement and fix syntax error

* fix: Resolve badge truncation in Models list

- Add w-full to flex container for proper spacing
- Wrap Badge in div to prevent truncation
- Match Knowledge.svelte badge pattern

* fix: Align Read Only badge with Knowledge.svelte pattern

- Match Knowledge.svelte structure for badge placement
- Actions only show when write_access or admin
- Remove w-full from container to prevent right-overflow

* fix: Return write_access from getModelById endpoint

fix: Return write_access from getModelById endpoint

- Use ModelAccessResponse instead of raw dict
- Remove inefficient getModels call in edit page

* revert

* fix

* fix

* fix
This commit is contained in:
Classic298
2026-01-05 20:37:41 +01:00
committed by GitHub
parent 8ef0f7743b
commit cd5a38a694
3 changed files with 123 additions and 89 deletions

View File

@@ -130,6 +130,10 @@ class ModelUserResponse(ModelModel):
user: Optional[UserResponse] = None
class ModelAccessResponse(ModelUserResponse):
write_access: Optional[bool] = False
class ModelResponse(ModelModel):
pass
@@ -139,6 +143,11 @@ class ModelListResponse(BaseModel):
total: int
class ModelAccessListResponse(BaseModel):
items: list[ModelAccessResponse]
total: int
class ModelForm(BaseModel):
id: str
base_model_id: Optional[str] = None
@@ -292,7 +301,7 @@ class ModelsTable:
db,
query,
filter,
permission="write",
permission="read",
)
tag = filter.get("tag")

View File

@@ -11,6 +11,8 @@ from open_webui.models.models import (
ModelModel,
ModelResponse,
ModelListResponse,
ModelAccessListResponse,
ModelAccessResponse,
Models,
)
@@ -51,7 +53,7 @@ PAGE_ITEM_COUNT = 30
@router.get(
"/list", response_model=ModelListResponse
"/list", response_model=ModelAccessListResponse
) # do NOT use "/" as path, conflicts with main.py
async def get_models(
query: Optional[str] = None,
@@ -88,7 +90,21 @@ async def get_models(
filter["user_id"] = user.id
return Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db)
result = Models.search_models(user.id, filter=filter, skip=skip, limit=limit, db=db)
return ModelAccessListResponse(
items=[
ModelAccessResponse(
**model.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
),
)
for model in result.items
],
total=result.total,
)
###########################
@@ -290,7 +306,7 @@ class ModelIdForm(BaseModel):
# Note: We're not using the typical url path param here, but instead using a query parameter to allow '/' in the id
@router.get("/model", response_model=Optional[ModelResponse])
@router.get("/model", response_model=Optional[ModelAccessResponse])
async def get_model_by_id(
id: str, user=Depends(get_verified_user), db: Session = Depends(get_session)
):
@@ -301,7 +317,14 @@ async def get_model_by_id(
or model.user_id == user.id
or has_access(user.id, "read", model.access_control, db=db)
):
return model
return ModelAccessResponse(
**model.model_dump(),
write_access=(
(user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL)
or user.id == model.user_id
or has_access(user.id, "write", model.access_control, db=db)
),
)
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,

View File

@@ -44,6 +44,7 @@
import ViewSelector from './common/ViewSelector.svelte';
import TagSelector from './common/TagSelector.svelte';
import Pagination from '../common/Pagination.svelte';
import Badge from '$lib/components/common/Badge.svelte';
let shiftKey = false;
@@ -457,14 +458,10 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
class=" flex cursor-pointer dark:hover:bg-gray-850/50 hover:bg-gray-50 transition rounded-2xl w-full p-2.5"
class="flex transition rounded-2xl w-full p-2.5 {model.write_access ? 'cursor-pointer dark:hover:bg-gray-850/50 hover:bg-gray-50' : 'cursor-not-allowed opacity-60'}"
id="model-item-{model.id}"
on:click={() => {
if (
$user?.role === 'admin' ||
model.user_id === $user?.id ||
model.access_control.write.group_ids.some((wg) => groupIds.includes(wg))
) {
if (model.write_access) {
goto(`/workspace/models/edit?id=${encodeURIComponent(model.id)}`);
}
}}
@@ -499,91 +496,95 @@
</a>
</Tooltip>
<div class=" flex items-center gap-1">
<div
class="flex justify-end w-full {model.is_active ? '' : 'text-gray-500'}"
>
<div class="flex justify-between items-center w-full">
<div class=""></div>
<div class="flex flex-row gap-0.5 items-center">
{#if shiftKey}
<Tooltip
content={model?.meta?.hidden
? $i18n.t('Show')
: $i18n.t('Hide')}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={(e) => {
e.stopPropagation();
hideModelHandler(model);
}}
>
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
</button>
</Tooltip>
<div class="flex items-center gap-1">
{#if !model.write_access}
<div>
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
{/if}
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={(e) => {
e.stopPropagation();
deleteModelHandler(model);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
<ModelMenu
user={$user}
{model}
editHandler={() => {
goto(
`/workspace/models/edit?id=${encodeURIComponent(model.id)}`
);
}}
shareHandler={() => {
shareModelHandler(model);
}}
cloneHandler={() => {
cloneModelHandler(model);
}}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
{#if model.write_access || $user?.role === 'admin'}
<div class="flex {model.is_active ? '' : 'text-gray-500'}">
<div class="flex items-center gap-0.5">
{#if shiftKey}
<Tooltip
content={model?.meta?.hidden
? $i18n.t('Show')
: $i18n.t('Hide')}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={(e) => {
e.stopPropagation();
hideModelHandler(model);
}}
pinModelHandler={() => {
pinModelHandler(model.id);
}}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
deleteHandler={() => {
selectedModel = model;
showModelDeleteConfirm = true;
}}
onClose={() => {}}
>
<div
class="self-center w-fit p-1 text-sm dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
>
<EllipsisHorizontal className="size-5" />
</div>
</ModelMenu>
{/if}
</div>
{#if model?.meta?.hidden}
<EyeSlash />
{:else}
<Eye />
{/if}
</button>
</Tooltip>
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm p-1.5 dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={(e) => {
e.stopPropagation();
deleteModelHandler(model);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
<ModelMenu
user={$user}
{model}
editHandler={() => {
goto(
`/workspace/models/edit?id=${encodeURIComponent(model.id)}`
);
}}
shareHandler={() => {
shareModelHandler(model);
}}
cloneHandler={() => {
cloneModelHandler(model);
}}
exportHandler={() => {
exportModelHandler(model);
}}
hideHandler={() => {
hideModelHandler(model);
}}
pinModelHandler={() => {
pinModelHandler(model.id);
}}
copyLinkHandler={() => {
copyLinkHandler(model);
}}
deleteHandler={() => {
selectedModel = model;
showModelDeleteConfirm = true;
}}
onClose={() => {}}
>
<div
class="self-center w-fit p-1 text-sm dark:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
>
<EllipsisHorizontal className="size-5" />
</div>
</ModelMenu>
{/if}
</div>
</div>
{/if}
{#if model.write_access}
<button
on:click={(e) => {
e.stopPropagation();
@@ -607,6 +608,7 @@
/>
</Tooltip>
</button>
{/if}
</div>
</div>