Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,14 @@ OWUI_ADMIN_TOKEN=
# 3144 prompt tokens against 52 without them. Set it to native only when the
# alias in use routes to a tool capable model.
OWUI_DEFAULT_FUNCTION_CALLING=legacy
# Catalog aliases the Open WebUI chat model picker must not list (#792).
# Comma-separated, exact alias ids. These stay on the gateway's own
# /v1/models, which is an OpenAI-contract surface direct API clients depend
# on; they are hidden only from the chat dropdown, where none of them can
# serve a completion. The image patch also hides whatever RAG_EMBEDDING_MODEL
# names, so the admin-selected embedding alias never needs a mention here.
# Leave unset to accept the compose default.
OWUI_PICKER_HIDDEN_ALIASES=hive-embedding-default,hive-stt,hive-tts
OWUI_E2E_MODE=false
# Open WebUI web search. Off by default -- this env var is shared with the
# enterprise profile, which must not silently make live outbound search
Expand Down
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,4 @@ test-scripts:
python3 scripts/test_owui_rag_env_config.py
python3 scripts/test_owui_ui_surfaces.py
python3 scripts/test_caddy_owui_blocklist.py
python3 scripts/test_owui_model_picker_filter.py
29 changes: 29 additions & 0 deletions deploy/docker/Dockerfile.open-webui
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,35 @@ RUN sed -i "s/or 'native'/or os.environ.get('HIVE_DEFAULT_FUNCTION_CALLING', 'na
&& ! grep -q "or 'native'" /app/backend/open_webui/main.py \
&& python3 -c "import ast, pathlib; ast.parse(pathlib.Path('/app/backend/open_webui/main.py').read_text())"

# #792: keep the three non-chat catalog aliases (hive-embedding-default,
# hive-stt, hive-tts) out of the chat model picker, while the gateway keeps
# serving all six on /v1/models for direct API clients.
#
# Open WebUI's own per-model access control cannot do this job on this
# deployment, which is why #776 shipped inert. docker-compose.yml sets
# BYPASS_MODEL_ACCESS_CONTROL: "true" by design, and in this pinned image that
# skips get_filtered_models on /api/models for every role; even with the flag
# off, get_filtered_models exempts administrators whenever
# BYPASS_ADMIN_ACCESS_CONTROL is set, which defaults to true, and the
# tenant-role patch above makes every tenant owner an administrator. Measured
# on a booted v0.10.2 container: with the flag off an administrator still saw
# all six aliases while a member saw an empty picker and got HTTP 400 "Model
# not found" on every chat request, so turning the flag off buys nothing and
# breaks chat for members.
#
# The patch filters the /api/models response and nothing else. It does not
# touch request.app.state.MODELS, so chat, document RAG embeddings and
# text-to-speech all still resolve every alias. The hidden set comes from the
# environment (HIVE_PICKER_HIDDEN_MODEL_IDS plus Open WebUI's own
# RAG_EMBEDDING_MODEL / AUDIO_TTS_MODEL / AUDIO_STT_MODEL), never from a
# hardcoded list, so the admin-selectable embedding alias stays single-sourced
# (D-001).
COPY deploy/docker/owui-patches/hive_model_picker.py /app/backend/open_webui/utils/hive_model_picker.py
COPY deploy/docker/owui-patches/apply_model_picker_patch.py /tmp/apply_model_picker_patch.py
RUN python3 /tmp/apply_model_picker_patch.py \
&& grep -q 'hive_model_picker' /app/backend/open_webui/main.py \
&& python3 -c "import ast, pathlib; ast.parse(pathlib.Path('/app/backend/open_webui/main.py').read_text())"

