Files
2026-07-13 12:10:23 +08:00

485 lines
20 KiB
Python

"""
Connection testing for AI providers.
This module provides functionality to test if a provider's API key is valid
by making minimal API calls to each provider, and to test individual model
configurations end-to-end.
"""
import io
import json
import os
import struct
from typing import Dict, Optional, Tuple
import httpx
from esperanto import (
EmbeddingModel,
LanguageModel,
SpeechToTextModel,
TextToSpeechModel,
)
from esperanto.common_types import ChatCompletion
from loguru import logger
from open_notebook.ai.provider_registry import PROVIDERS
from open_notebook.utils.url_validation import validate_url
def _is_vertex_credentials_file_error(exc: Exception) -> bool:
"""
True if `exc` came from loading a Vertex service-account file
(credentials_path - free text, no path validation; see
open_notebook/ai/key_provider.py).
Google's auth library raises distinguishable exceptions for "file
missing" (FileNotFoundError, an OSError), "not valid JSON"
(json.JSONDecodeError), and "valid JSON but wrong shape"
(google.auth.exceptions.GoogleAuthError) - confirmed by direct
reproduction. Echoing any of these back to an API caller turns
credential/model testing into a filesystem oracle: an attacker who can
create/test a Vertex credential could probe for the existence and
contents-shape of arbitrary files on the server. Callers should catch
these and return one generic message instead of the raw exception text.
Network failures are excluded even though they'd otherwise match
(ConnectionError/TimeoutError are OSError subclasses, TransportError a
GoogleAuthError subclass): they say nothing about the credentials file,
and classifying them here would tell a user with a blocked network to
go debug their file path. Letting them fall through reveals only the
error's category ("connection error"), which keeps the oracle closed.
"""
from google.auth.exceptions import GoogleAuthError, TransportError
if isinstance(exc, (ConnectionError, TimeoutError, TransportError)):
return False
return isinstance(exc, (OSError, json.JSONDecodeError, GoogleAuthError))
# Test models for each provider - uses minimal/cheapest models for testing.
# Derived from the provider registry (the source of truth for test models).
# Format: (model_name, model_type); None model = dynamic (first available).
#
# Prefer a provider-maintained floating alias where one exists, so a model
# retirement doesn't silently break the connection test (see #970: Google
# hard-shuts-down Gemini model ids on a schedule). `gemini-flash-latest`
# is Google's alias for the current stable Flash model and moves forward on
# its own. The provider test also no longer treats a model-level failure as
# a connection failure (see `_connection_failure_reason`), so even if an
# alias ever breaks, the test still reports the credentials correctly.
TEST_MODELS: Dict[str, Tuple[Optional[str], str]] = {
name: (spec.test_model, spec.test_model_type) for name, spec in PROVIDERS.items()
}
async def _test_azure_connection(
endpoint: Optional[str] = None,
api_key: Optional[str] = None,
api_version: Optional[str] = None,
) -> Tuple[bool, str]:
"""
Test Azure OpenAI connectivity by listing models.
Azure requires deployment names which vary per user, so instead of
invoking a model, we list available models to validate credentials.
"""
test_endpoint = endpoint or os.environ.get("AZURE_OPENAI_ENDPOINT")
test_api_key = api_key or os.environ.get("AZURE_OPENAI_API_KEY")
test_api_version = api_version or os.environ.get("AZURE_OPENAI_API_VERSION", "2024-10-21")
if not test_endpoint:
return False, "No Azure endpoint configured"
if not test_api_key:
return False, "No Azure API key configured"
# Strip trailing slash to avoid double-slash in URL
test_endpoint = test_endpoint.rstrip("/")
try:
# Re-validate at request time: the endpoint may have been saved
# against a hostname that only later resolved to an internal
# address (DNS rebinding), so a save-time check alone isn't enough.
await validate_url(test_endpoint, "azure")
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
f"{test_endpoint}/openai/models?api-version={test_api_version}",
headers={"api-key": test_api_key},
)
if response.status_code == 200:
data = response.json()
models = data.get("data", [])
count = len(models)
if count > 0:
names = [m.get("id", "unknown") for m in models[:3]]
name_list = ", ".join(names)
if count > 3:
name_list += f" (+{count - 3} more)"
return True, f"Connected. {count} models: {name_list}"
else:
return True, "Connected successfully (no models found)"
elif response.status_code == 401:
return False, "Invalid API key"
elif response.status_code == 403:
return False, "API key lacks required permissions"
else:
return False, f"Azure returned status {response.status_code}"
except ValueError as e:
return False, str(e)
except httpx.ConnectError:
return False, "Cannot connect to Azure endpoint. Check the URL."
except httpx.TimeoutException:
return False, "Connection timed out. Check the endpoint URL."
except Exception as e:
return False, f"Connection error: {str(e)[:100]}"
async def _test_ollama_connection(base_url: str) -> Tuple[bool, str]:
"""Test Ollama server connectivity."""
try:
# Re-validate at request time (see _test_azure_connection for why).
await validate_url(base_url, "ollama")
async with httpx.AsyncClient(timeout=10.0) as client:
# Try /api/tags endpoint (standard Ollama)
response = await client.get(f"{base_url}/api/tags")
if response.status_code == 200:
data = response.json()
models = data.get("models", [])
model_count = len(models)
if model_count > 0:
model_names = [m.get("name", "unknown") for m in models[:3]]
model_list = ", ".join(model_names)
if model_count > 3:
model_list += f" (+{model_count - 3} more)"
return True, f"Connected. {model_count} models available: {model_list}"
else:
return True, "Connected successfully (no models listed)"
elif response.status_code == 401:
return False, "Invalid API key"
elif response.status_code == 403:
return False, "API key lacks required permissions"
else:
return False, f"Server returned status {response.status_code}"
except ValueError as e:
return False, str(e)
except httpx.ConnectError:
return False, "Cannot connect to Ollama. Check if Ollama server is running."
except httpx.TimeoutException:
return False, "Connection timed out. Check if Ollama server is accessible."
except Exception as e:
return False, f"Connection error: {str(e)[:100]}"
async def _test_openai_compatible_connection(base_url: str, api_key: Optional[str] = None) -> Tuple[bool, str]:
"""Test OpenAI-compatible server connectivity."""
try:
# Re-validate at request time (see _test_azure_connection for why).
await validate_url(base_url, "openai_compatible")
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
async with httpx.AsyncClient(timeout=10.0) as client:
# Try /models endpoint (standard OpenAI-compatible)
response = await client.get(f"{base_url}/models", headers=headers)
if response.status_code == 200:
data = response.json()
models = data.get("data", [])
model_count = len(models)
if model_count > 0:
model_names = [m.get("id", "unknown") for m in models[:3]]
model_list = ", ".join(model_names)
if model_count > 3:
model_list += f" (+{model_count - 3} more)"
return True, f"Connected. {model_count} models available: {model_list}"
else:
return True, "Connected successfully (no models listed)"
elif response.status_code == 401:
return False, "Invalid API key"
elif response.status_code == 403:
return False, "API key lacks required permissions"
else:
return False, f"Server returned status {response.status_code}"
except ValueError as e:
return False, str(e)
except httpx.ConnectError:
return False, "Cannot connect to server. Check the URL is correct."
except httpx.TimeoutException:
return False, "Connection timed out. Check if server is accessible."
except Exception as e:
return False, f"Connection error: {str(e)[:100]}"
# Default voices for TTS testing per provider
# ElevenLabs and Mistral excluded: voices looked up dynamically via available_voices
DEFAULT_TEST_VOICES = {
"openai": "alloy",
"azure": "alloy",
"google": "Kore",
"vertex": "Kore",
"openai_compatible": "alloy",
"deepgram": "aura-2-thalia-en",
"xai": "eve",
}
def _generate_test_wav() -> io.BytesIO:
"""Generate a minimal 0.5s silence WAV file in memory (16kHz, 16-bit mono)."""
sample_rate = 16000
num_samples = sample_rate // 2 # 0.5 seconds
bits_per_sample = 16
num_channels = 1
byte_rate = sample_rate * num_channels * bits_per_sample // 8
block_align = num_channels * bits_per_sample // 8
data_size = num_samples * block_align
buf = io.BytesIO()
# RIFF header
buf.write(b"RIFF")
buf.write(struct.pack("<I", 36 + data_size))
buf.write(b"WAVE")
# fmt chunk
buf.write(b"fmt ")
buf.write(struct.pack("<I", 16)) # chunk size
buf.write(struct.pack("<H", 1)) # PCM format
buf.write(struct.pack("<H", num_channels))
buf.write(struct.pack("<I", sample_rate))
buf.write(struct.pack("<I", byte_rate))
buf.write(struct.pack("<H", block_align))
buf.write(struct.pack("<H", bits_per_sample))
# data chunk
buf.write(b"data")
buf.write(struct.pack("<I", data_size))
buf.write(b"\x00" * data_size) # silence
buf.seek(0)
buf.name = "test.wav"
return buf
# A short bundled clip of speech ("Hello there") used to validate STT models.
# Real speech (vs. silence) makes the test transcription non-empty, so a passing
# test visibly returns text instead of a blank result.
_TEST_SPEECH_PATH = os.path.join(os.path.dirname(__file__), "assets", "test_speech.mp3")
def _get_test_audio() -> io.BytesIO:
"""Return a short speech clip for STT testing, or silence as a fallback."""
try:
with open(_TEST_SPEECH_PATH, "rb") as f:
buf = io.BytesIO(f.read())
buf.name = "test_speech.mp3"
buf.seek(0)
return buf
except OSError:
# Fall back to a silent WAV if the bundled clip is missing
return _generate_test_wav()
def _connection_failure_reason(error_msg: str) -> Optional[str]:
"""Classify whether an error means the provider is genuinely unreachable
or the credentials are rejected.
Returns a user-facing failure message for the only errors that actually
disprove a working provider connection — bad key (401), insufficient
permissions (403), and network/timeout failures. Returns None for
anything the provider itself returned *after* authenticating (a missing
or retired model, an unsupported request, a rate limit): reaching the
model layer at all proves the credentials and endpoint work, so those
are not connection failures. This is what keeps a retired test model
(see #970) from being misreported as a broken provider connection.
"""
lower = error_msg.lower()
if "401" in error_msg or "unauthorized" in lower:
return "Invalid API key"
if "403" in error_msg or "forbidden" in lower:
return "API key lacks required permissions"
if "timeout" in lower or "timed out" in lower:
return "Connection timed out - check network/endpoint"
if (
"connection" in lower
or "network" in lower
or "getaddrinfo" in lower
or "name resolution" in lower
or "failed to establish" in lower
):
return "Connection error - check network/endpoint"
return None
def _is_rate_limit(error_msg: str) -> bool:
"""True if the error is a throttling/quota response. Being rate-limited
proves the request authenticated, so callers treat this as connection-OK.
Covers the common phrasings across providers (429, quota, resource
exhausted) rather than just the literal words "rate limit"."""
lower = error_msg.lower()
return (
("rate" in lower and "limit" in lower)
or "429" in error_msg
or "quota" in lower
or "resource has been exhausted" in lower
or "resource exhausted" in lower
)
def _normalize_error_message(error_msg: str) -> Tuple[bool, str]:
"""Normalize common error patterns into user-friendly messages.
Used by the *individual model* test, where the user is validating one
specific registered model — so a missing model IS a failure (unlike the
provider-level test, which only cares that the credentials work).
"""
reason = _connection_failure_reason(error_msg)
if reason:
return False, reason
if _is_rate_limit(error_msg):
return True, "Rate limited - but connection works"
lower = error_msg.lower()
if "not found" in lower and "model" in lower:
return False, "Model not found on this provider"
return False, error_msg
# Substrings that indicate the provider answered but the *test model* is
# missing/retired/unsupported - proof the credentials and endpoint work.
# Only consulted for fixed-endpoint API-key providers (URL-based providers
# are tested via their own handlers), so a "not found" here is about the
# model, never a user-supplied base URL.
_MODEL_UNAVAILABLE_MARKERS = (
"not found",
"not supported",
"does not exist",
"deprecated",
"unavailable",
"no longer available",
)
def classify_provider_test_error(error_msg: str) -> Tuple[bool, str]:
"""Classify a provider connection-test exception into (success, message).
The provider test only asks "do these credentials reach a working
provider?" - so the sole real failures are a rejected key (401),
insufficient permissions (403), and an unreachable endpoint. Anything
the provider returned after authenticating - a rate limit, or a
missing/retired/unsupported test model - still proves the connection
works, so it's reported as success. This is the durable half of the
#970 fix: even if the hard-coded test model is retired, a valid key is
never misreported as a broken connection.
"""
reason = _connection_failure_reason(error_msg)
if reason:
return False, reason
if _is_rate_limit(error_msg):
return True, "Rate limited - but connection works"
lower = error_msg.lower()
if any(marker in lower for marker in _MODEL_UNAVAILABLE_MARKERS):
return True, "API key valid (test model unavailable)"
truncated = error_msg[:100] + "..." if len(error_msg) > 100 else error_msg
return False, f"Error: {truncated}"
async def test_individual_model(model) -> Tuple[bool, str]:
"""
Test a specific model configuration end-to-end by making a real API call.
Args:
model: A Model instance (from open_notebook.ai.models)
Returns:
Tuple of (success: bool, message: str)
"""
from open_notebook.ai.models import ModelManager
try:
manager = ModelManager()
esp_model = await manager.get_model(model.id)
if esp_model is None:
return False, "Could not create model instance"
if model.type == "language":
if not isinstance(esp_model, LanguageModel):
return False, f"Model type mismatch: expected a language model, got {type(esp_model).__name__}"
response = await esp_model.achat_complete(
messages=[{"role": "user", "content": "Hi!"}]
)
if not isinstance(response, ChatCompletion):
# Non-streaming call; a streaming response would be a bug upstream.
return True, "Connection successful (streaming response)"
text = response.content[:100] if response.content else "(empty response)"
return True, f"Response: {text}"
elif model.type == "embedding":
if not isinstance(esp_model, EmbeddingModel):
return False, f"Model type mismatch: expected an embedding model, got {type(esp_model).__name__}"
result = await esp_model.aembed(["This is a test."])
if result and len(result) > 0:
dims = len(result[0])
return True, f"Embedding dimensions: {dims}"
return True, "Embedding successful"
elif model.type == "text_to_speech":
if not isinstance(esp_model, TextToSpeechModel):
return False, f"Model type mismatch: expected a text-to-speech model, got {type(esp_model).__name__}"
# For ElevenLabs, look up first available voice (API uses voice_id, not name)
voice = DEFAULT_TEST_VOICES.get(model.provider)
if not voice and hasattr(esp_model, "available_voices"):
try:
voices = esp_model.available_voices
if voices:
voice = next(iter(voices.keys()))
except Exception:
pass
if not voice:
voice = "alloy" # fallback
audio = await esp_model.agenerate_speech(
text="Hello from Open Notebook", voice=voice
)
if audio and hasattr(audio, "content"):
size = len(audio.content)
return True, f"Audio generated: {size} bytes"
return True, "Speech generation successful"
elif model.type == "speech_to_text":
if not isinstance(esp_model, SpeechToTextModel):
return False, f"Model type mismatch: expected a speech-to-text model, got {type(esp_model).__name__}"
audio_file = _get_test_audio()
transcription = await esp_model.atranscribe(
audio_file=audio_file, language="en"
)
text = (
str(transcription.text).strip()
if hasattr(transcription, "text")
else str(transcription).strip()
)
if not text:
return True, "Connection successful (test clip produced no transcription)"
return True, f"Transcription: {text[:100]}"
else:
return False, f"Unsupported model type: {model.type}"
except Exception as e:
if model.provider == "vertex" and _is_vertex_credentials_file_error(e):
logger.debug(f"Vertex credentials file error for model {model.id}: {e}")
return False, "Invalid or inaccessible credentials file"
error_msg = str(e)
success, normalized = _normalize_error_message(error_msg)
if success:
return True, normalized
logger.debug(f"Test individual model error for {model.id}: {e}")
return False, normalized