CI / docs-check (push) Canceled after 0s
CI / backend-test (push) Canceled after 0s
CI / service-test (push) Canceled after 0s
CI / frontend-test (push) Canceled after 0s
CI / rust-core (push) Canceled after 0s
CI / docs-check (pull_request) Canceled after 0s
CI / backend-test (pull_request) Canceled after 0s
CI / service-test (pull_request) Canceled after 0s
CI / frontend-test (pull_request) Canceled after 0s
CI / rust-core (pull_request) Canceled after 0s
717 lines
32 KiB
Python
717 lines
32 KiB
Python
"""离线模型路由约定、HTTP 验证、介质生命周期和持久性。所有HTTP都使用MockTransport(或进程内API)。凭证、模型和附件都是假的,conftest 将所有存储重定向到临时路径。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
from email import policy
|
|
from email.parser import BytesParser
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.contracts import ModelBinding, ModelRoutingConfig, ProviderConfig, ProviderType
|
|
from app.errors import ApiError
|
|
from app.providers import MockProvider
|
|
from app.providers.credentials import CredentialStoreError
|
|
from app.providers.registry import ProviderRegistry
|
|
from app.providers.routing import ModelRoutingService, PendingSpeechBackend
|
|
from app.retrieval.embedding import HashEmbeddingProvider
|
|
|
|
|
|
def run(awaitable):
|
|
return asyncio.run(awaitable)
|
|
|
|
|
|
def response(data, status=200):
|
|
# 原始 JSON 特意允许 NaN/Infinity,用于测试恶意 API 输出。
|
|
return httpx.Response(status, content=json.dumps(data).encode(), headers={"content-type": "application/json"})
|
|
|
|
|
|
class FakeCredentials:
|
|
def __init__(self):
|
|
self.value = "unit-test-placeholder"
|
|
self.error = None
|
|
self.calls = []
|
|
|
|
def resolve(self, credential_id):
|
|
self.calls.append(credential_id)
|
|
if self.error:
|
|
raise self.error
|
|
return self.value if credential_id else None
|
|
|
|
|
|
class FakeEmbedding:
|
|
model_id = "fake-local-model"
|
|
dim = 3
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.error = None
|
|
|
|
async def embed_documents(self, texts):
|
|
self.calls.append(list(texts))
|
|
if self.error:
|
|
raise self.error
|
|
return [[0.6, 0.8, 0.0] for _ in texts]
|
|
|
|
|
|
class FakeSpeech:
|
|
available = True
|
|
|
|
def __init__(self):
|
|
self.calls = []
|
|
self.text = "local transcript"
|
|
self.score = 0.25
|
|
self.error = None
|
|
|
|
async def transcribe(self, source, language):
|
|
self.calls.append(("transcribe", source, language))
|
|
if self.error:
|
|
raise self.error
|
|
return self.text
|
|
|
|
async def match(self, source, reference):
|
|
self.calls.append(("match", source, reference))
|
|
if self.error:
|
|
raise self.error
|
|
return self.score
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def no_real_http(monkeypatch):
|
|
async def reject_async(*args, **kwargs):
|
|
pytest.fail("Real HTTP transport is forbidden in model-routing tests")
|
|
|
|
def reject_sync(*args, **kwargs):
|
|
pytest.fail("Real HTTP transport is forbidden in model-routing tests")
|
|
|
|
monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", reject_async)
|
|
monkeypatch.setattr(httpx.HTTPTransport, "handle_request", reject_sync)
|
|
|
|
|
|
@pytest.fixture
|
|
def rig():
|
|
requests = []
|
|
|
|
def unexpected(request):
|
|
pytest.fail(f"Unexpected model HTTP request: {request.url}")
|
|
|
|
state = SimpleNamespace(handler=unexpected)
|
|
|
|
async def dispatch(request):
|
|
requests.append(request)
|
|
result = state.handler(request)
|
|
return await result if hasattr(result, "__await__") else result
|
|
|
|
providers = ProviderRegistry()
|
|
config = ProviderConfig(
|
|
provider_id="test-provider", provider_type=ProviderType.openai_compatible,
|
|
name="Fake provider", base_url="https://models.invalid/v1/", credential_id="test-credential",
|
|
)
|
|
providers.register(config, MockProvider())
|
|
credentials, embedding, speech = FakeCredentials(), FakeEmbedding(), FakeSpeech()
|
|
service = ModelRoutingService(
|
|
providers, credentials, local_embedding=embedding, local_speech=speech,
|
|
transport=httpx.MockTransport(dispatch),
|
|
)
|
|
return SimpleNamespace(
|
|
service=service, providers=providers, credentials=credentials,
|
|
embedding=embedding, speech=speech, requests=requests, http=state,
|
|
)
|
|
|
|
|
|
def bind(rig, capability="embedding", **overrides):
|
|
endpoints = {
|
|
"embedding": "/embeddings", "transcription": "/audio/transcriptions",
|
|
"speaker_matching": "/audio/speaker-matches",
|
|
}
|
|
binding = ModelBinding(**{
|
|
"provider_id": "test-provider", "model": "test-model",
|
|
"endpoint": endpoints[capability], **overrides,
|
|
})
|
|
current = rig.service.configuration()
|
|
return rig.service.update(current.model_copy(update={capability: binding}))
|
|
|
|
|
|
def assert_local(rig, result, texts, reason):
|
|
assert result.source == "local"
|
|
assert result.model_id == rig.embedding.model_id
|
|
assert result.dimensions == 3
|
|
assert result.vectors == [[0.6, 0.8, 0.0] for _ in texts]
|
|
assert result.fallback_reason == reason
|
|
assert rig.embedding.calls == [texts]
|
|
|
|
|
|
def test_embedding_observation_keeps_request_binding_when_config_changes(rig):
|
|
from app.retrieval.provenance import capture_embedding
|
|
initial = bind(rig, model="original-model")
|
|
|
|
def handler(request):
|
|
assert json.loads(request.content)["model"] == "original-model"
|
|
bind(rig, model="next-model")
|
|
return response({"data": [{"index": 0, "embedding": [1, 0, 0]}]})
|
|
|
|
rig.http.handler = handler
|
|
with capture_embedding() as observation:
|
|
result = run(rig.service.embed(["query"]))
|
|
assert result.source == "api"
|
|
assert observation["route_version"] == initial.config.version
|
|
assert observation["requested_route"]["model"] == "original-model"
|
|
assert observation["requested_route"]["provider_id"] == "test-provider"
|
|
assert rig.service.configuration().embedding.model == "next-model"
|
|
assert rig.credentials.value not in json.dumps(observation)
|
|
assert "credential_id" not in json.dumps(observation)
|
|
|
|
|
|
@pytest.fixture
|
|
def audio(tmp_path):
|
|
source, reference = tmp_path / "audio.wav", tmp_path / "reference.wav"
|
|
source.write_bytes(b"fake-audio-content")
|
|
reference.write_bytes(b"fake-reference-content")
|
|
return source, reference
|
|
|
|
|
|
def media_call(rig, capability, audio):
|
|
if capability == "transcription":
|
|
return rig.service.transcribe(audio[0], "zh")
|
|
return rig.service.match_speakers(*audio)
|
|
|
|
|
|
def track_media_handles(rig, monkeypatch):
|
|
handles = []
|
|
original = rig.service._media_file
|
|
|
|
def tracked(path):
|
|
handle = original(path)
|
|
handles.append(handle)
|
|
return handle
|
|
|
|
monkeypatch.setattr(rig.service, "_media_file", tracked)
|
|
return handles
|
|
|
|
|
|
def test_absent_binding_uses_hash_without_network(rig):
|
|
rig.service.local_embedding = HashEmbeddingProvider()
|
|
texts = ["hello retrieval", "向量检索"]
|
|
result = run(rig.service.embed(texts))
|
|
assert result.source == "local"
|
|
assert result.model_id == "hash-v1"
|
|
assert result.dimensions == 128
|
|
assert result.vectors == run(HashEmbeddingProvider().embed_documents(texts))
|
|
assert result.fallback_reason is None
|
|
assert rig.requests == rig.credentials.calls == []
|
|
statuses = {item.capability: item.status for item in rig.service.describe().local_backends}
|
|
assert statuses == {"embedding": "placeholder", "transcription": "ready", "speaker_matching": "ready"}
|
|
|
|
|
|
def test_empty_embedding_input_does_not_call_remote(rig):
|
|
bind(rig)
|
|
result = run(rig.service.embed([]))
|
|
assert result.vectors == [] and result.source == "local"
|
|
assert rig.requests == []
|
|
|
|
|
|
def test_remote_embedding_restores_batch_order_normalizes_and_sends_auth(rig):
|
|
bind(rig, dimensions=2)
|
|
texts = [str(index) for index in range(35)]
|
|
|
|
def handler(request):
|
|
assert request.method == "POST"
|
|
assert str(request.url) == "https://models.invalid/v1/embeddings"
|
|
assert request.headers["authorization"] == "Bearer unit-test-placeholder"
|
|
payload = json.loads(request.content)
|
|
assert payload["model"] == "test-model"
|
|
assert payload["dimensions"] == 2
|
|
assert payload["encoding_format"] == "float"
|
|
return response({"data": [
|
|
{"index": index, "embedding": [float(int(text) + 1), 1.0]}
|
|
for index, text in reversed(list(enumerate(payload["input"])))
|
|
]})
|
|
|
|
rig.http.handler = handler
|
|
result = run(rig.service.embed(texts))
|
|
assert result.source == "api" and result.fallback_reason is None
|
|
assert result.dimensions == 2 and len(result.vectors) == 35
|
|
for index, vector in enumerate(result.vectors):
|
|
assert sum(value * value for value in vector) == pytest.approx(1.0)
|
|
assert vector[0] / vector[1] == pytest.approx(index + 1)
|
|
assert [json.loads(req.content)["input"] for req in rig.requests] == [texts[:32], texts[32:]]
|
|
assert rig.embedding.calls == []
|
|
|
|
|
|
def test_space_id_is_stable_and_includes_full_url_model_and_inferred_dimensions(rig):
|
|
dimensions = 2
|
|
|
|
def handler(request):
|
|
assert "dimensions" not in json.loads(request.content)
|
|
return response({"data": [{"index": 0, "embedding": [1.0] * dimensions}]})
|
|
|
|
rig.http.handler = handler
|
|
bind(rig, model=" trimmed-model ")
|
|
|
|
def check(url, model, dimension):
|
|
result = run(rig.service.embed(["hello"]))
|
|
digest = hashlib.sha256(json.dumps([url, model, dimension], separators=(",", ":")).encode()).hexdigest()
|
|
assert result.model_id == "api-" + digest
|
|
assert result.source == "api"
|
|
return result.model_id
|
|
|
|
first = check("https://models.invalid/v1/embeddings", "trimmed-model", 2)
|
|
assert first == check("https://models.invalid/v1/embeddings", "trimmed-model", 2)
|
|
config = rig.providers.get_any("test-provider").config.model_copy(update={"base_url": "https://models.invalid/v1"})
|
|
rig.providers.replace(config, MockProvider())
|
|
assert first == check("https://models.invalid/v1/embeddings", "trimmed-model", 2)
|
|
bind(rig, model="trimmed-model", endpoint="/custom/embeddings")
|
|
endpoint_id = check("https://models.invalid/v1/custom/embeddings", "trimmed-model", 2)
|
|
bind(rig, model="another-model", endpoint="/custom/embeddings")
|
|
model_id = check("https://models.invalid/v1/custom/embeddings", "another-model", 2)
|
|
dimensions = 3
|
|
dim_id = check("https://models.invalid/v1/custom/embeddings", "another-model", 3)
|
|
config = config.model_copy(update={"base_url": "https://other.invalid/v1"})
|
|
rig.providers.replace(config, MockProvider())
|
|
provider_id = check("https://other.invalid/v1/custom/embeddings", "another-model", 3)
|
|
assert len({first, endpoint_id, model_id, dim_id, provider_id}) == 5
|
|
|
|
|
|
@pytest.mark.parametrize("data", [
|
|
{"data": []},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}]},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 0, "embedding": [0, 1]}]},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 2, "embedding": [0, 1]}]},
|
|
{"data": [{"index": False, "embedding": [1, 0]}, {"index": 1, "embedding": [0, 1]}]},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [0, 1, 0]}]},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [float("nan"), 1]}]},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [float("inf"), 1]}]},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [True, 1]}]},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": [0, 0]}]},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": []}]},
|
|
{"data": [{"index": 0, "embedding": [1, 0]}, {"index": 1, "embedding": ["1", 0]}]},
|
|
{"data": [None, None]},
|
|
{"error": {"message": "in-band failure"}, "data": []},
|
|
[],
|
|
], ids=["empty", "count", "duplicate-index", "out-of-range-index", "bool-index", "dimensions", "nan", "infinity", "bool", "zero", "empty-vector", "string", "invalid-items", "in-band-error", "non-object"])
|
|
def test_invalid_remote_embeddings_fall_back_as_a_whole(rig, data):
|
|
bind(rig)
|
|
rig.http.handler = lambda request: response(data)
|
|
texts = ["first", "second"]
|
|
assert_local(rig, run(rig.service.embed(texts)), texts, "PROVIDER_INVALID_RESPONSE")
|
|
|
|
|
|
def test_explicit_embedding_dimension_mismatch_falls_back(rig):
|
|
bind(rig, dimensions=3)
|
|
rig.http.handler = lambda request: response({"data": [{"index": 0, "embedding": [1, 0]}]})
|
|
assert_local(rig, run(rig.service.embed(["text"])), ["text"], "PROVIDER_INVALID_RESPONSE")
|
|
|
|
|
|
def test_later_batch_dimension_mismatch_discards_earlier_remote_vectors(rig):
|
|
bind(rig)
|
|
|
|
def handler(request):
|
|
batch = json.loads(request.content)["input"]
|
|
dimension = 2 if len(rig.requests) == 1 else 3
|
|
return response({"data": [{"index": i, "embedding": [1] * dimension} for i in range(len(batch))]})
|
|
|
|
rig.http.handler = handler
|
|
texts = [str(i) for i in range(33)]
|
|
assert_local(rig, run(rig.service.embed(texts)), texts, "PROVIDER_INVALID_RESPONSE")
|
|
assert len(rig.requests) == 2
|
|
|
|
|
|
@pytest.mark.parametrize("failure, reason", [
|
|
(401, "PROVIDER_AUTH_FAILED"), (403, "PROVIDER_AUTH_FAILED"),
|
|
(404, "MODEL_NOT_FOUND"), (429, "PROVIDER_RATE_LIMITED"), (500, "PROVIDER_UNAVAILABLE"),
|
|
("timeout", "PROVIDER_TIMEOUT"), ("connect", "PROVIDER_UNAVAILABLE"),
|
|
("json", "PROVIDER_INVALID_RESPONSE"),
|
|
])
|
|
def test_embedding_http_failures_use_injected_local(rig, failure, reason):
|
|
bind(rig)
|
|
|
|
def handler(request):
|
|
if failure == "timeout":
|
|
raise httpx.ReadTimeout("simulated timeout", request=request)
|
|
if failure == "connect":
|
|
raise httpx.ConnectError("simulated connection failure", request=request)
|
|
if failure == "json":
|
|
return httpx.Response(200, content=b"not JSON")
|
|
return response({"error": "failed"}, failure)
|
|
|
|
rig.http.handler = handler
|
|
assert_local(rig, run(rig.service.embed(["text"])), ["text"], reason)
|
|
|
|
|
|
@pytest.mark.parametrize("failure, reason", [
|
|
("missing-key", "PROVIDER_CREDENTIAL_MISSING"),
|
|
("unreadable-key", "PROVIDER_CREDENTIAL_UNAVAILABLE"),
|
|
("disabled-provider", "PROVIDER_UNAVAILABLE"),
|
|
])
|
|
def test_unavailable_remote_configuration_falls_back_without_http(rig, failure, reason):
|
|
bind(rig)
|
|
if failure == "missing-key":
|
|
rig.credentials.value = None
|
|
elif failure == "unreadable-key":
|
|
rig.credentials.error = CredentialStoreError("fake unavailable store")
|
|
else:
|
|
config = rig.providers.get_any("test-provider").config.model_copy(update={"enabled": False})
|
|
rig.providers.replace(config, MockProvider())
|
|
assert_local(rig, run(rig.service.embed(["text"])), ["text"], reason)
|
|
assert rig.requests == []
|
|
|
|
|
|
@pytest.mark.parametrize("capability", ["transcription", "speaker_matching"])
|
|
def test_media_success_sends_expected_multipart_and_closes_files(rig, audio, monkeypatch, capability):
|
|
bind(rig, capability)
|
|
handles = track_media_handles(rig, monkeypatch)
|
|
|
|
def handler(request):
|
|
assert request.headers["authorization"] == "Bearer unit-test-placeholder"
|
|
assert str(request.url).endswith("/audio/transcriptions" if capability == "transcription" else "/audio/speaker-matches")
|
|
message = BytesParser(policy=policy.default).parsebytes(
|
|
b"Content-Type: " + request.headers["content-type"].encode() + b"\r\nMIME-Version: 1.0\r\n\r\n" + request.content,
|
|
)
|
|
parts = {part.get_param("name", header="content-disposition"): part for part in message.iter_parts()}
|
|
assert parts["model"].get_payload(decode=True) == b"test-model"
|
|
assert parts["file"].get_filename() == audio[0].name
|
|
assert parts["file"].get_payload(decode=True) == audio[0].read_bytes()
|
|
if capability == "transcription":
|
|
assert set(parts) == {"model", "language", "file"}
|
|
assert parts["language"].get_payload(decode=True) == b"zh"
|
|
return response({"text": "remote transcript"})
|
|
assert set(parts) == {"model", "file", "reference_file"}
|
|
assert parts["reference_file"].get_filename() == audio[1].name
|
|
assert parts["reference_file"].get_payload(decode=True) == audio[1].read_bytes()
|
|
return response({"score": 0.875})
|
|
|
|
rig.http.handler = handler
|
|
result = run(media_call(rig, capability, audio))
|
|
assert result.source == "api" and result.fallback_reason is None
|
|
assert result.text == "remote transcript" if capability == "transcription" else result.score == 0.875
|
|
assert len(handles) == (1 if capability == "transcription" else 2)
|
|
assert all(handle.closed for handle in handles)
|
|
assert rig.speech.calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("capability, data", [
|
|
("transcription", {}), ("transcription", {"text": " "}), ("transcription", {"text": False}),
|
|
("transcription", {"error": "in-band", "text": "must not use"}),
|
|
("speaker_matching", {}), ("speaker_matching", {"score": -0.1}),
|
|
("speaker_matching", {"score": 1.1}), ("speaker_matching", {"score": True}),
|
|
("speaker_matching", {"score": float("nan")}), ("speaker_matching", {"score": "0.5"}),
|
|
("speaker_matching", {"error": "in-band", "score": 0.9}),
|
|
])
|
|
def test_invalid_remote_media_falls_back_to_injected_local(rig, audio, monkeypatch, capability, data):
|
|
bind(rig, capability)
|
|
handles = track_media_handles(rig, monkeypatch)
|
|
rig.http.handler = lambda request: response(data)
|
|
result = run(media_call(rig, capability, audio))
|
|
assert result.source == "local" and result.fallback_reason == "PROVIDER_INVALID_RESPONSE"
|
|
assert result.text == "local transcript" if capability == "transcription" else result.score == 0.25
|
|
assert rig.speech.calls == [
|
|
("transcribe", audio[0], "zh") if capability == "transcription" else ("match", *audio)
|
|
]
|
|
assert handles and all(handle.closed for handle in handles)
|
|
|
|
|
|
@pytest.mark.parametrize("capability", ["transcription", "speaker_matching"])
|
|
@pytest.mark.parametrize("configured", [False, True])
|
|
def test_pending_local_backend_has_explicit_503_and_fallback_details(rig, audio, capability, configured):
|
|
rig.service.local_speech = PendingSpeechBackend()
|
|
if configured:
|
|
bind(rig, capability)
|
|
rig.http.handler = lambda request: response({"error": "unauthorized"}, 401)
|
|
with pytest.raises(ApiError) as caught:
|
|
run(media_call(rig, capability, audio))
|
|
assert caught.value.status_code == 503
|
|
assert caught.value.code == "LOCAL_MODEL_NOT_INSTALLED"
|
|
assert caught.value.details == {"fallback_reason": "PROVIDER_AUTH_FAILED" if configured else None}
|
|
statuses = {item.capability: item.status for item in rig.service.describe().local_backends}
|
|
assert statuses["transcription"] == statuses["speaker_matching"] == "not_installed"
|
|
assert len(rig.requests) == int(configured)
|
|
|
|
|
|
@pytest.mark.parametrize("capability", ["transcription", "speaker_matching"])
|
|
def test_invalid_local_speech_returns_explicit_503(rig, audio, capability):
|
|
rig.speech.text = ""
|
|
rig.speech.score = True
|
|
with pytest.raises(ApiError) as caught:
|
|
run(media_call(rig, capability, audio))
|
|
assert (caught.value.status_code, caught.value.code) == (503, "LOCAL_MODEL_INVALID_RESPONSE")
|
|
assert caught.value.details == {"fallback_reason": None}
|
|
|
|
|
|
@pytest.mark.parametrize("capability", ["embedding", "transcription", "speaker_matching"])
|
|
@pytest.mark.parametrize("stage", ["remote", "local"])
|
|
def test_cancellation_propagates_and_upload_handles_close(rig, audio, monkeypatch, capability, stage):
|
|
bind(rig, capability)
|
|
handles = track_media_handles(rig, monkeypatch)
|
|
|
|
async def cancelled(request):
|
|
raise asyncio.CancelledError()
|
|
|
|
if stage == "remote":
|
|
rig.http.handler = cancelled
|
|
else:
|
|
rig.http.handler = lambda request: response({"error": "fallback"}, 500)
|
|
rig.embedding.error = rig.speech.error = asyncio.CancelledError()
|
|
operation = rig.service.embed(["text"]) if capability == "embedding" else media_call(rig, capability, audio)
|
|
with pytest.raises(asyncio.CancelledError):
|
|
run(operation)
|
|
assert len(handles) == {"embedding": 0, "transcription": 1, "speaker_matching": 2}[capability]
|
|
assert all(handle.closed for handle in handles)
|
|
if stage == "remote":
|
|
assert rig.embedding.calls == rig.speech.calls == []
|
|
|
|
|
|
def test_missing_reference_closes_already_open_source(rig, audio, monkeypatch):
|
|
bind(rig, "speaker_matching")
|
|
handles = track_media_handles(rig, monkeypatch)
|
|
audio[1].unlink()
|
|
with pytest.raises(ApiError) as caught:
|
|
run(rig.service.match_speakers(*audio))
|
|
assert caught.value.status_code == 404
|
|
assert len(handles) == 1 and handles[0].closed
|
|
assert rig.requests == []
|
|
|
|
|
|
def test_config_optimistic_conflict_preserves_saved_bindings(rig):
|
|
assert rig.service.configuration().version == 0
|
|
saved = bind(rig).config
|
|
assert saved.version == 1
|
|
with pytest.raises(ApiError) as caught:
|
|
rig.service.update(ModelRoutingConfig(version=0))
|
|
assert (caught.value.status_code, caught.value.code) == (409, "MODEL_ROUTING_VERSION_CONFLICT")
|
|
assert rig.service.configuration() == saved
|
|
assert rig.service.uses_provider("test-provider")
|
|
assert not rig.service.uses_provider("not-a-provider")
|
|
cleared = rig.service.update(ModelRoutingConfig(version=1)).config
|
|
assert cleared.version == 2 and cleared.embedding is None
|
|
assert not rig.service.uses_provider("test-provider")
|
|
|
|
|
|
@pytest.mark.parametrize("capability", ["embedding", "transcription", "speaker_matching"])
|
|
@pytest.mark.parametrize("provider_id, code", [
|
|
("missing", "PROVIDER_NOT_FOUND"), ("unsupported", "MODEL_ROUTING_PROTOCOL_UNSUPPORTED"),
|
|
])
|
|
def test_config_references_require_existing_supported_providers(rig, capability, provider_id, code):
|
|
rig.providers.register(
|
|
ProviderConfig(provider_id="unsupported", provider_type=ProviderType.ollama, name="unsupported"), MockProvider(),
|
|
)
|
|
with pytest.raises(ApiError) as caught:
|
|
bind(rig, capability, provider_id=provider_id)
|
|
assert (caught.value.status_code, caught.value.code) == (422, code)
|
|
assert rig.service.configuration() == ModelRoutingConfig()
|
|
assert rig.requests == []
|
|
|
|
|
|
@pytest.fixture
|
|
def api(monkeypatch, no_real_http, _isolate_data_dir):
|
|
# 配置临时存储后才导入生产容器。
|
|
from app import container as container_module, routes
|
|
from app.main import app
|
|
|
|
containers = []
|
|
|
|
def restart():
|
|
container = container_module.build_container()
|
|
container.model_routing.credentials = FakeCredentials()
|
|
|
|
def unexpected(request):
|
|
pytest.fail(f"Unexpected API-side provider HTTP: {request.url}")
|
|
|
|
container.model_routing.transport = httpx.MockTransport(unexpected)
|
|
monkeypatch.setattr(container_module, "container", container)
|
|
monkeypatch.setattr(routes, "container", container)
|
|
containers.append(container)
|
|
return container
|
|
|
|
container = restart()
|
|
client = TestClient(app)
|
|
yield SimpleNamespace(client=client, container=container, restart=restart)
|
|
client.close()
|
|
for container in containers:
|
|
container.plugins.shutdown()
|
|
container.mcp_servers.shutdown()
|
|
|
|
|
|
def create_api_provider(api):
|
|
result = api.client.post("/api/providers", json={
|
|
"provider_type": "openai_compatible", "name": "Persisted fake",
|
|
"base_url": "https://persist.invalid/v1", "default_model": "fake-model",
|
|
})
|
|
assert result.status_code == 200, result.text
|
|
return result.json()
|
|
|
|
|
|
def test_api_config_conflict_reference_delete_and_restart_persistence(api):
|
|
provider = create_api_provider(api)
|
|
provider_id = provider["provider_id"]
|
|
assert api.client.get("/api/model-routing").json()["config"]["version"] == 0
|
|
config = {"version": 0, "embedding": {"provider_id": provider_id, "model": "embed-model", "endpoint": "/embeddings"}}
|
|
saved = api.client.put("/api/model-routing", json=config)
|
|
assert saved.status_code == 200
|
|
assert saved.json()["config"]["version"] == 1
|
|
conflict = api.client.put("/api/model-routing", json=config)
|
|
assert conflict.status_code == 409
|
|
assert conflict.json()["error"]["code"] == "MODEL_ROUTING_VERSION_CONFLICT"
|
|
blocked = api.client.delete(f"/api/providers/{provider_id}")
|
|
assert blocked.status_code == 409 and blocked.json()["error"]["code"] == "PROVIDER_IN_USE"
|
|
restarted = api.restart()
|
|
assert restarted.providers.get_any(provider_id).config.model_dump(mode="json") == provider
|
|
assert api.client.get("/api/model-routing").json()["config"] == saved.json()["config"]
|
|
assert {item["provider_id"] for item in api.client.get("/api/providers").json()["items"]} == {"mock", provider_id}
|
|
cleared = api.client.put("/api/model-routing", json={"version": 1})
|
|
assert cleared.status_code == 200
|
|
assert api.client.delete(f"/api/providers/{provider_id}").status_code == 200
|
|
api.restart()
|
|
assert api.client.get(f"/api/providers/{provider_id}").status_code == 404
|
|
assert api.client.get("/api/model-routing").json()["config"]["version"] == 2
|
|
|
|
|
|
def test_api_provider_type_patch_rebuilds_adapter_and_persists(api):
|
|
from app.providers.anthropic_messages import AnthropicMessagesProvider
|
|
|
|
provider = create_api_provider(api)
|
|
provider_id = provider["provider_id"]
|
|
changed = api.client.patch(f"/api/providers/{provider_id}", json={
|
|
"provider_type": "anthropic_messages", "base_url": "https://anthropic.invalid/v1",
|
|
})
|
|
assert changed.status_code == 200, changed.text
|
|
assert changed.json()["provider_type"] == "anthropic_messages"
|
|
assert changed.json()["name"] == provider["name"]
|
|
assert isinstance(api.container.providers.get_any(provider_id).adapter, AnthropicMessagesProvider)
|
|
restarted = api.restart()
|
|
assert isinstance(restarted.providers.get_any(provider_id).adapter, AnthropicMessagesProvider)
|
|
assert api.client.get(f"/api/providers/{provider_id}").json() == changed.json()
|
|
for invalid_type in (None, "mock", "nonexistent-type"):
|
|
rejected = api.client.patch(f"/api/providers/{provider_id}", json={"provider_type": invalid_type})
|
|
assert rejected.status_code == 422
|
|
assert api.client.get(f"/api/providers/{provider_id}").json() == changed.json()
|
|
|
|
|
|
@pytest.mark.parametrize("endpoint", ["https://elsewhere.invalid/embed", "//elsewhere.invalid/embed", "relative", "/../embed", "/embed?key=test"])
|
|
def test_api_config_rejects_non_provider_endpoint_paths(api, endpoint):
|
|
provider = create_api_provider(api)
|
|
result = api.client.put("/api/model-routing", json={
|
|
"embedding": {"provider_id": provider["provider_id"], "model": "embed", "endpoint": endpoint},
|
|
})
|
|
assert result.status_code == 422
|
|
assert api.client.get("/api/model-routing").json()["config"]["version"] == 0
|
|
|
|
|
|
def test_api_embedding_reports_remote_and_fallback_sources(api):
|
|
provider = create_api_provider(api)
|
|
assert api.client.put("/api/model-routing", json={
|
|
"embedding": {"provider_id": provider["provider_id"], "model": "embed", "endpoint": "/embeddings"},
|
|
}).status_code == 200
|
|
api.container.model_routing.transport = httpx.MockTransport(
|
|
lambda request: response({"data": [{"index": 0, "embedding": [3, 4]}]}),
|
|
)
|
|
result = api.client.post("/api/models/embeddings", json={"texts": ["hello"]})
|
|
assert result.status_code == 200
|
|
assert result.json()["source"] == "api" and result.json()["vectors"][0] == pytest.approx([0.6, 0.8])
|
|
api.container.model_routing.transport = httpx.MockTransport(lambda request: response({"error": "denied"}, 401))
|
|
result = api.client.post("/api/models/embeddings", json={"texts": ["hello"]})
|
|
assert result.status_code == 200
|
|
assert result.json()["source"] == "local" and result.json()["model_id"] == "hash-v1"
|
|
assert result.json()["fallback_reason"] == "PROVIDER_AUTH_FAILED"
|
|
assert api.client.post("/api/models/embeddings", json={"texts": []}).status_code == 422
|
|
|
|
|
|
def test_api_speech_failure_reports_reason_in_503_and_transcription_job(api):
|
|
from app.services.attachment_service import attachment_path
|
|
|
|
source, reference = attachment_path("audio.wav"), attachment_path("reference.wav")
|
|
source.parent.mkdir(parents=True, exist_ok=True)
|
|
source.write_bytes(b"test audio")
|
|
reference.write_bytes(b"test reference")
|
|
provider = create_api_provider(api)
|
|
assert api.client.put("/api/model-routing", json={
|
|
"transcription": {"provider_id": provider["provider_id"], "model": "asr", "endpoint": "/audio/transcriptions"},
|
|
"speaker_matching": {"provider_id": provider["provider_id"], "model": "voice", "endpoint": "/audio/speaker-matches"},
|
|
}).status_code == 200
|
|
api.container.model_routing.transport = httpx.MockTransport(lambda request: response({"error": "offline"}, 500))
|
|
match = api.client.post("/api/media/speaker-matches", json={"attachment_id": source.name, "reference_attachment_id": reference.name})
|
|
assert match.status_code == 503
|
|
assert match.json()["error"]["code"] == "LOCAL_MODEL_NOT_INSTALLED"
|
|
assert match.json()["error"]["details"] == {"fallback_reason": "PROVIDER_UNAVAILABLE"}
|
|
with api.client:
|
|
transcript = api.client.post("/api/media/transcriptions", json={"attachment_id": source.name, "language": "zh"})
|
|
assert transcript.status_code == 202
|
|
job = transcript.json()
|
|
assert job["status"] == "queued"
|
|
stream = api.client.get(f"/api/media/transcriptions/{job['job_id']}/events")
|
|
assert "event: Failed" in stream.text
|
|
job = api.client.get(f"/api/media/transcriptions/{job['job_id']}").json()
|
|
assert job["status"] == "failed" and job["error_code"] == "LOCAL_MODEL_NOT_INSTALLED"
|
|
assert job["fallback_reason"] == "PROVIDER_UNAVAILABLE"
|
|
assert api.client.get(f"/api/media/transcriptions/{job['job_id']}").json() == job
|
|
|
|
|
|
@pytest.mark.parametrize("capability", ["embedding", "speaker_matching"])
|
|
def test_out_of_float_range_json_number_is_invalid_remote_and_falls_back(rig, audio, capability):
|
|
"""JSON 整数可能是有限的,但太大而无法转换为 Python 浮点数。"""
|
|
bind(rig, capability)
|
|
data = {"data": [{"index": 0, "embedding": [10 ** 400, 1]}]} if capability == "embedding" else {"score": 10 ** 400}
|
|
rig.http.handler = lambda request: response(data)
|
|
if capability == "embedding":
|
|
assert_local(rig, run(rig.service.embed(["text"])), ["text"], "PROVIDER_INVALID_RESPONSE")
|
|
else:
|
|
result = run(media_call(rig, capability, audio))
|
|
assert result.source == "local" and result.score == rig.speech.score
|
|
assert result.fallback_reason == "PROVIDER_INVALID_RESPONSE"
|
|
|
|
|
|
def test_remote_segments_are_validated_and_local_only_skips_api(rig, audio):
|
|
bind(rig, "transcription")
|
|
rig.http.handler = lambda request: response({"text":"内容", "segments":[{"start":0,"end":1.5,"text":"内容"}]})
|
|
result = run(rig.service.transcribe(audio[0], "zh"))
|
|
assert result.source == "api" and result.segments[0].end_time == 1.5
|
|
rig.http.handler = lambda request: response({"text":"内容", "segments":[{"start":2,"end":1,"text":"内容"}]})
|
|
assert run(rig.service.transcribe(audio[0], "zh")).fallback_reason == "PROVIDER_INVALID_RESPONSE"
|
|
count = len(rig.requests)
|
|
result = run(rig.service.transcribe(audio[0], "zh", local_only=True))
|
|
assert result.source == "local" and len(rig.requests) == count
|
|
|
|
|
|
def test_embedding_local_only_does_not_change_normal_api_fallback(rig):
|
|
bind(rig)
|
|
result = run(rig.service.embed(['private'], local_only=True))
|
|
assert result.source == 'local' and result.fallback_reason is None
|
|
assert rig.requests == [] and rig.credentials.calls == []
|
|
rig.http.handler = lambda request: response({'data': [{'index': 0, 'embedding': [1, 0, 0]}]})
|
|
assert run(rig.service.embed(['normal'])).source == 'api'
|
|
rig.http.handler = lambda request: response({}, status=503)
|
|
result = run(rig.service.embed(['fallback']))
|
|
assert result.source == 'local' and result.fallback_reason
|
|
|
|
|
|
@pytest.mark.parametrize('api_failure', [False, True])
|
|
def test_local_embedding_identity_and_device_are_frozen_during_inference(rig, monkeypatch, api_failure):
|
|
import app.local_models.runtime as module
|
|
config = module.RuntimeConfig(embedding_model='bekko')
|
|
monkeypatch.setattr(module, 'configuration', lambda: module.runtime_context.get() or config)
|
|
calls = []
|
|
async def infer(key, *args, **kwargs):
|
|
calls.append(key)
|
|
config.embedding_model = 'granite'
|
|
config.device = 'cuda'
|
|
await asyncio.sleep(0)
|
|
assert module.configuration().embedding_model == key
|
|
assert module.configuration().device == ('cpu' if len(calls) == 1 else 'cuda')
|
|
return [[1.0] + [0.0] * 383]
|
|
monkeypatch.setattr(module.runtime, 'infer', infer)
|
|
rig.service.local_embedding = module.LocalEmbedding()
|
|
if api_failure:
|
|
bind(rig)
|
|
rig.http.handler = lambda request: response({}, status=503)
|
|
first = run(rig.service.embed(['first']))
|
|
assert 'bekko' in first.model_id
|
|
assert module.runtime_context.get() is None
|
|
second = run(rig.service.embed(['second']))
|
|
assert 'granite' in second.model_id
|
|
assert calls == ['bekko', 'granite']
|
|
assert bool(first.fallback_reason) == api_failure
|