# #771: drop the Settings > Integrations tab, the chat UI's only entry point to
# "Manage Tool Servers" and "Open Terminal". Both accept an arbitrary URL and an
# auth credential. The tool servers added there are direct ones, called by the
Expand Down
20 changes: 20 additions & 0 deletions deploy/docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,26 @@ services:
# never filtered anything; the filtering was Open WebUI's default access
# control the whole time.
BYPASS_MODEL_ACCESS_CONTROL: "true"
# #792: the one thing the gateway list cannot express. All six aliases
# are legitimately on /v1/models -- that endpoint is an OpenAI-contract
# surface and direct API clients need the embedding and audio ids -- but
# three of them serve no chat completion, so listing them in the chat
# dropdown only produces dead conversations. Dockerfile.open-webui's
# owui-patches/apply_model_picker_patch.py drops these ids from the
# /api/models response the picker reads, and from nothing else.
#
# Not Open WebUI's per-model access control, which is what #776 tried:
# the flag above disables it for every role, and it is admin-exempt
# regardless (BYPASS_ADMIN_ACCESS_CONTROL defaults true) while every
# tenant owner here is an Open WebUI admin. That fix could never fire.
#
# The patch also hides whatever RAG_EMBEDDING_MODEL, AUDIO_TTS_MODEL and
# AUDIO_STT_MODEL name, so changing the admin-selected embedding alias
# (D-001) does not need a second edit here. This list is for aliases
# Open WebUI itself never calls; hive-stt and hive-tts are named because
# this deployment routes voice through the gateway rather than through
# Open WebUI's own audio settings.
HIVE_PICKER_HIDDEN_MODEL_IDS: ${OWUI_PICKER_HIDDEN_ALIASES:-hive-embedding-default,hive-stt,hive-tts}
ENABLE_EVALUATION_ARENA_MODELS: "false"
# Web search. Opt-in via .env, default off (same pattern as
# OWUI_E2E_MODE below) -- this service block is shared with the
Expand Down
129 changes: 129 additions & 0 deletions deploy/docker/owui-patches/apply_model_picker_patch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Build-time splice: filter Hive's non-chat aliases out of the listing Open
WebUI's chat model picker reads (issue #792).

The anchor is inside `main.py`'s `/api/models` handler, so the filter runs on
that response and on nothing else. It is inserted after `get_filtered_models`
rather than replacing it, and it is inserted UNCONDITIONALLY: the Hive filter
must not inherit upstream's access-control gating, which
`BYPASS_MODEL_ACCESS_CONTROL: "true"` disables for every role on this
deployment and which exempts administrators regardless. That gating is exactly
why #776's `access_control` mechanism shipped inert, and `assert_unconditional`
below is what stops this one from repeating it.

Asserts its own effect and fails the build otherwise, the same posture as this
Dockerfile's other patches: a future open-webui digest bump whose `/api/models`
handler shifted breaks the build loudly rather than silently restoring the
three aliases to the dropdown.

The transform lives in `patch()` so `scripts/test_owui_model_picker_filter.py`
can run the real thing against a checked-in excerpt of the pinned image's own
`main.py`. PR CI never builds this image, so without that the patch would be
verified only at deploy time.
"""

import ast
import pathlib
import re
import sys

TARGET = pathlib.Path("/app/backend/open_webui/main.py")

SIGNATURE = "async def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)):\n"
ANCHOR = " models = await get_filtered_models(models, user)\n"
RETURN = " return {'data': models}\n"
CALL = " models = _hive_filter_models(models, os.environ)\n"

INSERT = (
""" # hive #792: the three non-chat catalog aliases (embeddings, STT, TTS)
# must not appear in the chat model picker, while the gateway keeps
# serving all six on /v1/models for direct API clients. This runs on the
# response only -- request.app.state.MODELS is untouched, so every
# invocation path (chat, document RAG embeddings, text-to-speech) is
# unaffected. Unconditional by design: the access-control filter above is
# disabled deployment-wide by BYPASS_MODEL_ACCESS_CONTROL and is
# admin-exempt regardless, and every tenant owner here is an admin.
from open_webui.utils.hive_model_picker import filter_models as _hive_filter_models

"""
+ CALL
)


def handler_body(text: str) -> str:
"""Return the source of main.py's /api/models handler."""
assert text.count(SIGNATURE) == 1, (
"the /api/models handler is not defined exactly once with the expected "
"signature -- upstream open-webui source shifted, patch needs updating"
)
start = text.index(SIGNATURE) + len(SIGNATURE)
next_top_level = re.search(r"\n@|\n\S", text[start:])
end = start + next_top_level.start() if next_top_level else len(text)
return text[start:end]


def assert_unconditional(body: str) -> None:
"""Fail unless the Hive filter runs on every request to this handler.

This is the guard #776 did not have. Its mechanism was real code that a
deployment flag switched off, and every test it shipped with still passed.
Any future edit that puts the call behind a flag, a role check, or an
access-control branch trips this, because the statement would no longer sit
at the handler's own indentation level.
"""
for line in body.splitlines(keepends=True):
if line.lstrip().startswith("models = _hive_filter_models("):
assert line == CALL.rstrip("\n") + "\n" or line == CALL, (
"the hive picker filter is indented deeper than the handler "
"body, so something now gates it. It must run for every "
"request: BYPASS_MODEL_ACCESS_CONTROL and "
"BYPASS_ADMIN_ACCESS_CONTROL between them disable every "
"conditional Open WebUI offers here (issue #792)."
)
return
raise AssertionError("the hive picker filter call is not in the /api/models handler")


