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:
beardthelion
2026-09-17 03:42:14 +00:00
committed by Teknium
parent 66d79311e1
commit 3b0aae77ee
2 changed files with 33 additions and 4 deletions

View File

@@ -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 ────────────────────────────────────────────────────

View File

@@ -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