ai: only list custom GGUFs llama-server can load; reset context on custom select

Review found two gaps in the custom-model listing.

Any *.gguf basename was listed and accepted, but start_llama_server.sh
reads active_model back out of ai.yaml through read_config_value.sh.
PyYAML dumps non-ASCII as escapes the reader prints literally, the
reader strips " #..." before unquoting, and the start script's
traversal guard rejects any ".." run. Those names got a 200 from the
API and then "Model file does not exist" at startup. A shared
is_loadable_model_name check now filters the catalog loop and rejects
the name in manage_settings, so the UI never shows an activation that
cannot start. Directories named *.gguf are skipped too.

Selecting a custom model after a catalog model kept the previous
model's explicit context_size (e.g. 16384 from the 4B) because the
adopt branch was skipped when there was no catalog default. The
default is now always written on model selection, so a custom model
resets to null and the conservative default applies. The hint text
says so instead of claiming the default is used unconditionally.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-09-24 13:49:15 -06:00
parent 49ff97c3ad
commit 32e0f69abc
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
4 changed files with 90 additions and 8 deletions

View file

@ -187,9 +187,10 @@ export function ManageAi() {
is downloaded. Download a model, select it, then enable the assistant. Monitor
download progress on the <b>Downloads</b> page.</p>
<p>You can also copy your own GGUF file into <b>ai/models/</b>; it will appear here as a
<b>custom</b> model the next time this page loads. The model must support tool calling
(Qwen3, Llama 3.x, and Mistral instruct models do). Custom models use the default
context size unless you set <b>context_size</b> in <b>ai.yaml</b>.</p>
<b>custom</b> model the next time this page loads. Use a plain ASCII file name without
<b>#</b>, quotes, or <b>..</b>. The model must support tool calling (Qwen3, Llama 3.x,
and Mistral instruct models do). Selecting a custom model resets the context size to the
default; set <b>context_size</b> in <b>ai.yaml</b> to override it.</p>
</InfoMessage>
</>;
}

View file

