fix(P0): Auto-detect OpenAI provider from BYOK api_key prefix
Browse filesBug: When users entered an OpenAI key (sk-...) in Gradio without
a provider parameter, the client factory only checked settings.has_openai_key
(env var) and fell through to HuggingFace, causing V1 chat completion errors.
Fix: Add provider auto-detection from api_key prefix in get_chat_client():
- sk-ant-... β Anthropic (NotImplementedError - not yet supported)
- sk-... β OpenAI (now correctly routed)
- hf_... β Falls through to HuggingFace (default)
Order matters: "sk-ant-" must be checked before "sk-" since both share prefix.
Tests added:
- test_byok_auto_detects_openai_from_key_prefix (the critical fix test)
- test_byok_auto_detects_anthropic_from_key_prefix
- test_byok_hf_token_falls_through_to_huggingface
- test_anthropic_provider_raises_not_implemented
All 310 unit tests pass.
|
@@ -23,13 +23,14 @@ def get_chat_client(
|
|
| 23 |
|
| 24 |
Auto-detection priority:
|
| 25 |
1. Explicit provider parameter
|
| 26 |
-
2.
|
| 27 |
-
3.
|
| 28 |
-
4.
|
|
|
|
| 29 |
|
| 30 |
Args:
|
| 31 |
provider: Force specific provider ("openai", "gemini", "huggingface")
|
| 32 |
-
api_key: Override API key for the provider
|
| 33 |
model_id: Override default model ID
|
| 34 |
**kwargs: Additional arguments for the client
|
| 35 |
|
|
@@ -38,13 +39,23 @@ def get_chat_client(
|
|
| 38 |
|
| 39 |
Raises:
|
| 40 |
ValueError: If an unsupported provider is explicitly requested
|
| 41 |
-
NotImplementedError: If Gemini is
|
| 42 |
"""
|
| 43 |
# Normalize provider to lowercase for case-insensitive matching
|
| 44 |
normalized = provider.lower() if provider is not None else None
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
# Validate explicit provider requests early
|
| 47 |
-
valid_providers = (None, "openai", "gemini", "huggingface")
|
| 48 |
if normalized not in valid_providers:
|
| 49 |
raise ValueError(f"Unsupported provider: {provider!r}")
|
| 50 |
|
|
@@ -57,7 +68,15 @@ def get_chat_client(
|
|
| 57 |
**kwargs,
|
| 58 |
)
|
| 59 |
|
| 60 |
-
# 2.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
if normalized == "gemini":
|
| 62 |
# Explicit request for Gemini - fail loudly
|
| 63 |
raise NotImplementedError("Gemini client not yet implemented (Planned Phase 4)")
|
|
@@ -66,7 +85,7 @@ def get_chat_client(
|
|
| 66 |
# Implicit (has key but not explicit) - log warning and fall through
|
| 67 |
logger.warning("Gemini key detected but client not yet implemented; falling back")
|
| 68 |
|
| 69 |
-
#
|
| 70 |
# This is the default if no other keys are present
|
| 71 |
logger.info("Using HuggingFace Chat Client (Free Tier)")
|
| 72 |
return HuggingFaceChatClient(
|
|
|
|
| 23 |
|
| 24 |
Auto-detection priority:
|
| 25 |
1. Explicit provider parameter
|
| 26 |
+
2. API key prefix detection (sk- β OpenAI, sk-ant- β Anthropic)
|
| 27 |
+
3. OpenAI key from env (Best Function Calling)
|
| 28 |
+
4. Gemini key from env (Best Context/Cost)
|
| 29 |
+
5. HuggingFace (Free Fallback)
|
| 30 |
|
| 31 |
Args:
|
| 32 |
provider: Force specific provider ("openai", "gemini", "huggingface")
|
| 33 |
+
api_key: Override API key for the provider (auto-detects provider from prefix)
|
| 34 |
model_id: Override default model ID
|
| 35 |
**kwargs: Additional arguments for the client
|
| 36 |
|
|
|
|
| 39 |
|
| 40 |
Raises:
|
| 41 |
ValueError: If an unsupported provider is explicitly requested
|
| 42 |
+
NotImplementedError: If Gemini or Anthropic is requested (not yet implemented)
|
| 43 |
"""
|
| 44 |
# Normalize provider to lowercase for case-insensitive matching
|
| 45 |
normalized = provider.lower() if provider is not None else None
|
| 46 |
|
| 47 |
+
# FIX: Auto-detect provider from API key prefix when not explicitly set
|
| 48 |
+
# This enables BYOK (Bring Your Own Key) from Gradio without explicit provider
|
| 49 |
+
# Order matters: "sk-ant-" must be checked before "sk-" (both start with "sk-")
|
| 50 |
+
if normalized is None and api_key:
|
| 51 |
+
if api_key.startswith("sk-ant-"):
|
| 52 |
+
normalized = "anthropic"
|
| 53 |
+
elif api_key.startswith("sk-"):
|
| 54 |
+
normalized = "openai"
|
| 55 |
+
# HF tokens start with "hf_" - no auto-detection needed (falls through to default)
|
| 56 |
+
|
| 57 |
# Validate explicit provider requests early
|
| 58 |
+
valid_providers = (None, "openai", "anthropic", "gemini", "huggingface")
|
| 59 |
if normalized not in valid_providers:
|
| 60 |
raise ValueError(f"Unsupported provider: {provider!r}")
|
| 61 |
|
|
|
|
| 68 |
**kwargs,
|
| 69 |
)
|
| 70 |
|
| 71 |
+
# 2. Anthropic (Detected from sk-ant- prefix or explicit)
|
| 72 |
+
if normalized == "anthropic":
|
| 73 |
+
# Anthropic key was detected or explicitly requested - fail loudly
|
| 74 |
+
raise NotImplementedError(
|
| 75 |
+
"Anthropic client not yet implemented. "
|
| 76 |
+
"Use OpenAI key (sk-...) or leave empty for free HuggingFace tier."
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# 3. Gemini (High Performance / Alternative)
|
| 80 |
if normalized == "gemini":
|
| 81 |
# Explicit request for Gemini - fail loudly
|
| 82 |
raise NotImplementedError("Gemini client not yet implemented (Planned Phase 4)")
|
|
|
|
| 85 |
# Implicit (has key but not explicit) - log warning and fall through
|
| 86 |
logger.warning("Gemini key detected but client not yet implemented; falling back")
|
| 87 |
|
| 88 |
+
# 4. HuggingFace (Free Fallback)
|
| 89 |
# This is the default if no other keys are present
|
| 90 |
logger.info("Using HuggingFace Chat Client (Free Tier)")
|
| 91 |
return HuggingFaceChatClient(
|
|
@@ -91,8 +91,73 @@ class TestChatClientFactory:
|
|
| 91 |
from src.clients.factory import get_chat_client
|
| 92 |
|
| 93 |
with pytest.raises(ValueError, match="Unsupported provider"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
get_chat_client(provider="anthropic")
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def test_provider_is_case_insensitive(self) -> None:
|
| 97 |
"""Provider matching should be case-insensitive."""
|
| 98 |
with patch("src.clients.factory.settings") as mock_settings:
|
|
|
|
| 91 |
from src.clients.factory import get_chat_client
|
| 92 |
|
| 93 |
with pytest.raises(ValueError, match="Unsupported provider"):
|
| 94 |
+
get_chat_client(provider="invalid_provider")
|
| 95 |
+
|
| 96 |
+
def test_anthropic_provider_raises_not_implemented(self) -> None:
|
| 97 |
+
"""Anthropic provider should raise NotImplementedError (not yet implemented)."""
|
| 98 |
+
with patch("src.clients.factory.settings") as mock_settings:
|
| 99 |
+
mock_settings.has_openai_key = False
|
| 100 |
+
mock_settings.has_gemini_key = False
|
| 101 |
+
|
| 102 |
+
from src.clients.factory import get_chat_client
|
| 103 |
+
|
| 104 |
+
with pytest.raises(NotImplementedError, match="Anthropic client not yet implemented"):
|
| 105 |
get_chat_client(provider="anthropic")
|
| 106 |
|
| 107 |
+
def test_byok_auto_detects_openai_from_key_prefix(self) -> None:
|
| 108 |
+
"""BYOK: api_key starting with 'sk-' should auto-select OpenAI without explicit provider.
|
| 109 |
+
|
| 110 |
+
This is the critical BYOK (Bring Your Own Key) test case:
|
| 111 |
+
- User enters 'sk-...' key in Gradio
|
| 112 |
+
- No explicit provider parameter
|
| 113 |
+
- No OPENAI_API_KEY in env (settings.has_openai_key = False)
|
| 114 |
+
- Should auto-detect OpenAI from the key prefix
|
| 115 |
+
"""
|
| 116 |
+
with patch("src.clients.factory.settings") as mock_settings:
|
| 117 |
+
mock_settings.has_openai_key = False # No env key
|
| 118 |
+
mock_settings.has_gemini_key = False
|
| 119 |
+
mock_settings.openai_api_key = None
|
| 120 |
+
mock_settings.openai_model = "gpt-5"
|
| 121 |
+
|
| 122 |
+
from src.clients.factory import get_chat_client
|
| 123 |
+
|
| 124 |
+
# BYOK: Pass api_key without explicit provider
|
| 125 |
+
client = get_chat_client(api_key="sk-user-provided-key")
|
| 126 |
+
|
| 127 |
+
# Should auto-detect OpenAI from 'sk-' prefix
|
| 128 |
+
assert "OpenAI" in type(client).__name__
|
| 129 |
+
|
| 130 |
+
def test_byok_auto_detects_anthropic_from_key_prefix(self) -> None:
|
| 131 |
+
"""BYOK: api_key starting with 'sk-ant-' should auto-detect Anthropic.
|
| 132 |
+
|
| 133 |
+
Anthropic keys start with 'sk-ant-' which is a superset of 'sk-'.
|
| 134 |
+
Detection must check 'sk-ant-' first to avoid misdetecting as OpenAI.
|
| 135 |
+
"""
|
| 136 |
+
with patch("src.clients.factory.settings") as mock_settings:
|
| 137 |
+
mock_settings.has_openai_key = False
|
| 138 |
+
mock_settings.has_gemini_key = False
|
| 139 |
+
|
| 140 |
+
from src.clients.factory import get_chat_client
|
| 141 |
+
|
| 142 |
+
# BYOK: Anthropic key should raise NotImplementedError (not fall to HuggingFace!)
|
| 143 |
+
with pytest.raises(NotImplementedError, match="Anthropic client not yet implemented"):
|
| 144 |
+
get_chat_client(api_key="sk-ant-user-anthropic-key")
|
| 145 |
+
|
| 146 |
+
def test_byok_hf_token_falls_through_to_huggingface(self) -> None:
|
| 147 |
+
"""BYOK: HuggingFace tokens (hf_...) should use HuggingFace client."""
|
| 148 |
+
with patch("src.clients.factory.settings") as mock_settings:
|
| 149 |
+
mock_settings.has_openai_key = False
|
| 150 |
+
mock_settings.has_gemini_key = False
|
| 151 |
+
mock_settings.huggingface_model = "Qwen/Qwen2.5-7B-Instruct"
|
| 152 |
+
mock_settings.hf_token = None
|
| 153 |
+
|
| 154 |
+
from src.clients.factory import get_chat_client
|
| 155 |
+
|
| 156 |
+
# HF tokens don't trigger auto-detection, falls through to HuggingFace
|
| 157 |
+
client = get_chat_client(api_key="hf_user_provided_token")
|
| 158 |
+
|
| 159 |
+
assert "HuggingFace" in type(client).__name__
|
| 160 |
+
|
| 161 |
def test_provider_is_case_insensitive(self) -> None:
|
| 162 |
"""Provider matching should be case-insensitive."""
|
| 163 |
with patch("src.clients.factory.settings") as mock_settings:
|