diff --git a/tests/tools/test_tts_streaming.py b/tests/tools/test_tts_streaming.py index 75670c4999..49905ac097 100644 --- a/tests/tools/test_tts_streaming.py +++ b/tests/tools/test_tts_streaming.py @@ -199,13 +199,40 @@ def test_xai_available_uses_oauth_credential_resolver(monkeypatch): import types fake = types.ModuleType("tools.xai_http") - fake.resolve_xai_http_credentials = lambda: {"api_key": "xai-key"} + fake.resolve_xai_http_credentials = lambda **kw: {"api_key": "xai-key"} monkeypatch.setitem(sys.modules, "tools.xai_http", fake) assert ts.XAIStreamer.available() is True - fake.resolve_xai_http_credentials = lambda: {"api_key": ""} + fake.resolve_xai_http_credentials = lambda **kw: {"api_key": ""} assert ts.XAIStreamer.available() is False +def test_xai_streaming_prefers_explicit_api_key(monkeypatch): + """Metered TTS 403s on the subscription OAuth bearer — the streaming path must + resolve credentials with prefer_api_key=True like the sync path (#87045).""" + import sys + import types + + calls = [] + + fake = types.ModuleType("tools.xai_http") + fake.resolve_xai_http_credentials = lambda **kw: calls.append(kw) or {"api_key": "k"} + monkeypatch.setitem(sys.modules, "tools.xai_http", fake) + + ts.XAIStreamer.available() + assert calls and all(c.get("prefer_api_key") is True for c in calls) + + # _async_frames resolves before websockets.connect; an empty key raises first. + calls.clear() + ws_fake = types.ModuleType("websockets") + monkeypatch.setitem(sys.modules, "websockets", ws_fake) + fake.resolve_xai_http_credentials = lambda **kw: calls.append(kw) or {"api_key": ""} + streamer = ts.XAIStreamer({}, {"voice_id": "v"}) + with pytest.raises(RuntimeError, match="No xAI credentials"): + import asyncio + asyncio.run(streamer._async_frames("hi").__anext__()) + assert calls and calls[0].get("prefer_api_key") is True + + # ── Gemini SSE parsing ──────────────────────────────────────────────────── diff --git a/tools/tts_streaming.py b/tools/tts_streaming.py index ef72670ce2..71bc4a2cee 100644 --- a/tools/tts_streaming.py +++ b/tools/tts_streaming.py @@ -293,7 +293,9 @@ class XAIStreamer(StreamingTTSProvider): def available() -> bool: try: from tools.xai_http import resolve_xai_http_credentials - return bool(str(resolve_xai_http_credentials().get("api_key") or "").strip()) + # Same ordering as the sync path: the subscription OAuth bearer + # authorizes but 403s on metered TTS, so an explicit key wins (#87045). + return bool(str(resolve_xai_http_credentials(prefer_api_key=True).get("api_key") or "").strip()) except Exception: return False @@ -312,7 +314,7 @@ class XAIStreamer(StreamingTTSProvider): import websockets from tools.tts_tool_providers import DEFAULT_XAI_VOICE_ID from tools.xai_http import resolve_xai_http_credentials - api_key = str(resolve_xai_http_credentials().get("api_key") or "").strip() + api_key = str(resolve_xai_http_credentials(prefer_api_key=True).get("api_key") or "").strip() if not api_key: raise RuntimeError("No xAI credentials for streaming TTS") voice = str(self.section.get("voice_id", DEFAULT_XAI_VOICE_ID)).strip() or DEFAULT_XAI_VOICE_ID