Spaces:
Sleeping
Sleeping
Download test_app.py from HayalabCore/gemma-endpoint-test: direct link, hf CLI and curl.
- Browser
- Download file 28.6 kB
-
https://huggingface.co/spaces/HayalabCore/gemma-endpoint-test/resolve/main/test_app.py
- Command line
-
hf download hf://spaces/HayalabCore/gemma-endpoint-test/test_app.py
-
curl -L -o test_app.py https://huggingface.co/spaces/HayalabCore/gemma-endpoint-test/resolve/main/test_app.py
28.6 kB
| """Tests for the endpoint chat app. | |
| Run: pytest -q (the required environment is filled in below) | |
| """ | |
| import json | |
| import os | |
| import pytest | |
| import requests | |
| # Assigned outright so real values exported in the shell cannot leak in. | |
| os.environ.update( | |
| ENDPOINT_URL="http://endpoint.test/", | |
| QWEN_ENDPOINT_URL="http://qwen.endpoint.test/", | |
| DEEPSEEK_ENDPOINT_URL="http://deepseek.endpoint.test/", | |
| HF_TOKEN="test-token", | |
| QWEN_HF_TOKEN="qwen-org-token", | |
| ) | |
| os.environ.pop("DEEPSEEK_HF_TOKEN", None) | |
| os.environ.setdefault("LOGIN_PASSWORD", "s3cret") | |
| import app | |
| def drain(generator): | |
| """Consume a streaming handler, returning (all yields, final yield).""" | |
| frames = list(generator) | |
| return frames, frames[-1] | |
| def histories(frames): | |
| return [frame[0] for frame in frames] | |
| def textbox(frame): | |
| """The value the textbox update carries.""" | |
| update = frame[1] | |
| return update["value"] if isinstance(update, dict) else update | |
| def locked(frame): | |
| """True when the frame locks the input controls (a request is in flight).""" | |
| msg_update, send_update, stop_update = frame[1:] | |
| return ( | |
| msg_update["interactive"] is False | |
| and send_update["interactive"] is False | |
| and stop_update["interactive"] is True | |
| ) | |
| def drain_failing(generator, expected): | |
| """Consume a streaming handler that fails partway; returns frames seen.""" | |
| frames = [] | |
| with pytest.raises(expected): | |
| for frame in generator: | |
| frames.append(frame) # noqa: PERF402 - loop can raise mid-iteration | |
| return frames | |
| def fake_stream(*chunks): | |
| def stream(messages, model_name, turn="-"): | |
| yield from chunks | |
| return stream | |
| def test_build_api_messages_keeps_order_and_appends_new_turn(): | |
| history = [ | |
| {"role": "user", "content": "hi"}, | |
| {"role": "assistant", "content": "hello"}, | |
| ] | |
| assert app.build_api_messages(history, "how are you?") == [ | |
| {"role": "user", "content": "hi"}, | |
| {"role": "assistant", "content": "hello"}, | |
| {"role": "user", "content": "how are you?"}, | |
| ] | |
| def test_build_api_messages_flattens_gradio_typed_content(): | |
| """Gradio hands handlers typed content parts, not plain strings.""" | |
| import gradio as gr | |
| chatbot = gr.Chatbot() | |
| history = chatbot.preprocess( | |
| chatbot.postprocess( | |
| [ | |
| {"role": "user", "content": "hi"}, | |
| {"role": "assistant", "content": "hello"}, | |
| ] | |
| ) | |
| ) | |
| assert history[0]["content"] != "hi", "test no longer exercises typed parts" | |
| assert app.build_api_messages(history, "next") == [ | |
| {"role": "user", "content": "hi"}, | |
| {"role": "assistant", "content": "hello"}, | |
| {"role": "user", "content": "next"}, | |
| ] | |
| def test_build_api_messages_drops_non_text_and_malformed_entries(): | |
| history = [ | |
| {"role": "user", "content": [{"type": "file", "file": {"path": "a.png"}}]}, | |
| {"role": "system", "content": "ignored"}, | |
| {"role": "user"}, | |
| None, | |
| {"role": "assistant", "content": "kept"}, | |
| ] | |
| assert app.build_api_messages(history, "next") == [ | |
| {"role": "assistant", "content": "kept"}, | |
| {"role": "user", "content": "next"}, | |
| ] | |
| def test_message_text_handles_mixed_and_unknown_content(): | |
| assert app.message_text("plain") == "plain" | |
| assert app.message_text([{"type": "text", "text": "a"}, {"type": "file"}]) == "a" | |
| assert ( | |
| app.message_text([{"type": "text", "text": "a"}, {"type": "text", "text": "b"}]) | |
| == "ab" | |
| ) | |
| assert app.message_text([{"type": "text", "text": None}]) == "" | |
| assert app.message_text(None) == "" | |
| assert app.message_text(42) == "" | |
| def test_respond_streams_partial_replies_then_final(monkeypatch): | |
| monkeypatch.setattr(app, "stream_completion", fake_stream("Hel", "lo ", "there")) | |
| frames, final = drain(app.respond("ping", [], app.DEFAULT_MODEL)) | |
| history = final[0] | |
| # Assistant text grows monotonically across frames. | |
| assert [h[-1]["content"] for h in histories(frames)] == [ | |
| app.WAITING_TEXT, | |
| "Hel", | |
| "Hello ", | |
| "Hello there", | |
| "Hello there", | |
| ] | |
| assert history == [ | |
| {"role": "user", "content": "ping"}, | |
| {"role": "assistant", "content": "Hello there"}, | |
| ] | |
| assert textbox(final) == "" | |
| def test_respond_shows_pending_spinner_until_first_token(monkeypatch): | |
| monkeypatch.setattr(app, "stream_completion", fake_stream("first token")) | |
| frames, final = drain(app.respond("ping", [], app.DEFAULT_MODEL)) | |
| # Frame 1: user turn plus a pending placeholder that renders as a spinner. | |
| placeholder = frames[0][0][-1] | |
| assert placeholder["metadata"]["status"] == "pending" | |
| assert placeholder["metadata"]["title"] | |
| # Regression: the Chatbot with reasoning_tags silently dropped the empty | |
| # placeholder, so a sleeping endpoint showed no feedback at all. | |
| import gradio as gr | |
| chatbot = next(b for b in app.demo.blocks.values() if isinstance(b, gr.Chatbot)) | |
| rendered = chatbot.postprocess(frames[0][0]).root | |
| assert [m.metadata.get("status") if m.metadata else None for m in rendered] == [ | |
| None, | |
| "pending", | |
| ] | |
| # The spinner is gone the moment real text exists, and never comes back. | |
| for history in histories(frames[1:]): | |
| assert "metadata" not in history[-1], "spinner outlived the first token" | |
| assert "metadata" not in final[0][-1] | |
| def test_respond_locks_controls_only_while_in_flight(monkeypatch): | |
| monkeypatch.setattr(app, "stream_completion", fake_stream("a", "b")) | |
| frames, final = drain(app.respond("ping", [], app.DEFAULT_MODEL)) | |
| assert [locked(f) for f in frames] == [True, True, True, False] | |
| assert locked(final) is False | |
| def test_respond_unlocks_controls_when_message_is_blank(monkeypatch): | |
| monkeypatch.setattr(app, "stream_completion", fake_stream("unused")) | |
| _frames, final = drain(app.respond("", [], app.DEFAULT_MODEL)) | |
| assert locked(final) is False | |
| def test_respond_rejects_an_empty_reply(monkeypatch): | |
| import gradio as gr | |
| monkeypatch.setattr(app, "stream_completion", fake_stream()) | |
| frames = drain_failing(app.respond("ping", [], app.DEFAULT_MODEL), gr.Error) | |
| # No stuck spinner, no empty bubble, and the controls are released. | |
| assert frames[-1][0] == [] | |
| assert locked(frames[-1]) is False | |
| def test_respond_history_survives_gradio_format_check(monkeypatch): | |
| """Regression: tuple history raised 'Data incompatible with messages format'.""" | |
| import gradio as gr | |
| monkeypatch.setattr(app, "stream_completion", fake_stream("pong")) | |
| frames, _ = drain(app.respond("ping", [], app.DEFAULT_MODEL)) | |
| for history in histories(frames): | |
| gr.Chatbot()._check_format(history) | |
| def test_respond_sends_history_without_the_placeholder_turn(monkeypatch): | |
| sent = {} | |
| def stream(messages, model_name, turn="-"): | |
| sent["messages"] = messages | |
| yield "ok" | |
| monkeypatch.setattr(app, "stream_completion", stream) | |
| drain(app.respond("second", [{"role": "user", "content": "first"}], None)) | |
| assert sent["messages"] == [ | |
| {"role": "user", "content": "first"}, | |
| {"role": "user", "content": "second"}, | |
| ] | |
| def test_respond_ignores_blank_message(monkeypatch): | |
| def fail(*args, **kwargs): | |
| raise AssertionError("endpoint should not be called for a blank message") | |
| monkeypatch.setattr(app, "stream_completion", fail) | |
| _frames, final = drain( | |
| app.respond(" ", [{"role": "user", "content": "hi"}], None) | |
| ) | |
| assert final[0] == [{"role": "user", "content": "hi"}] | |
| def test_respond_does_not_mutate_caller_history(monkeypatch): | |
| monkeypatch.setattr(app, "stream_completion", fake_stream("pong")) | |
| history = [] | |
| drain(app.respond("ping", history, app.DEFAULT_MODEL)) | |
| assert history == [] | |
| def test_respond_surfaces_immediate_failures_and_restores_input(monkeypatch, error): | |
| import gradio as gr | |
| def stream(messages, model_name, turn="-"): | |
| raise error | |
| yield # pragma: no cover - makes this a generator | |
| monkeypatch.setattr(app, "stream_completion", stream) | |
| frames = drain_failing(app.respond("ping", [], app.DEFAULT_MODEL), gr.Error) | |
| # Nothing streamed, so the whole failed turn is rolled back and the user's | |
| # text is handed back to the input box rather than duplicated in the chat. | |
| assert frames[-1][0] == [] | |
| assert textbox(frames[-1]) == "ping" | |
| assert locked(frames[-1]) is False | |
| def test_respond_keeps_partial_reply_when_stream_breaks(monkeypatch): | |
| import gradio as gr | |
| def stream(messages, model_name, turn="-"): | |
| yield "half a " | |
| raise requests.exceptions.ChunkedEncodingError("dropped") | |
| monkeypatch.setattr(app, "stream_completion", stream) | |
| frames = drain_failing(app.respond("ping", [], app.DEFAULT_MODEL), gr.Error) | |
| assert frames[-1][0] == [ | |
| {"role": "user", "content": "ping"}, | |
| {"role": "assistant", "content": "half a "}, | |
| ] | |
| # A kept partial reply means the box is not refilled with the same text. | |
| assert textbox(frames[-1]) == "" | |
| assert locked(frames[-1]) is False | |
| def test_respond_reraises_unexpected_errors(monkeypatch): | |
| def stream(messages, model_name, turn="-"): | |
| raise RuntimeError("boom") | |
| yield # pragma: no cover | |
| monkeypatch.setattr(app, "stream_completion", stream) | |
| with pytest.raises(RuntimeError, match="boom"): | |
| drain(app.respond("ping", [], app.DEFAULT_MODEL)) | |
| def test_verify_login_rejects_wrong_and_empty_password(): | |
| assert app.verify_login("user", "s3cret") is True | |
| assert app.verify_login("user", "wrong") is False | |
| assert app.verify_login("user", "") is False | |
| assert app.verify_login("user", None) is False | |
| def test_missing_required_env_fails_with_named_message(monkeypatch): | |
| monkeypatch.delenv("ENDPOINT_URL", raising=False) | |
| with pytest.raises(RuntimeError, match="ENDPOINT_URL"): | |
| app._required_env("ENDPOINT_URL") | |
| class FakeStreamResponse: | |
| """Stand-in for the streaming `requests` response.""" | |
| def __init__(self, lines, status_code=200): | |
| self._lines = lines | |
| self.status_code = status_code | |
| self.closed = False | |
| def __enter__(self): | |
| return self | |
| def __exit__(self, *exc_info): | |
| self.closed = True | |
| def raise_for_status(self): | |
| pass | |
| def iter_lines(self, chunk_size=512, decode_unicode=False): | |
| self.iter_kwargs = {"chunk_size": chunk_size, "decode_unicode": decode_unicode} | |
| yield from self._lines | |
| def sse(*payloads): | |
| return [f"data: {p}" for p in payloads] | |
| def chunk(content): | |
| return json.dumps({"choices": [{"delta": {"content": content}}]}) | |
| def test_each_model_posts_to_its_own_endpoint(monkeypatch): | |
| captured = {} | |
| def fake_post(url, **kwargs): | |
| captured.update(url=url, **kwargs) | |
| return FakeStreamResponse(sse(chunk("hi"), "[DONE]")) | |
| monkeypatch.setattr(app.requests, "post", fake_post) | |
| expected = { | |
| app.GEMMA_MODEL: "http://endpoint.test", | |
| app.QWEN_MODEL: "http://qwen.endpoint.test", | |
| app.DEEPSEEK_MODEL: "http://deepseek.endpoint.test", | |
| } | |
| for model, url in expected.items(): | |
| list(app.stream_completion([{"role": "user", "content": "hi"}], model)) | |
| assert captured["url"] == f"{url}/v1/chat/completions" | |
| assert captured["json"]["model"] == model | |
| assert app.AVAILABLE_MODELS == [ | |
| app.GEMMA_MODEL, | |
| app.QWEN_MODEL, | |
| app.DEEPSEEK_MODEL, | |
| ] | |
| def test_each_model_sends_its_own_token(monkeypatch): | |
| """Endpoints in different orgs need tokens with access to that org.""" | |
| sent = {} | |
| def fake_post(url, **kwargs): | |
| sent[url] = kwargs["headers"]["Authorization"] | |
| return FakeStreamResponse(sse(chunk("hi"), "[DONE]")) | |
| monkeypatch.setattr(app.requests, "post", fake_post) | |
| for model in app.AVAILABLE_MODELS: | |
| list(app.stream_completion([], model)) | |
| assert sent == { | |
| "http://endpoint.test/v1/chat/completions": "Bearer test-token", | |
| "http://qwen.endpoint.test/v1/chat/completions": "Bearer qwen-org-token", | |
| # No DEEPSEEK_HF_TOKEN, so it falls back to HF_TOKEN. | |
| "http://deepseek.endpoint.test/v1/chat/completions": "Bearer test-token", | |
| } | |
| assert app.MODELS[app.QWEN_MODEL].token_env == "QWEN_HF_TOKEN" | |
| assert app.MODELS[app.DEEPSEEK_MODEL].token_env == "HF_TOKEN" | |
| def test_rejected_token_names_the_secret_to_fix(monkeypatch, status): | |
| import gradio as gr | |
| monkeypatch.setattr( | |
| app.requests, "post", lambda url, **kw: FakeStreamResponse([], status) | |
| ) | |
| with pytest.raises(gr.Error, match="QWEN_HF_TOKEN") as error: | |
| list(app.stream_completion([], app.QWEN_MODEL)) | |
| assert "qwen-org-token" not in str(error.value) | |
| def test_stream_completion_parses_sse_chunks(monkeypatch): | |
| lines = [ | |
| ": keep-alive", | |
| "", | |
| *sse(chunk("Hel"), chunk("lo")), | |
| "", | |
| # Final chunk carries a finish_reason and no content. | |
| json.dumps({"choices": [{"delta": {}, "finish_reason": "stop"}]}).join( | |
| ["data: ", ""] | |
| ), | |
| "data: [DONE]", | |
| "data: {bad json that must never be reached}", | |
| ] | |
| response = FakeStreamResponse(lines) | |
| captured = {} | |
| def fake_post(url, **kwargs): | |
| captured.update(url=url, **kwargs) | |
| return response | |
| monkeypatch.setattr(app.requests, "post", fake_post) | |
| assert list(app.stream_completion([{"role": "user", "content": "hi"}], "m")) == [ | |
| "Hel", | |
| "lo", | |
| ] | |
| assert captured["url"] == "http://endpoint.test/v1/chat/completions" | |
| assert captured["json"]["stream"] is True | |
| assert captured["stream"] is True | |
| assert captured["headers"]["Authorization"] == "Bearer test-token" | |
| assert response.closed | |
| def reasoning_chunk(text): | |
| return json.dumps({"choices": [{"delta": {"reasoning_content": text}}]}) | |
| def test_stream_completion_wraps_reasoning_in_think_tags(monkeypatch): | |
| """llama.cpp streams thinking as reasoning_content; the Chatbot needs tags.""" | |
| lines = sse(reasoning_chunk("7 x "), reasoning_chunk("143"), chunk("No."), "[DONE]") | |
| monkeypatch.setattr( | |
| app.requests, "post", lambda url, **kwargs: FakeStreamResponse(lines) | |
| ) | |
| assert "".join(app.stream_completion([], "m")) == "<think>7 x 143</think>No." | |
| def test_stream_completion_closes_reasoning_cut_off_mid_thought(monkeypatch): | |
| lines = sse(reasoning_chunk("still thinking"), "[DONE]") | |
| monkeypatch.setattr( | |
| app.requests, "post", lambda url, **kwargs: FakeStreamResponse(lines) | |
| ) | |
| assert "".join(app.stream_completion([], "m")) == "<think>still thinking</think>" | |
| def test_reasoning_is_not_sent_back_to_the_model(): | |
| """The Chatbot splits reasoning into a titled thought, which is dropped.""" | |
| import gradio as gr | |
| chatbot = gr.Chatbot(reasoning_tags=[(app.THINK_OPEN, app.THINK_CLOSE)]) | |
| history = chatbot.preprocess( | |
| chatbot.postprocess( | |
| [ | |
| {"role": "user", "content": "hi"}, | |
| {"role": "assistant", "content": "<think>hmm</think>hello"}, | |
| ] | |
| ) | |
| ) | |
| assert app.build_api_messages(history, "next") == [ | |
| {"role": "user", "content": "hi"}, | |
| {"role": "assistant", "content": "hello"}, | |
| {"role": "user", "content": "next"}, | |
| ] | |
| def test_stop_replaces_send_while_a_reply_streams(): | |
| _msg, send, stop = app.busy_controls() | |
| assert send["visible"] is False and stop["visible"] is True | |
| _msg, send, stop = app.idle_controls() | |
| assert send["visible"] is True and stop["visible"] is False | |
| def test_new_chat_empties_history_and_unlocks(): | |
| history, *controls = app.new_chat() | |
| assert history == [] | |
| assert controls[0]["interactive"] is True | |
| def test_every_model_has_a_display_label(): | |
| assert set(app.MODEL_LABELS) == set(app.AVAILABLE_MODELS) | |
| def test_stream_completion_tolerates_chunks_without_choices(monkeypatch): | |
| lines = sse(json.dumps({"choices": []}), chunk("ok"), "[DONE]") | |
| monkeypatch.setattr( | |
| app.requests, "post", lambda url, **kwargs: FakeStreamResponse(lines) | |
| ) | |
| assert list(app.stream_completion([], "m")) == ["ok"] | |
| def test_stream_completion_raises_on_malformed_chunk(monkeypatch): | |
| lines = sse(chunk("ok"), "{not json") | |
| monkeypatch.setattr( | |
| app.requests, "post", lambda url, **kwargs: FakeStreamResponse(lines) | |
| ) | |
| with pytest.raises(ValueError): | |
| list(app.stream_completion([], "m")) | |
| def test_module_exposes_demo_for_spaces_hot_reload(): | |
| """Spaces' reloader does getattr(__main__, "demo") after every reload.""" | |
| import gradio as gr | |
| assert isinstance(app.demo, gr.Blocks) | |
| def test_cancel_stream_clears_spinner_and_unlocks(monkeypatch): | |
| """Stop cancels the generator mid-flight, so cleanup runs in its own event.""" | |
| monkeypatch.setattr(app, "stream_completion", fake_stream("half")) | |
| frames, _final = drain(app.respond("ping", [], app.DEFAULT_MODEL)) | |
| mid_flight = frames[0][0] # still showing the pending spinner | |
| history, *controls = app.cancel_stream(mid_flight) | |
| assert history == [{"role": "user", "content": "ping"}] | |
| assert all(update["interactive"] is not False for update in controls[:2]) | |
| def test_normalize_history_drops_thought_placeholders(): | |
| history = [ | |
| {"role": "user", "content": "hi"}, | |
| {"role": "assistant", "content": "", "metadata": app.WAITING_METADATA}, | |
| {"role": "assistant", "content": "kept", "metadata": {}}, | |
| ] | |
| assert app.normalize_history(history) == [ | |
| {"role": "user", "content": "hi"}, | |
| {"role": "assistant", "content": "kept"}, | |
| ] | |
| def test_stream_completion_reads_incrementally(monkeypatch): | |
| """Regression: the default 512-byte reads deliver the whole reply at once.""" | |
| response = FakeStreamResponse(sse(chunk("hi"), "[DONE]")) | |
| monkeypatch.setattr(app.requests, "post", lambda url, **kwargs: response) | |
| list(app.stream_completion([], "m")) | |
| assert response.iter_kwargs["chunk_size"] == 1 | |
| assert response.iter_kwargs["decode_unicode"] is True | |
| def test_successful_turn_is_logged(monkeypatch, caplog): | |
| monkeypatch.setattr(app, "stream_completion", fake_stream("pong")) | |
| with caplog.at_level("INFO", logger="gemma-endpoint"): | |
| drain(app.respond("ping", [], app.DEFAULT_MODEL)) | |
| messages = [r.getMessage() for r in caplog.records] | |
| assert any("start model=" in m for m in messages) | |
| assert any("complete reply_chars=4" in m for m in messages) | |
| def test_failed_turn_is_logged_at_error(monkeypatch, caplog): | |
| import gradio as gr | |
| def stream(messages, model_name, turn="-"): | |
| raise requests.exceptions.ConnectionError("refused") | |
| yield # pragma: no cover | |
| monkeypatch.setattr(app, "stream_completion", stream) | |
| with caplog.at_level("INFO", logger="gemma-endpoint"): | |
| drain_failing(app.respond("ping", [], app.DEFAULT_MODEL), gr.Error) | |
| errors = [r.getMessage() for r in caplog.records if r.levelname == "ERROR"] | |
| assert any("failed after" in m and "refused" in m for m in errors) | |
| def test_unexpected_error_logs_a_traceback(monkeypatch, caplog): | |
| def stream(messages, model_name, turn="-"): | |
| raise RuntimeError("boom") | |
| yield # pragma: no cover | |
| monkeypatch.setattr(app, "stream_completion", stream) | |
| with caplog.at_level("INFO", logger="gemma-endpoint"), pytest.raises(RuntimeError): | |
| drain(app.respond("ping", [], app.DEFAULT_MODEL)) | |
| assert any(r.exc_info for r in caplog.records), "no traceback was logged" | |
| def test_turn_ids_correlate_lines_of_one_turn(monkeypatch, caplog): | |
| def stream(messages, model_name, turn="-"): | |
| app.logger.info("turn=%s inner", turn) | |
| yield "ok" | |
| monkeypatch.setattr(app, "stream_completion", stream) | |
| with caplog.at_level("INFO", logger="gemma-endpoint"): | |
| drain(app.respond("one", [], app.DEFAULT_MODEL)) | |
| first = { | |
| m.split("turn=")[1].split()[0] | |
| for m in (r.getMessage() for r in caplog.records) | |
| if "turn=" in m | |
| } | |
| caplog.clear() | |
| drain(app.respond("two", [], app.DEFAULT_MODEL)) | |
| second = { | |
| m.split("turn=")[1].split()[0] | |
| for m in (r.getMessage() for r in caplog.records) | |
| if "turn=" in m | |
| } | |
| assert len(first) == 1 and len(second) == 1, "one turn must use one id" | |
| assert first != second, "consecutive turns must not share an id" | |
| def test_rejected_login_is_logged_without_the_password(caplog): | |
| with caplog.at_level("INFO", logger="gemma-endpoint"): | |
| assert app.verify_login("intruder", "hunter2") is False | |
| record = caplog.records[-1] | |
| assert record.levelname == "WARNING" | |
| assert "REJECTED" in record.getMessage() | |
| assert "hunter2" not in record.getMessage() | |
| assert "s3cret" not in record.getMessage() | |
| def test_logs_never_contain_the_hf_token(monkeypatch, caplog): | |
| """The token is only ever an Authorization header, never a log field.""" | |
| response = FakeStreamResponse(sse(chunk("hi"), "[DONE]")) | |
| monkeypatch.setattr(app.requests, "post", lambda url, **kwargs: response) | |
| with caplog.at_level("DEBUG", logger="gemma-endpoint"): | |
| for model in app.AVAILABLE_MODELS: | |
| list( | |
| app.stream_completion( | |
| [{"role": "user", "content": "hi"}], model, turn=1 | |
| ) | |
| ) | |
| assert caplog.records, "expected the request to be logged" | |
| tokens = {endpoint.token for endpoint in app.MODELS.values()} | |
| for record in caplog.records: | |
| assert not any(token in record.getMessage() for token in tokens) | |
| assert "Authorization" not in record.getMessage() | |
| def test_log_level_applies_to_this_app_only(): | |
| """LOG_LEVEL must not switch on httpx/urllib3 chatter along with ours.""" | |
| import logging | |
| assert app.logger.level == logging.INFO | |
| assert logging.getLogger().level == logging.WARNING | |
| assert app.logger.isEnabledFor(logging.INFO) | |
| assert not logging.getLogger("httpx").isEnabledFor(logging.INFO) | |
| def no_sleep(monkeypatch): | |
| """Run retry loops at full speed, with a clock that still advances.""" | |
| clock = {"now": 0.0} | |
| monkeypatch.setattr(app.time, "monotonic", lambda: clock["now"]) | |
| monkeypatch.setattr( | |
| app.time, "sleep", lambda seconds: clock.update(now=clock["now"] + seconds) | |
| ) | |
| return clock | |
| def test_stream_completion_flags_a_cold_endpoint(monkeypatch, status): | |
| monkeypatch.setattr( | |
| app.requests, "post", lambda url, **kw: FakeStreamResponse([], status) | |
| ) | |
| with pytest.raises(app.EndpointWaking): | |
| list(app.stream_completion([], "m")) | |
| def test_stream_completion_still_raises_for_real_errors(monkeypatch): | |
| response = FakeStreamResponse([], 404) | |
| def raise_for_status(): | |
| raise requests.exceptions.HTTPError("404", response=None) | |
| response.raise_for_status = raise_for_status | |
| monkeypatch.setattr(app.requests, "post", lambda url, **kw: response) | |
| with pytest.raises(requests.exceptions.HTTPError): | |
| list(app.stream_completion([], "m")) | |
| def test_stream_turn_waits_out_a_cold_endpoint(monkeypatch, no_sleep): | |
| attempts = {"n": 0} | |
| def stream(messages, model_name, turn="-"): | |
| attempts["n"] += 1 | |
| if attempts["n"] < 3: | |
| raise app.EndpointWaking(503) | |
| yield # pragma: no cover | |
| yield "awake" | |
| monkeypatch.setattr(app, "stream_completion", stream) | |
| events = list(app.stream_turn([], "m", turn=1)) | |
| assert attempts["n"] == 3, "should retry until the endpoint answers" | |
| assert ("token", "awake") in events | |
| waking = [value for kind, value in events if kind == "waking"] | |
| assert waking, "no waking updates were emitted" | |
| assert waking == sorted(waking), "the waited counter must not go backwards" | |
| def test_stream_turn_gives_up_after_the_wake_timeout(monkeypatch, no_sleep): | |
| import gradio as gr | |
| def always_cold(messages, model_name, turn="-"): | |
| raise app.EndpointWaking(503) | |
| yield # pragma: no cover | |
| monkeypatch.setattr(app, "stream_completion", always_cold) | |
| with pytest.raises(gr.Error, match="did not come up"): | |
| for _ in app.stream_turn([], "m", turn=1): | |
| pass | |
| assert no_sleep["now"] >= app.WAKE_TIMEOUT_SECONDS | |
| def test_respond_shows_a_waking_message_then_the_reply(monkeypatch, no_sleep): | |
| attempts = {"n": 0} | |
| def stream(messages, model_name, turn="-"): | |
| attempts["n"] += 1 | |
| if attempts["n"] == 1: | |
| raise app.EndpointWaking(503) | |
| yield # pragma: no cover | |
| yield "hello" | |
| monkeypatch.setattr(app, "stream_completion", stream) | |
| frames, final = drain(app.respond("ping", [], app.DEFAULT_MODEL)) | |
| titles = [ | |
| (h[-1].get("metadata") or {}).get("title") for h in histories(frames) if h | |
| ] | |
| assert any(t and "Waking up the endpoint" in t for t in titles) | |
| assert all(locked(f) for f in frames[:-1]), "controls stay locked while waking" | |
| assert final[0][-1] == {"role": "assistant", "content": "hello"} | |
| def test_respond_reports_a_cold_endpoint_that_never_wakes(monkeypatch, no_sleep): | |
| import gradio as gr | |
| def always_cold(messages, model_name, turn="-"): | |
| raise app.EndpointWaking(503) | |
| yield # pragma: no cover | |
| monkeypatch.setattr(app, "stream_completion", always_cold) | |
| frames = drain_failing(app.respond("ping", [], app.DEFAULT_MODEL), gr.Error) | |
| assert frames[-1][0] == [], "the failed turn is rolled back" | |
| assert textbox(frames[-1]) == "ping" | |
| assert locked(frames[-1]) is False | |
| class Utf8SseServer: | |
| """A real HTTP server streaming raw UTF-8 SSE, as vLLM/llama.cpp do. | |
| `FakeStreamResponse` hands back `str` lines that are already decoded, so it | |
| cannot catch a decoding bug. Only a real socket exercises the path where | |
| `requests` has to pick a charset. | |
| """ | |
| def __init__(self, content_type): | |
| import http.server | |
| import threading | |
| body = "".join( | |
| f"data: {json.dumps({'choices': [{'delta': {'content': token}}]}, ensure_ascii=False)}\n\n" | |
| for token in ("γγγ«γ‘γ―", "γ", "ζ₯ζ¬θͺ", "γ§γ", "γ") | |
| ) | |
| raw = (body + "data: [DONE]\n\n").encode("utf-8") | |
| class Handler(http.server.BaseHTTPRequestHandler): | |
| def do_POST(self): | |
| self.rfile.read(int(self.headers.get("Content-Length") or 0)) | |
| self.send_response(200) | |
| self.send_header("Content-Type", content_type) | |
| self.send_header("Transfer-Encoding", "chunked") | |
| self.end_headers() | |
| # Split on a 7-byte boundary so multi-byte characters land | |
| # across two chunks, as they do on a real connection. | |
| for start in range(0, len(raw), 7): | |
| piece = raw[start : start + 7] | |
| self.wfile.write(b"%x\r\n%s\r\n" % (len(piece), piece)) | |
| self.wfile.flush() | |
| self.wfile.write(b"0\r\n\r\n") | |
| def log_message(self, *args): | |
| pass | |
| self._server = http.server.HTTPServer(("127.0.0.1", 0), Handler) | |
| threading.Thread(target=self._server.serve_forever, daemon=True).start() | |
| def url(self): | |
| return f"http://127.0.0.1:{self._server.server_port}" | |
| def close(self): | |
| self._server.shutdown() | |
| def test_stream_completion_decodes_utf8_replies(monkeypatch, content_type): | |
| """Regression: a charset-less SSE response decoded Japanese as mojibake. | |
| `requests` falls back to ISO-8859-1 for a text/* response with no charset, | |
| so every non-ASCII reply came back garbled while English looked fine. | |
| """ | |
| server = Utf8SseServer(content_type) | |
| monkeypatch.setattr(app, "ENDPOINT_URL", server.url) | |
| try: | |
| reply = "".join(app.stream_completion([], "m")) | |
| finally: | |
| server.close() | |
| assert reply == "γγγ«γ‘γ―γζ₯ζ¬θͺγ§γγ" | |