fix(tts): prefer explicit XAI_API_KEY over subscription OAuth in streaming TTS
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.
This commit is contained in:
@@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user