@ -750,7 +750,8 @@ async def manage_catalog(_: Request):
# here on every request (no file refresh) so it appears in the Active Model dropdown.
catalog_names = {i['name'] for i in models}
for name, path in sorted(on_disk.items()):
if name not in catalog_names:
# Skip names start_llama_server.sh could not load (see is_loadable_model_name).
if name not in catalog_names and path.is_file() and catalog.is_loadable_model_name(name):
models.append(dict(
name=name,
tier='custom',
@ -788,11 +789,16 @@ async def manage_settings(_: Request, body: schema.AIManageSettingsRequest):
if body.active_model is not None:
if body.active_model and not (catalog.get_models_directory() / body.active_model).is_file():
raise ValidationError(f'Model is not downloaded: {body.active_model}')
if body.active_model and not catalog.is_loadable_model_name(body.active_model):
raise ValidationError(f'Model file name cannot be loaded by llama-server; rename it to plain ASCII'
f' without #, quotes, or "..": {body.active_model}')
config.active_model = body.active_model
# Each model has its own context default (the 4B runs 16k, the small tier 8k); selecting
# a model adopts it unless this request also sets context_size explicitly.
if body.context_size is None and (default := catalog.get_model_default_context(body.active_model)):
config.context_size = default
# a model adopts it unless this request also sets context_size explicitly. A custom model
# has no catalog default, so the previous model's explicit value is cleared rather than
# carried over.
if body.context_size is None:
config.context_size = catalog.get_model_default_context(body.active_model)
if body.enabled is not None:
config.enabled = body.enabled
if body.idle_unload_minutes is not None:

View file

@ -51,6 +51,24 @@ def get_models_directory() -> Path:
return get_media_directory() / 'ai/models'
# Printable ASCII only, minus the characters the shell config reader cannot round-trip.
# start_llama_server.sh reads active_model back out of ai.yaml with read_config_value.sh: PyYAML
# escapes non-ASCII (\xE9), the reader strips " #..." before unquoting, and quotes/backslashes
# survive as literal characters. The start script's traversal guard also rejects any ".." run.
_UNLOADABLE_CHARS = set('#"\'\\/')
def is_loadable_model_name(name: str) -> bool:
"""True when llama-server can be started with this file name from ai/models.
Catalog names always pass; this exists for GGUFs the user copies in by hand."""
if not name or not name.endswith('.gguf') or name != name.strip():
return False
if '..' in name or any(c in _UNLOADABLE_CHARS for c in name):
return False
return all(0x20 <= ord(c) < 0x7F for c in name)
async def fetch_models_manifest(url: str = None) -> dict:
"""Fetch the models manifest from the CDN and verify its GPG signature (map-manifest pattern)."""
url = url or MODELS_MANIFEST_URL

View file

@ -218,13 +218,70 @@ async def test_manage_catalog_lists_custom_models(async_client, test_directory,
assert custom['size'] == 40
assert custom['url'] is None
# It can be activated like any downloaded model; it has no catalog context default.
# Selecting a catalog model first stores its context default in ai.yaml ...
content = dict(active_model='Qwen3-1.7B-Q4_K_M.gguf')
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
assert response.json['context_size'] == 8_192
# ... and switching to the custom model clears it, since a custom model has no catalog
# default; it must not silently keep the previous model's value.
content = dict(active_model='My-Custom-Model-Q8_0.gguf')
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.status_code == HTTPStatus.OK
assert response.json['active_model'] == 'My-Custom-Model-Q8_0.gguf'
assert response.json['context_size'] is None
assert catalog.get_effective_context_size() == 8_192 # the conservative default
# An explicit context_size in the same request still wins for a custom model.
content = dict(active_model='My-Custom-Model-Q8_0.gguf', context_size=4_096)
request, response = await async_client.post('/api/ai/manage/settings', content=json.dumps(content))
assert response.json['context_size'] == 4_096
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
request, response = await async_client.get('/api/ai/manage/catalog')
custom = next(i for i in response.json['models'] if i['name'] == 'My-Custom-Model-Q8_0.gguf')
assert custom['active'] is True
@pytest.mark.parametrize('name,loadable', [
('My-Custom-Model-Q8_0.gguf', True),
('model (v2) [q4].gguf', True), # shell-safe once quoted by PyYAML
('café.gguf', False), # PyYAML dumps \xE9; read_config_value.sh prints the escape literally
('模型-Q4.gguf', False),
('model #1.gguf', False), # the reader strips " #..." before unquoting
('foo..bar.gguf', False), # start_llama_server.sh's traversal guard rejects any ".."
('it\'s.gguf', False),
('say "hi".gguf', False),
('back\\slash.gguf', False),
(' padded.gguf', False),
('model.bin', False),
])
def test_is_loadable_model_name(name, loadable):
"""Only names start_llama_server.sh can read back out of ai.yaml are loadable."""
assert catalog.is_loadable_model_name(name) is loadable
@pytest.mark.asyncio
async def test_manage_catalog_skips_unloadable_custom_models(async_client, test_directory, test_ai_config):
"""A copied-in GGUF whose name cannot round-trip through ai.yaml and the shell reader is neither
listed nor accepted, so the UI never reports an activation that llama-server would refuse."""
models_dir = test_directory / 'ai/models'
models_dir.mkdir(parents=True)
(models_dir / 'café.gguf').write_bytes(b'GGUF')
(models_dir / 'model #1.gguf').write_bytes(b'GGUF')
(models_dir / 'foo..bar.gguf').write_bytes(b'GGUF')
(models_dir / 'Fine-Model.gguf').write_bytes(b'GGUF')
(models_dir / 'a-directory.gguf').mkdir()
with mock.patch('modules.ai.catalog.fetch_models_manifest', side_effect=RuntimeError('offline')):
request, response = await async_client.get('/api/ai/manage/catalog')
names = [i['name'] for i in response.json['models']]
assert 'Fine-Model.gguf' in names
for bad in ('café.gguf', 'model #1.gguf', 'foo..bar.gguf', 'a-directory.gguf'):
assert bad not in names
for bad in ('café.gguf', 'model #1.gguf', 'foo..bar.gguf'):
request, response = await async_client.post('/api/ai/manage/settings',
content=json.dumps(dict(active_model=bad)))
assert response.status_code == HTTPStatus.BAD_REQUEST, bad
assert 'cannot be loaded' in response.json['error']