# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """ Integration tests for the external providers API. Requires a running Unsloth Studio server. Configure via env vars: export STUDIO_TEST_URL="http://localhost:8888" # default export STUDIO_TEST_USER="unsloth" # default export STUDIO_TEST_PASSWORD="..." # required — see .bootstrap_password # Provider API keys — tests skip when their key is unset export OPENAI_API_KEY="sk-..." export MISTRAL_API_KEY="..." export GOOGLE_API_KEY="..." export TOGETHER_API_KEY="..." export FIREWORKS_API_KEY="..." export PERPLEXITY_API_KEY="..." Run: cd studio/backend pytest tests/test_providers_api.py -v -s """ import base64 import json import os import pytest import requests from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding # ── Configuration ───────────────────────────────────────────────── BASE_URL = os.getenv("STUDIO_TEST_URL", "http://localhost:8000") USERNAME = os.getenv("STUDIO_TEST_USER", "unsloth") PASSWORD = os.getenv("STUDIO_TEST_PASSWORD", "") # Skip the whole module when no live Studio server / bootstrap password is # available (e.g. on CI) so pytest discovery does not error out. pytestmark = pytest.mark.skipif( not PASSWORD, reason = "Integration test requires a running Studio server; set STUDIO_TEST_PASSWORD to enable.", ) # provider_type → (env var name, model for inference test) _PROVIDER_CONFIGS: dict[str, tuple[str, str]] = { "openai": ("OPENAI_API_KEY", "gpt-4o-mini"), "mistral": ("MISTRAL_API_KEY", "mistral-small-2506"), "gemini": ("GEMINI_API_KEY", "gemini-3-flash-preview"), "openrouter": ("OPENROUTER_API_KEY", "openai/gpt-4o-mini"), "anthropic": ("ANTHROPIC_API_KEY", "claude-haiku-4-5"), "deepseek": ("DEEPSEEK_API_KEY", "deepseek-chat"), "huggingface": ("HUGGINGFACE_API_KEY", "meta-llama/Llama-3.3-70B-Instruct"), "kimi": ("MOONSHOT_API_KEY", "moonshot-v1-8k"), "qwen": ("DASHSCOPE_API_KEY", "qwen-turbo"), } PROVIDER_KEYS: dict[str, str] = { ptype: os.getenv(env_var, "") for ptype, (env_var, _) in _PROVIDER_CONFIGS.items() } EXPECTED_PROVIDER_TYPES = set(_PROVIDER_CONFIGS.keys()) # ── Helpers ──────────────────────────────────────────────────────── def _url(path: str) -> str: return f"{BASE_URL}/{path.lstrip('/')}" def _parse_sse_stream(response: requests.Response) -> tuple[str, bool]: """Read an SSE response, return (assembled_text, saw_done). Each chunk is JSON with choices[0].delta.content; stream ends at `data: [DONE]`. """ reply_parts: list[str] = [] saw_done = False for raw_line in response.iter_lines(): if isinstance(raw_line, bytes): raw_line = raw_line.decode("utf-8") if not raw_line.startswith("data:"): continue data = raw_line[len("data:") :].strip() if data == "[DONE]": saw_done = True break try: chunk = json.loads(data) # Handle error payloads and normal chunks if "error" in chunk: raise RuntimeError(f"Provider error in stream: {chunk['error']}") delta = chunk.get("choices", [{}])[0].get("delta", {}) content = delta.get("content") or "" if content: reply_parts.append(content) except (json.JSONDecodeError, IndexError, KeyError): pass # skip malformed lines return "".join(reply_parts), saw_done # ── Session-scoped fixtures ──────────────────────────────────────── @pytest.fixture(scope = "session") def auth_headers() -> dict[str, str]: """Log in once per session and return auth headers. On a fresh install the bootstrap password forces a change; this fixture detects must_change_password, auto-completes the change (new password = STUDIO_TEST_NEW_PASSWORD or PASSWORD + "-test"), and re-logs in. On the second run, set STUDIO_TEST_PASSWORD to the new password. """ assert PASSWORD, ( "STUDIO_TEST_PASSWORD is not set.\n" "Run: export STUDIO_TEST_PASSWORD=$(cat studio/backend/.bootstrap_password)" ) resp = requests.post( _url("/api/auth/login"), json = {"username": USERNAME, "password": PASSWORD}, timeout = 10, ) assert resp.status_code == 200, f"Login failed ({resp.status_code}): {resp.text}" body = resp.json() token = body["access_token"] assert token, "access_token is empty" if body.get("must_change_password"): # Bootstrap token only works with change-password; auto-complete the # forced change so the rest of the tests get a full token. new_password = os.getenv("STUDIO_TEST_NEW_PASSWORD") or f"{PASSWORD}-test" change_resp = requests.post( _url("/api/auth/change-password"), headers = {"Authorization": f"Bearer {token}"}, json = {"current_password": PASSWORD, "new_password": new_password}, timeout = 10, ) assert ( change_resp.status_code == 200 ), f"Auto password-change failed ({change_resp.status_code}): {change_resp.text}" token = change_resp.json()["access_token"] return {"Authorization": f"Bearer {token}"} @pytest.fixture(scope = "session") def public_key_pem(auth_headers: dict[str, str]) -> str: """Fetch RSA public key PEM once per session.""" resp = requests.get( _url("/api/providers/public-key"), headers = auth_headers, timeout = 10, ) assert resp.status_code == 200, f"Public key fetch failed: {resp.text}" pem = resp.json().get("public_key", "") assert pem.startswith("-----BEGIN PUBLIC KEY-----"), "Not a valid PEM public key" return pem @pytest.fixture(scope = "session") def vision_image_data_url() -> str: """Download the sloth image once per session as a base64 data URI. A data URI sends the image inline; Gemini's OpenAI-compatible layer doesn't fetch external HTTP URLs, so raw image_url links give empty Gemini replies. """ resp = requests.get(_VISION_IMAGE_URL, timeout = 30) resp.raise_for_status() content_type = resp.headers.get("Content-Type", "image/jpeg").split(";")[0].strip() b64 = base64.b64encode(resp.content).decode("utf-8") return f"data:{content_type};base64,{b64}" @pytest.fixture(scope = "session") def encrypt_key(public_key_pem: str): """Return encrypt_key(plaintext) -> base64 RSA-OAEP ciphertext. Uses the backend's RSA public key; mirrors the frontend. """ pem_bytes = public_key_pem.encode("utf-8") rsa_pub = serialization.load_pem_public_key(pem_bytes) def _encrypt(plaintext: str) -> str: ciphertext = rsa_pub.encrypt( plaintext.encode("utf-8"), padding.OAEP( mgf = padding.MGF1(algorithm = hashes.SHA256()), algorithm = hashes.SHA256(), label = None, ), ) return base64.b64encode(ciphertext).decode("utf-8") return _encrypt # ── TestAuth ──────────────────────────────────────────────────────── class TestAuth: def test_login_returns_token(self): """POST /api/auth/login returns a non-empty access_token.""" assert PASSWORD, "STUDIO_TEST_PASSWORD not set" resp = requests.post( _url("/api/auth/login"), json = {"username": USERNAME, "password": PASSWORD}, timeout = 10, ) assert resp.status_code == 200, f"Login failed ({resp.status_code}): {resp.text}" body = resp.json() assert body.get("access_token"), "access_token is missing or empty" assert body.get("token_type") == "bearer" # ── TestPublicKey ──────────────────────────────────────────────────── class TestPublicKey: def test_public_key_is_valid_pem(self, auth_headers: dict[str, str], public_key_pem: str): """GET /api/providers/public-key returns an importable RSA PEM key.""" pem_bytes = public_key_pem.encode("utf-8") key = serialization.load_pem_public_key(pem_bytes) key_size = key.key_size # type: ignore[attr-defined] assert key_size >= 2048, f"Key size too small: {key_size}" print(f"\n RSA-{key_size} public key OK") # ── TestRegistry ──────────────────────────────────────────────────── class TestRegistry: def test_registry_returns_all_providers(self, auth_headers: dict[str, str]): """GET /api/providers/registry returns all supported providers.""" resp = requests.get( _url("/api/providers/registry"), headers = auth_headers, timeout = 10, ) assert resp.status_code == 200, f"Registry failed: {resp.text}" providers = resp.json() assert len(providers) == 9, f"Expected 9 providers, got {len(providers)}: {providers}" print(f"\n {'Provider':<12} {'Base URL'}") print(f" {'-'*12} {'-'*45}") for p in providers: print(f" {p['provider_type']:<12} {p['base_url']}") def test_registry_has_expected_types(self, auth_headers: dict[str, str]): """All expected provider_type values are present in the registry.""" resp = requests.get( _url("/api/providers/registry"), headers = auth_headers, timeout = 10, ) assert resp.status_code == 200 returned_types = {p["provider_type"] for p in resp.json()} missing = EXPECTED_PROVIDER_TYPES - returned_types assert not missing, f"Missing provider types: {missing}" def test_registry_entries_have_required_fields(self, auth_headers: dict[str, str]): """Each registry entry has provider_type, display_name, base_url, default_models.""" resp = requests.get(_url("/api/providers/registry"), headers = auth_headers, timeout = 10) assert resp.status_code == 200 for entry in resp.json(): for field in ( "provider_type", "display_name", "base_url", "default_models", "model_list_mode", ): assert field in entry, f"Missing field '{field}' in entry: {entry}" assert entry["model_list_mode"] in ("remote", "curated") assert isinstance(entry["default_models"], list) assert len(entry["default_models"]) > 0 # ── TestProviderCRUD ──────────────────────────────────────────────── class TestProviderCRUD: """ Run sequentially, sharing state via class variables. Create, read, update, and delete a single test provider config. """ _created_id: str = "" def test_create_provider(self, auth_headers: dict[str, str]): """POST /api/providers/ creates a provider config and returns 201.""" resp = requests.post( _url("/api/providers/"), headers = auth_headers, json = {"provider_type": "openai", "display_name": "Test OpenAI (pytest)"}, timeout = 10, ) assert resp.status_code == 201, f"Create failed ({resp.status_code}): {resp.text}" body = resp.json() assert body.get("id"), "No id in response" assert body["provider_type"] == "openai" assert body["display_name"] == "Test OpenAI (pytest)" assert body["is_enabled"] is True TestProviderCRUD._created_id = body["id"] print(f"\n created id={body['id']}") def test_list_includes_created(self, auth_headers: dict[str, str]): """GET /api/providers/ includes the newly created config.""" assert TestProviderCRUD._created_id, "No created_id (run test_create_provider first)" resp = requests.get(_url("/api/providers/"), headers = auth_headers, timeout = 10) assert resp.status_code == 200 ids = [p["id"] for p in resp.json()] assert ( TestProviderCRUD._created_id in ids ), f"Created id {TestProviderCRUD._created_id!r} not found in list: {ids}" print(f"\n found id={TestProviderCRUD._created_id} in list of {len(ids)}") def test_update_display_name(self, auth_headers: dict[str, str]): """PUT /api/providers/{id} updates the display_name.""" assert TestProviderCRUD._created_id, "No created_id" new_name = "Test OpenAI (pytest updated)" resp = requests.put( _url(f"/api/providers/{TestProviderCRUD._created_id}"), headers = auth_headers, json = {"display_name": new_name}, timeout = 10, ) assert resp.status_code == 200, f"Update failed ({resp.status_code}): {resp.text}" assert resp.json()["display_name"] == new_name print(f"\n updated display_name to '{new_name}'") def test_delete_provider(self, auth_headers: dict[str, str]): """DELETE /api/providers/{id} removes the config (204) and it's gone from list.""" assert TestProviderCRUD._created_id, "No created_id" resp = requests.delete( _url(f"/api/providers/{TestProviderCRUD._created_id}"), headers = auth_headers, timeout = 10, ) assert resp.status_code == 204, f"Delete failed ({resp.status_code}): {resp.text}" # Confirm gone list_resp = requests.get(_url("/api/providers/"), headers = auth_headers, timeout = 10) ids = [p["id"] for p in list_resp.json()] assert TestProviderCRUD._created_id not in ids, "Deleted provider still in list" print(f"\n deleted id={TestProviderCRUD._created_id} confirmed gone") # ── TestProviderInference ──────────────────────────────────────────── # Parametrize (provider_type, model, api_key) for configured providers _INFERENCE_PARAMS = [ pytest.param( ptype, model, PROVIDER_KEYS.get(ptype, ""), id = ptype, marks = pytest.mark.skipif( not PROVIDER_KEYS.get(ptype, ""), reason = f"no {env_var} set", ), ) for ptype, (env_var, model) in _PROVIDER_CONFIGS.items() ] class TestProviderInference: """ Live inference tests, one parametrized set per provider. Each is skipped when the provider's API key env var is unset. """ @pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS) def test_connection( self, auth_headers: dict[str, str], encrypt_key, provider_type: str, model: str, api_key: str, ): """POST /api/providers/test → success: true.""" encrypted = encrypt_key(api_key) resp = requests.post( _url("/api/providers/test"), headers = auth_headers, json = {"provider_type": provider_type, "encrypted_api_key": encrypted}, timeout = 30, ) assert resp.status_code == 200, f"Request failed ({resp.status_code}): {resp.text}" body = resp.json() assert ( body["success"] is True ), f"Connection test failed for {provider_type}: {body.get('message')}" print(f"\n [{provider_type}] connection OK — {body['message']}") @pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS) def test_list_models( self, auth_headers: dict[str, str], encrypt_key, provider_type: str, model: str, api_key: str, ): """POST /api/providers/models → non-empty list, print first 3.""" encrypted = encrypt_key(api_key) resp = requests.post( _url("/api/providers/models"), headers = auth_headers, json = {"provider_type": provider_type, "encrypted_api_key": encrypted}, timeout = 30, ) assert resp.status_code == 200, f"Request failed ({resp.status_code}): {resp.text}" models = resp.json() assert isinstance(models, list), f"Expected list, got {type(models)}" assert len(models) > 0, f"No models returned for {provider_type}" preview = [m["id"] for m in models[:3]] print(f"\n [{provider_type}] {len(models)} models — first 3: {preview}") @pytest.mark.parametrize("provider_type,model,api_key", _INFERENCE_PARAMS) def test_chat_inference( self, auth_headers: dict[str, str], encrypt_key, provider_type: str, model: str, api_key: str, ): """POST /v1/chat/completions with provider fields → streamed reply.""" encrypted = encrypt_key(api_key) payload = { "messages": [{"role": "user", "content": "Say hello in one sentence."}], "stream": True, "temperature": 0.7, "max_tokens": 64, "provider_type": provider_type, "external_model": model, "encrypted_api_key": encrypted, } with requests.post( _url("/v1/chat/completions"), headers = {**auth_headers, "Content-Type": "application/json"}, json = payload, stream = True, timeout = 60, ) as resp: assert ( resp.status_code == 200 ), f"Chat completions failed ({resp.status_code}): {resp.text[:500]}" reply, saw_done = _parse_sse_stream(resp) assert reply.strip(), f"Empty reply from {provider_type}/{model}" assert saw_done, f"Stream did not end with [DONE] for {provider_type}/{model}" print(f'\n [{provider_type}/{model}] reply: "{reply.strip()}"') # ── TestVisionInference ───────────────────────────────────────────── # Sloth photo for testing vision routing across providers _VISION_IMAGE_URL = "https://www.travelexcellence.com/images/where-to-see-sloths-in-costa-rica.jpg" _VISION_PARAMS = [ pytest.param( ptype, model, PROVIDER_KEYS.get(ptype, ""), id = ptype, marks = pytest.mark.skipif( not PROVIDER_KEYS.get(ptype, ""), reason = f"no key for {ptype}", ), ) for ptype, (_, model) in _PROVIDER_CONFIGS.items() if ptype in {"openai", "mistral", "gemini", "anthropic", "openrouter"} ] class TestVisionInference: """ Send a 1×1 white PNG plus a text question to each vision-capable provider. Verifies image content parts survive the proxy and the provider replies. """ @pytest.mark.parametrize("provider_type,model,api_key", _VISION_PARAMS) def test_vision_chat_inference( self, auth_headers: dict[str, str], encrypt_key, vision_image_data_url: str, provider_type: str, model: str, api_key: str, ): """Image URL + text message → non-empty streamed reply.""" encrypted = encrypt_key(api_key) payload = { "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Which animal is in this image? Reply in one word.", }, { "type": "image_url", "image_url": {"url": vision_image_data_url}, }, ], } ], "stream": True, "max_tokens": 215, "provider_type": provider_type, "external_model": model, "encrypted_api_key": encrypted, } with requests.post( _url("/v1/chat/completions"), headers = {**auth_headers, "Content-Type": "application/json"}, json = payload, stream = True, timeout = 60, ) as resp: assert ( resp.status_code == 200 ), f"Vision request failed ({resp.status_code}): {resp.text[:300]}" reply, saw_done = _parse_sse_stream(resp) assert reply.strip(), f"Empty reply from {provider_type}/{model}" assert saw_done, f"Stream did not end with [DONE] for {provider_type}/{model}" print(f"\n [{provider_type}/{model}] vision reply: {reply.strip()!r}") # ── TestLocalInferenceUnaffected ──────────────────────────────────── class TestLocalInferenceUnaffected: def test_chat_without_provider(self, auth_headers: dict[str, str]): """POST /v1/chat/completions without provider fields must not 422/500. 200 = local model responded; 503 = no model loaded (fine in tests); any other 4xx/5xx = request-handling regression. """ resp = requests.post( _url("/v1/chat/completions"), headers = {**auth_headers, "Content-Type": "application/json"}, json = { "messages": [{"role": "user", "content": "Hello"}], "stream": False, }, timeout = 15, ) allowed = {200, 400, 503} assert resp.status_code in allowed, ( f"Unexpected status {resp.status_code} for local inference path: {resp.text[:300]}\n" f"This likely means the provider fields broke the base request schema." ) status_label = ( "local model responded" if resp.status_code == 200 else "no model loaded (expected)" ) print(f"\n status={resp.status_code} ({status_label}) — local path unaffected")