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
12 changes: 12 additions & 0 deletions api/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3807,6 +3807,18 @@ def _read_custom_endpoint_models(
"models": models_for_group,
}
)
elif pid == "custom" and cfg_base_url:
# Anonymous custom endpoint: /v1/models probe may have
# failed (e.g. llama-server, lightweight relay), but the
# chat endpoint itself may still work. Add the group
# with an empty model list so the user can type a model
# ID manually rather than being blocked by a silent
# probe failure (#2542).
groups.append({
"provider": provider_name,
"provider_id": pid,
"models": [],
})
else:
if default_model:
label = _get_label_for_model(default_model, groups)
Expand Down
6 changes: 6 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@
<span class="app-titlebar-sub" id="appTitlebarSub" hidden></span>
</div>
<div class="app-titlebar-spacer" aria-hidden="true"></div>
<button class="app-titlebar-reload" id="btnReload" onclick="window.location.reload()" type="button" aria-label="Reload" title="Reload page">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" width="16" height="16">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
</header>
<div class="layout">
<nav class="rail" aria-label="Primary navigation">
Expand Down
12 changes: 12 additions & 0 deletions static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -425,6 +425,18 @@
@media (display-mode: standalone), (display-mode: fullscreen){
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
}
/* Pull-to-refresh indicator for PWA standalone mode */
.pull-to-refresh-indicator{position:absolute;top:0;left:0;right:0;z-index:100;display:flex;align-items:center;justify-content:center;gap:8px;padding:12px;font-size:13px;color:var(--muted);pointer-events:none;opacity:0;transform:translateY(-100%);transition:opacity .2s,transform .2s;background:var(--bg);border-bottom:1px solid var(--border);}
.pull-to-refresh-indicator.active{opacity:1;transform:translateY(0);}
.pull-to-refresh-indicator .ptr-icon{display:inline-block;transition:transform .2s;font-size:16px;}
.pull-to-refresh-indicator .ptr-icon.ready{transform:rotate(180deg);color:var(--accent);}
/* Reload button — visible only in PWA standalone / fullscreen */
.app-titlebar-reload{display:none;align-items:center;justify-content:center;width:32px;height:32px;flex-shrink:0;background:none;border:none;color:var(--muted);border-radius:8px;cursor:pointer;padding:0;-webkit-tap-highlight-color:transparent;transition:background-color .15s,color .15s;}
.app-titlebar-reload:hover{background:var(--hover-bg);color:var(--text);}
@media (display-mode: standalone), (display-mode: fullscreen){
:root{--app-titlebar-safe-top:env(safe-area-inset-top,0px);}
.app-titlebar-reload{display:inline-flex;}
}
}
html{background:var(--sidebar);}
body{background:var(--bg);color:var(--text);height:100vh;height:100dvh;overflow:hidden;display:flex;flex-direction:column;}
Expand Down
64 changes: 63 additions & 1 deletion static/ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -1914,7 +1914,69 @@ if(typeof document!=='undefined'){
// prevent the new chat's first scroll comparing against the previous chat's
// scrollTop (Opus stage-302 SHOULD-FIX, #1731 follow-up).
function _resetScrollDirectionTracker(){ _lastScrollTop=null; }
if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetScrollDirectionTracker;
if(typeof window!=='undefined') window._resetScrollDirectionTracker=_resetScrollDirectionTracker;
/* ── Pull-to-refresh for PWA standalone (Android) ── */
(function(){
if(typeof document==='undefined') return;
const isStandalone=window.navigator?.standalone||matchMedia('(display-mode:standalone),(display-mode:fullscreen)').matches;
if(!isStandalone) return;
const el=document.getElementById('messages');
if(!el) return;
let _ptrState=0; // 0=idle, 1=pulling, 2=ready
let _ptrStartY=0;
let _ptrCurrentY=0;
const THRESHOLD=80;
let _indicator=null;
function _ptrCreateIndicator(){
if(_indicator) return;
_indicator=document.createElement('div');
_indicator.className='pull-to-refresh-indicator';
_indicator.innerHTML='<span class="ptr-icon">↓</span> <span class="ptr-text">Pull to refresh</span>';
el.parentNode.insertBefore(_indicator,el);
}
function _ptrUpdate(progress){
_ptrCreateIndicator();
const pulling=progress<1;
_indicator.classList.toggle('active',progress>0);
const icon=_indicator.querySelector('.ptr-icon');
const text=_indicator.querySelector('.ptr-text');
if(icon) icon.classList.toggle('ready',!pulling);
if(text) text.textContent=pulling?'Pull to refresh':'Release to refresh';
}
function _ptrReset(){
_ptrState=0;
_ptrStartY=0;
_ptrCurrentY=0;
if(_indicator) _indicator.classList.remove('active');
}
el.addEventListener('touchstart',function(e){
if(el.scrollTop>0||_ptrState!==0) return;
_ptrStartY=e.touches[0].clientY;
_ptrState=1;
},{passive:true});
el.addEventListener('touchmove',function(e){
if(_ptrState!==1) return;
_ptrCurrentY=e.touches[0].clientY;
const pull=_ptrCurrentY-_ptrStartY;
if(pull<0){ _ptrReset(); return; }
/* If not at the top, smooth-scroll to top first.
Next pull gesture will trigger the refresh. */
if(el.scrollTop>0){
el.scrollTo({top:0,behavior:'smooth'});
_ptrReset();
return;
}
const progress=Math.min(pull/THRESHOLD,1);
_ptrUpdate(progress);
_ptrState=progress>=1?2:1;
if(progress>0.3) e.preventDefault();
},{passive:false});
el.addEventListener('touchend',function(){
if(_ptrState===2){ window.location.reload(); return; }
_ptrReset();
},{passive:true});
el.addEventListener('touchcancel',_ptrReset,{passive:true});
})();
(function(){
const el=document.getElementById('messages');
if(!el) return;
Expand Down
33 changes: 33 additions & 0 deletions tests/test_issue2542_anonymous_custom_endpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Tests for anonymous custom endpoint fallback in get_available_models().

Verifies that when /v1/models probe fails for an anonymous custom endpoint
(configured via bare base_url, not custom_providers[]), the provider group
is still added to the picker with an empty model list so the user can type
a model ID manually (#2542).
"""

import re

REPO = __file__.rsplit("/", 2)[-3] if "/" in __file__ else "."
CONFIG_PY = open(f"{REPO}/api/config.py").read() if REPO != "." else ""


def test_custom_anonymous_endpoint_empty_models_fallback_in_get_available_models():
"""get_available_models() must add a 'custom' provider group with empty
models when cfg_base_url is set but the /v1/models probe returned nothing
(anonymous endpoint — no match in custom_providers[])."""
assert 'elif pid == "custom" and cfg_base_url:' in CONFIG_PY, (
"Anonymous custom endpoints with failed /v1/models probe must get "
"an empty-model fallback group instead of being silently dropped (#2542)"
)
# Verify the empty models dict is correct
assert '"models": []' in CONFIG_PY.split('elif pid == "custom" and cfg_base_url:')[1].split("groups.append")[1][:200], (
"The fallback group must have an empty models list"
)


def test_revert_custom_endpoint_fallback_comment():
"""The #2542 comment should reference the issue."""
assert "#2542" in CONFIG_PY, (
"The fallback should reference the issue number for traceability"
)
Loading