def patch(text: str) -> str:
"""Return main.py with the picker filter spliced into /api/models."""
body = handler_body(text)

assert text.count(ANCHOR) == 1, (
"the 'models = await get_filtered_models(models, user)' anchor is not "
"present exactly once -- upstream open-webui source shifted, patch "
"needs updating"
)
assert ANCHOR in body, (
"the get_filtered_models anchor is not inside the /api/models "
"handler's own body -- upstream open-webui source shifted, patch needs "
"updating"
)
assert RETURN in body, (
"the /api/models handler no longer returns {'data': models} -- the "
"filter would run on a value the response does not use, patch needs "
"updating"
)
assert "@app.get('/api/models')\n" in text, (
"main.py no longer mounts GET /api/models -- patch needs updating"
)
assert re.search(r"^import os$", text, re.MULTILINE), (
"main.py no longer imports os -- patch needs updating"
)

patched = text.replace(ANCHOR, ANCHOR + INSERT, 1)
patched_body = handler_body(patched)
assert_unconditional(patched_body)
assert patched_body.index(CALL) > patched_body.index(ANCHOR), (
"the filter must run after upstream's own filtering, not before"
)
assert patched_body.index(CALL) < patched_body.index(RETURN), (
"the filter must run before the handler returns, or the response is "
"unchanged -- which is precisely how #776 shipped inert"
)
ast.parse(patched) # never write a main.py that cannot be imported
return patched


