From 3b0aae77ee47bbd339b5e93253247ff20df81062 Mon Sep 17 00:00:00 2001 From: beardthelion Date: Thu, 17 Sep 2026 03:42:14 +0000 Subject: [PATCH] fix(tts): prefer explicit XAI_API_KEY over subscription OAuth in streaming TTS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sync xAI TTS path resolves credentials with prefer_api_key=True (#87045/#88040): the subscription OAuth bearer authorizes chat but returns 403 on the metered TTS API. The WebSocket streaming path (XAIStreamer, salvaged from #47588 before that ordering existed) calls resolve_xai_http_credentials() with the default OAuth-first order at both sites, so a user holding both credentials gets the 403ing bearer, and XAIStreamer.available() reports True on a credential that cannot stream. Pass prefer_api_key=True at both sites — identical availability semantics (OAuth remains the fallback), correct preference order, parity with _generate_xai_tts. --- tests/tools/test_tts_streaming.py | 31 +++++++++++++++++++++++++++++-- tools/tts_streaming.py | 6 ++++-- 2 files changed, 33 insertions(+), 4 deletions(-) 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