release #5511: exclude Copilot from providers: routing scan + regression tests + CHANGELOG
Gate-cert (nesquena-hermes) + Codex found 1 CORE: akay64's new providers: scan in resolve_model_provider() iterated ALL providers.<slug>.models INCLUDING providers.copilot.models — but that key is a documented per-model SETTINGS map (reasoning_effort/limits), NOT a routing allowlist (config.py exception). So a Copilot per-model settings entry hijacked that model's routing to Copilot (Codex reproduced gpt-5 -> copilot). Fix (Codex-exact): skip copilot in the scan. Added the regression tests the PR shipped 0 of: user-defined allowlist routes; active-openai + providers.copilot.models (dict + list shapes) stays openai (both verified to fail without the exclusion). Co-authored-by: akay64 <akay64@users.noreply.github.com>
This commit is contained in:
parent
ec9f77c319
commit
cca3b6c279
3 changed files with 73 additions and 0 deletions
|
|
@ -14,6 +14,7 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- **Models from a user-defined provider in `config.yaml` now route correctly.** If you defined a custom provider under the `providers:` block with an explicit `models:` allowlist, a bare model id from that list wasn't matched by request-time routing — it silently fell back to the default provider and often 404'd. Resolution now scans your `providers:` allowlists (exact match, same as `custom_providers:`), so those models reach the right endpoint. Copilot is deliberately excluded from this scan because `providers.copilot.models` is a per-model settings map (reasoning effort, limits), not a routing allowlist. Thanks @akay64. (#5511)
|
||||
- **The transcript no longer scrolls up when the composer grows.** When you were pinned to the bottom of a conversation and the composer got taller — multi-line typing, `Shift+Enter`, or a multi-line / dictation paste — the transcript viewport shrank by the same amount and left you stranded a row or two above the bottom (it read as the chat "randomly scrolling up" during normal use). The transcript now re-pins to the bottom whenever the composer grows, but only when you were genuinely still pinned there — if you'd scrolled up to read history, nothing yanks you back down. (#5516, #5514, #5515)
|
||||
- **A deleted conversation no longer comes back as a read-only "ghost" after a restart.** Deleting a WebUI session removed its file but left a recoverable row in the agent's state database, so after a server restart and page reload the deleted conversation could reappear as a read-only "Agent" session still showing its old transcript. Deleted WebUI sessions are now recorded in a durable tombstone that the sidebar projection, startup backup-recovery, audit, and session-claim paths all honor, so a deleted session stays gone (the backend keeps returning the 404 the client relies on to clear the stale route). Genuine crash recovery is unaffected — a session whose sidecar is lost without a delete is still restored — imported external-channel (messaging) sessions are exempt and keep their transcript, and a deleted id is automatically un-tombstoned if the same session is later re-created or re-imported. Thanks @rodboev. (#5504, #5498)
|
||||
- **A drag that ends over the sidebar no longer switches conversations by accident.** Releasing the mouse (a stray `pointerup`) over a session row after a drag that began elsewhere in the main content area could switch you into that conversation unintentionally. Session switching now ignores a `pointerup` that isn't a genuine click on the row. Thanks @webtecnica. (#5487, #5462)
|
||||
|
|
|
|||
|
|
@ -2572,6 +2572,13 @@ def resolve_model_provider(model_id: str) -> tuple:
|
|||
for slug, pdef in providers_cfg.items():
|
||||
if not isinstance(pdef, dict):
|
||||
continue
|
||||
# Copilot is the documented exception: `providers.copilot.models` is
|
||||
# a per-model SETTINGS map (reasoning_effort, limits, etc.), NOT a
|
||||
# routable allowlist (see the exception at the catalog-build site).
|
||||
# Scanning it here would let a Copilot per-model settings entry
|
||||
# hijack that model's routing away from its real provider (#5511).
|
||||
if _canonicalise_provider_id(slug) == "copilot":
|
||||
continue
|
||||
p_models = pdef.get('models')
|
||||
if isinstance(p_models, list):
|
||||
for m in p_models:
|
||||
|
|
|
|||
|
|
@ -748,3 +748,68 @@ def test_bare_custom_provider_no_base_url_with_known_prefix_keeps_custom_and_ful
|
|||
)
|
||||
assert model == 'google/gemma-2-9b'
|
||||
assert base_url is None
|
||||
|
||||
|
||||
# ── providers: (config.yaml user-defined provider) scan (#5511) ─────────────
|
||||
|
||||
def _resolve_with_providers(model_id, providers_cfg, *, provider=None, default=None):
|
||||
"""Helper: temporarily set config.cfg['providers'] + model, call resolve, restore."""
|
||||
old_cfg = dict(config.cfg)
|
||||
model_cfg = {}
|
||||
if provider:
|
||||
model_cfg['provider'] = provider
|
||||
if default:
|
||||
model_cfg['default'] = default
|
||||
config.cfg['model'] = model_cfg
|
||||
config.cfg['providers'] = providers_cfg
|
||||
try:
|
||||
return config.resolve_model_provider(model_id)
|
||||
finally:
|
||||
config.cfg.clear()
|
||||
config.cfg.update(old_cfg)
|
||||
|
||||
|
||||
def test_providers_scan_routes_user_defined_allowlist_5511():
|
||||
"""A user-defined providers.<slug>.models allowlist routes a bare model id
|
||||
to that provider (the feature #5511 adds)."""
|
||||
model, provider, base_url = _resolve_with_providers(
|
||||
'my-model-1',
|
||||
{'myprov': {'base_url': 'https://my.example/v1', 'models': ['my-model-1', 'my-model-2']}},
|
||||
provider='openai',
|
||||
default='gpt-5',
|
||||
)
|
||||
assert provider == 'myprov', f"user-defined provider allowlist must route; got {provider!r}"
|
||||
assert model == 'my-model-1'
|
||||
assert base_url == 'https://my.example/v1'
|
||||
|
||||
|
||||
def test_providers_scan_skips_copilot_settings_map_5511():
|
||||
"""providers.copilot.models is a per-model SETTINGS map, NOT a routable
|
||||
allowlist — a Copilot per-model settings entry must NOT hijack routing away
|
||||
from the model's real provider (#5511 gate-cert CORE finding)."""
|
||||
model, provider, base_url = _resolve_with_providers(
|
||||
'gpt-5',
|
||||
{'copilot': {'models': {'gpt-5': {'reasoning_effort': 'high'}}}},
|
||||
provider='openai',
|
||||
default='gpt-5',
|
||||
)
|
||||
assert provider == 'openai', (
|
||||
"Copilot settings-map entry must NOT hijack routing; "
|
||||
f"gpt-5 must stay on openai, got {provider!r}"
|
||||
)
|
||||
assert model == 'gpt-5'
|
||||
|
||||
|
||||
def test_providers_scan_copilot_list_shape_also_skipped_5511():
|
||||
"""Defense in depth: even if providers.copilot.models is a list shape, the
|
||||
Copilot exclusion still prevents a routing hijack."""
|
||||
model, provider, base_url = _resolve_with_providers(
|
||||
'gpt-5',
|
||||
{'copilot': {'models': ['gpt-5', 'gpt-5-mini']}},
|
||||
provider='openai',
|
||||
default='gpt-5',
|
||||
)
|
||||
assert provider == 'openai', (
|
||||
f"Copilot (list shape) must not hijack routing; got {provider!r}"
|
||||
)
|
||||
assert model == 'gpt-5'
|
||||
|
|
|
|||
Loading…
Reference in a new issue