if __name__ == "__main__":
TARGET.write_text(patch(TARGET.read_text()))
sys.stdout.write("hive #792: /api/models picker filter spliced into main.py\n")
92 changes: 92 additions & 0 deletions deploy/docker/owui-patches/hive_model_picker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Keep Hive's non-chat catalog aliases out of Open WebUI's chat model picker
(issues #772, #792).

The gateway serves one model list for every client, and it must keep doing
that: `GET /v1/models` on the API origin is an OpenAI-contract surface that
`packages/openai-contract/matrix/support-matrix.json` marks supported, and
upstream OpenAI lists its embedding and audio model ids there too. Direct API
clients must keep seeing `hive-embedding-default`, `hive-stt` and `hive-tts`.
The chat picker is the only surface where listing them is wrong, because none
of the three serves chat completions and picking one produces a dead
conversation.

So the filter belongs on the listing the picker reads and nowhere else.
`/api/models` in Open WebUI's `main.py` is exactly that listing: it is built
from `request.app.state.MODELS`, but it does not write it back, so removing an
entry here removes it from the dropdown while leaving every invocation path
untouched. Nothing in Open WebUI resolves a model for a chat completion, a RAG
embedding, or a text-to-speech call through this response.

Why not Open WebUI's own per-model access control, which is what #776 tried:
`deploy/docker/docker-compose.yml` sets `BYPASS_MODEL_ACCESS_CONTROL: "true"`
by deliberate design (the gateway is the single source of truth for model
visibility, D-014), and in the pinned v0.10.2 image that flag makes
`main.py` skip `get_filtered_models` entirely, for every role. Even with the
flag off, `get_filtered_models` exempts administrators whenever
`BYPASS_ADMIN_ACCESS_CONTROL` is set, and that variable defaults to true
(`config.py:2029`) while this deployment promotes every tenant owner to an
Open WebUI administrator. Measured on a booted v0.10.2 container: with the
flag off, an administrator still saw all six aliases and a member saw an
empty picker and got HTTP 400 "Model not found" on every chat request. So an
access-control-shaped fix cannot hide anything from the people who use the
product, and turning the flag off to reach it breaks chat for everyone else.

This filter is therefore unconditional. It reads no access-control flag, no
role, and no group membership, because every one of those is either bypassed
or admin-exempt on this deployment.

The hidden set is never hardcoded here. It is the union of

* `HIVE_PICKER_HIDDEN_MODEL_IDS`, a comma-separated list compose owns, and
* whichever aliases Open WebUI is itself configured to call for a non-chat
purpose (`RAG_EMBEDDING_MODEL`, `AUDIO_TTS_MODEL`, `AUDIO_STT_MODEL`).

The second half matters because the RAG embedding alias is admin-selectable
and drives vector-store provisioning (D-001), so a deployment that changes it
must not have to remember to edit a second list to keep the picker clean.
An unset or blank variable contributes nothing, so a deployment that sets none
of them keeps upstream behaviour exactly.
"""

# Comma-separated list of model ids compose wants out of the chat picker.
HIDDEN_IDS_ENV = "HIVE_PICKER_HIDDEN_MODEL_IDS"

# Open WebUI's own settings for the three non-chat modalities. Anything named
# here is by definition not a chat model, so it is hidden without needing a
# second mention in HIDDEN_IDS_ENV.
MODALITY_MODEL_ENV = (
"RAG_EMBEDDING_MODEL",
"AUDIO_TTS_MODEL",
"AUDIO_STT_MODEL",
)


def hidden_model_ids(environ) -> frozenset:
"""Model ids the chat picker must not list, from the environment alone."""
hidden = set()

for entry in (environ.get(HIDDEN_IDS_ENV) or "").split(","):
entry = entry.strip()
if entry:
hidden.add(entry)

for variable in MODALITY_MODEL_ENV:
value = (environ.get(variable) or "").strip()
if value:
hidden.add(value)

return frozenset(hidden)


def filter_models(models, environ):
"""Drop the hidden ids from a `/api/models` list.

Returns the input unchanged when nothing is configured, so this is a no-op
on a deployment that sets none of the variables above. Entries without an
`id` are passed through rather than dropped: this filter exists to remove
three known aliases, not to police the shape of upstream's payload.
"""
hidden = hidden_model_ids(environ)
if not hidden:
return models
return [model for model in models if model.get("id") not in hidden]
7 changes: 7 additions & 0 deletions deploy/docker/owui-patches/pinned-main-excerpts.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"image": "ghcr.io/open-webui/open-webui:v0.10.2@sha256:9fcea9c6e32ab60b0498f3986c6cdf651ddbe61db48d2213a3d28048ddd673d4",
"source": "/app/backend/open_webui/main.py",
"note": "Verbatim excerpt of the pinned image's /api/models handler, the listing Open WebUI's chat model picker reads. PR CI never builds Dockerfile.open-webui, so scripts/test_owui_model_picker_filter.py runs apply_model_picker_patch.patch against this instead of against an image nothing in CI has. Regenerate on any digest bump. The leading 'import os' is not from upstream's excerpt window; it stands in for main.py's own import so the excerpt parses on its own.",
"sha256": "3d26fd33a5ede734d1a2b9c828a63e470eb1ca4df1f8414b8141d7f3b08d8810",
"api_models_handler": "import os\n\n\n@app.get('/api/models')\n@app.get('/api/v1/models') # Experimental: Compatibility with OpenAI API\nasync def get_models(request: Request, refresh: bool = False, user=Depends(get_verified_user)):\n all_models = await get_all_models(request, refresh=refresh, user=user)\n\n models = []\n for model in all_models:\n # Filter out filter pipelines\n if 'pipeline' in model and model['pipeline'].get('type', None) == 'filter':\n continue\n\n # Remove profile image URL to reduce payload size\n if model.get('info', {}).get('meta', {}).get('profile_image_url'):\n model['info']['meta'].pop('profile_image_url', None)\n\n try:\n model_tags = [tag.get('name') for tag in model.get('info', {}).get('meta', {}).get('tags', [])]\n tags = [tag.get('name') for tag in model.get('tags', [])]\n\n tags = list(set(model_tags + tags))\n model['tags'] = [{'name': tag} for tag in tags]\n except Exception as e:\n log.debug(f'Error processing model tags: {e}')\n model['tags'] = []\n pass\n\n models.append(model)\n\n # Chat requests resolve models by ID from request.app.state.MODELS, where\n # duplicate IDs collapse to the last model. Return the same effective list.\n models = list({model['id']: model for model in models}.values())\n\n model_order_list = await Config.get('ui.model_order_list')\n if model_order_list:\n model_order_dict = {model_id: i for i, model_id in enumerate(model_order_list)}\n # Sort models by order list priority, with fallback for those not in the list\n models.sort(\n key=lambda model: (\n model_order_dict.get(model.get('id', ''), float('inf')),\n (model.get('name', '') or ''),\n )\n )\n\n models = await get_filtered_models(models, user)\n\n log.debug(\n f'/api/models returned filtered models accessible to the user: {json.dumps([model.get(\"id\") for model in models])}'\n )\n return {'data': models}\n\n\n"
}
Binary file added docs/proof/issue-792/01-before-picker-owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/proof/issue-792/02-after-picker-owner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading