diff --git a/api/config.py b/api/config.py index 96ff96ef..92e4772b 100644 --- a/api/config.py +++ b/api/config.py @@ -2430,6 +2430,31 @@ def _custom_slug_rest_looks_like_host_port(rest: str) -> bool: return False +def _parse_provider_qualified_model_id(model_id: str) -> tuple[str, str] | None: + """Parse WebUI's ``@provider:model`` route hint into ``(model, provider)``. + + The provider segment can contain colons for named custom providers, while + the model segment can also contain colons for tags such as ``:free``. + Keep this parser shared with ``resolve_model_provider`` so any caller that + compares route-hinted model lanes uses the same grammar. + """ + candidate = str(model_id or "").strip() + if not candidate.startswith("@") or ":" not in candidate: + return None + inner = candidate[1:] + provider_hint, bare_model = inner.rsplit(":", 1) + if provider_hint.startswith("custom:") and provider_hint.count(":") >= 2: + _slug_rest = provider_hint[len("custom:"):] + if not _custom_slug_rest_looks_like_host_port(_slug_rest): + provider_hint, extra = provider_hint.rsplit(":", 1) + bare_model = f"{extra}:{bare_model}" + elif (provider_hint not in _PROVIDER_MODELS + and provider_hint not in _PROVIDER_DISPLAY + and not provider_hint.startswith("custom:")): + provider_hint, bare_model = inner.split(":", 1) + return bare_model, provider_hint + + def _get_provider_base_url(provider_id): """Look up the configured base_url for a provider (e.g. lmstudio). @@ -2667,18 +2692,9 @@ def resolve_model_provider(model_id: str) -> tuple: # # Exception: ``custom::`` is a single logical slug derived # from OpenAI ``base_url`` authority and contains no eaten model segments. - if model_id.startswith("@") and ":" in model_id: - inner = model_id[1:] - provider_hint, bare_model = inner.rsplit(":", 1) - if provider_hint.startswith("custom:") and provider_hint.count(":") >= 2: - _slug_rest = provider_hint[len("custom:"):] - if not _custom_slug_rest_looks_like_host_port(_slug_rest): - provider_hint, extra = provider_hint.rsplit(":", 1) - bare_model = f"{extra}:{bare_model}" - elif (provider_hint not in _PROVIDER_MODELS - and provider_hint not in _PROVIDER_DISPLAY - and not provider_hint.startswith("custom:")): - provider_hint, bare_model = inner.split(":", 1) + parsed_provider_hint = _parse_provider_qualified_model_id(model_id) + if parsed_provider_hint is not None: + bare_model, provider_hint = parsed_provider_hint if ( provider_hint.startswith("custom:") and config_base_url diff --git a/api/models.py b/api/models.py index 588cf2ae..4acdc75d 100644 --- a/api/models.py +++ b/api/models.py @@ -1571,8 +1571,12 @@ def _process_wakeup_pause_provider_part(value) -> str: def _process_wakeup_pause_lane(model=None, provider=None) -> tuple[str, str]: model_part = _process_wakeup_pause_part(model) provider_part = _process_wakeup_pause_provider_part(provider) - if model_part.startswith('@') and ':' in model_part: - provider_hint, bare_model = model_part[1:].rsplit(':', 1) + try: + parsed_route_hint = _cfg._parse_provider_qualified_model_id(model) + except Exception: + parsed_route_hint = None + if parsed_route_hint is not None: + bare_model, provider_hint = parsed_route_hint provider_hint = _process_wakeup_pause_provider_part(provider_hint) bare_model = _process_wakeup_pause_part(bare_model) if provider_hint and not provider_part: diff --git a/tests/test_issue3929_process_wakeup_pause.py b/tests/test_issue3929_process_wakeup_pause.py index d075303f..d5a2b4e7 100644 --- a/tests/test_issue3929_process_wakeup_pause.py +++ b/tests/test_issue3929_process_wakeup_pause.py @@ -402,6 +402,94 @@ def test_process_wakeup_pause_suppresses_at_provider_model_session(tmp_path, mon assert saved.process_wakeup_pause["suppressed_count"] == 1 +def test_process_wakeup_pause_suppresses_openrouter_tagged_model_session(tmp_path, monkeypatch): + session = Session( + session_id="wakeup_pause_openrouter_tagged_model", + workspace=str(tmp_path), + model="@openrouter:deepseek/deepseek-r1:free", + model_provider=None, + ) + pause = models.record_process_wakeup_provider_unavailable_pause( + session, + classification="credential_pool_empty", + model="deepseek/deepseek-r1:free", + provider="openrouter", + ) + assert pause is not None + session.save() + models.SESSIONS[session.session_id] = session + + def _unexpected_start_run(*_args, **_kwargs): + raise AssertionError("OpenRouter tagged wakeup must be suppressed on the paused lane") + + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_read_profile_model_config", lambda _s, _p: (None, None, {})) + monkeypatch.setattr( + routes, + "_resolve_compatible_session_model_state", + lambda *_args, **_kwargs: ("@openrouter:deepseek/deepseek-r1:free", None, False), + ) + monkeypatch.setattr(routes, "_start_run", _unexpected_start_run) + + response = routes.start_session_turn( + session.session_id, + "[IMPORTANT: Background process completed for OpenRouter tagged lane.]", + source="process_wakeup", + ) + + assert response["_status"] == 409 + assert response["error"] == PROCESS_WAKEUP_PAUSE_ERROR + saved = Session.load(session.session_id) + assert saved is not None + assert saved.process_wakeup_pause["model"] == "deepseek/deepseek-r1:free" + assert saved.process_wakeup_pause["provider"] == "openrouter" + assert saved.process_wakeup_pause["suppressed_count"] == 1 + + +def test_process_wakeup_pause_suppresses_custom_provider_tagged_model_session(tmp_path, monkeypatch): + session = Session( + session_id="wakeup_pause_custom_tagged_model", + workspace=str(tmp_path), + model="@custom:proxy:model:tag", + model_provider=None, + ) + pause = models.record_process_wakeup_provider_unavailable_pause( + session, + classification="credential_pool_empty", + model="model:tag", + provider="custom:proxy", + ) + assert pause is not None + session.save() + models.SESSIONS[session.session_id] = session + + def _unexpected_start_run(*_args, **_kwargs): + raise AssertionError("custom-provider tagged wakeup must be suppressed on the paused lane") + + monkeypatch.setattr(routes, "_resolve_chat_workspace_with_recovery", lambda _s, _w: str(tmp_path)) + monkeypatch.setattr(routes, "_read_profile_model_config", lambda _s, _p: (None, None, {})) + monkeypatch.setattr( + routes, + "_resolve_compatible_session_model_state", + lambda *_args, **_kwargs: ("@custom:proxy:model:tag", None, False), + ) + monkeypatch.setattr(routes, "_start_run", _unexpected_start_run) + + response = routes.start_session_turn( + session.session_id, + "[IMPORTANT: Background process completed for custom tagged lane.]", + source="process_wakeup", + ) + + assert response["_status"] == 409 + assert response["error"] == PROCESS_WAKEUP_PAUSE_ERROR + saved = Session.load(session.session_id) + assert saved is not None + assert saved.process_wakeup_pause["model"] == "model:tag" + assert saved.process_wakeup_pause["provider"] == "custom:proxy" + assert saved.process_wakeup_pause["suppressed_count"] == 1 + + def test_stale_credential_empty_process_wakeup_still_records_pause(tmp_path): session = Session( session_id="wakeup_pause_stale",