e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
568 lines
22 KiB
Python
568 lines
22 KiB
Python
# 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")
|