feat(multimodal): 实现本地模型管线与请求用量配置
This commit is contained in:
@@ -19,5 +19,19 @@ def _isolate_data_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APP_VAULT_PATH", str(tmp_path / "vault"))
|
||||
# 清除 lru 缓存,让本次测试内的 get_settings() 读到临时目录
|
||||
get_settings.cache_clear()
|
||||
# Unit tests explicitly inject deterministic embeddings. Production uses real models.
|
||||
from app import container as container_module
|
||||
from app.services import note_service
|
||||
from app.retrieval.engine import engine
|
||||
from app.retrieval.embedding import HashEmbeddingProvider
|
||||
from app.providers.routing import ModelRoutingService
|
||||
def test_routing(providers, credentials):
|
||||
return ModelRoutingService(providers, credentials, local_embedding=HashEmbeddingProvider())
|
||||
monkeypatch.setattr(container_module, "_local_model_routing", test_routing)
|
||||
monkeypatch.setattr(container_module.container.model_routing, "local_embedding", HashEmbeddingProvider())
|
||||
monkeypatch.setattr(note_service, "embedding", HashEmbeddingProvider())
|
||||
test_embedding = HashEmbeddingProvider()
|
||||
monkeypatch.setattr(engine, "embedding", test_embedding)
|
||||
monkeypatch.setattr(engine, "_routed_defaults", (test_embedding, engine.vector_store))
|
||||
yield
|
||||
get_settings.cache_clear()
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.local_models import manager
|
||||
from app.local_models.runtime import Runtime
|
||||
from app.providers.base import ProviderError
|
||||
|
||||
|
||||
def test_download_resumes_partial_and_checks_digest(monkeypatch):
|
||||
payload = b'verified-model-weights'
|
||||
entry = {'path':'model.safetensors','size':len(payload),'hash':hashlib.sha256(payload).hexdigest(),
|
||||
'algorithm':'sha256','url':'https://fixture.invalid/weights'}
|
||||
async def manifest(client, spec):
|
||||
return [entry]
|
||||
monkeypatch.setattr(manager, '_manifest', manifest)
|
||||
path = manager.model_path('bekko')
|
||||
path.mkdir(parents=True)
|
||||
(path/'model.safetensors.partial').write_bytes(payload[:5])
|
||||
requests = []
|
||||
def respond(request):
|
||||
requests.append(request)
|
||||
assert request.headers['range'] == 'bytes=5-'
|
||||
return httpx.Response(206, headers={'content-range':f'bytes 5-{len(payload)-1}/{len(payload)}'},content=payload[5:])
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(manager.httpx,'AsyncClient',lambda **kwargs:original(**kwargs,transport=httpx.MockTransport(respond)))
|
||||
asyncio.run(manager._download('bekko'))
|
||||
assert manager.read_state('bekko')['status'] == 'installed'
|
||||
assert (path/'model.safetensors').read_bytes() == payload
|
||||
assert manager.valid_file(path/'model.safetensors',entry)
|
||||
(path/'model.safetensors').write_bytes(b'x'*len(payload))
|
||||
assert not manager.valid_file(path/'model.safetensors',entry)
|
||||
assert len(requests) == 1
|
||||
|
||||
|
||||
def test_local_model_missing_is_explicit():
|
||||
with pytest.raises(ProviderError) as error:
|
||||
asyncio.run(Runtime().infer('qwen3-asr','transcription',{'source':'missing.wav'}))
|
||||
assert error.value.code == 'LOCAL_MODEL_NOT_INSTALLED'
|
||||
|
||||
|
||||
def test_cancel_reaps_active_model_process(monkeypatch):
|
||||
import app.local_models.runtime as module
|
||||
monkeypatch.setattr(module,'read_state',lambda key:{'status':'installed'})
|
||||
monkeypatch.setattr(module,'interpreter',lambda:Path(sys.executable))
|
||||
class Input:
|
||||
def write(self, value):
|
||||
request = json.loads(value)
|
||||
assert request['config']['device'] == 'cpu'
|
||||
async def drain(self):
|
||||
pass
|
||||
def close(self):
|
||||
pass
|
||||
class Process:
|
||||
returncode = None
|
||||
stdin = Input()
|
||||
def __init__(self):
|
||||
self.stdout = asyncio.StreamReader()
|
||||
self.killed = False
|
||||
def kill(self):
|
||||
self.killed = True
|
||||
self.returncode = -9
|
||||
self.stdout.feed_eof()
|
||||
async def wait(self):
|
||||
return self.returncode
|
||||
async def scenario():
|
||||
started = asyncio.Event()
|
||||
process = Process()
|
||||
async def spawn(*args, **kwargs):
|
||||
assert kwargs['env']['HF_HUB_OFFLINE'] == '1'
|
||||
started.set()
|
||||
return process
|
||||
monkeypatch.setattr(module.asyncio,'create_subprocess_exec',spawn)
|
||||
runtime = Runtime()
|
||||
task = asyncio.create_task(runtime.infer('qwen3-asr','transcription',{'source':'fixture.wav'}))
|
||||
await started.wait()
|
||||
task.cancel()
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await task
|
||||
assert process.killed and not runtime.active
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Durability, cancellation and optimistic editing without model downloads."""
|
||||
import asyncio
|
||||
from contextlib import closing
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.contracts import TranscriptEditRequest
|
||||
from app.database.db import connect
|
||||
from app.errors import ApiError
|
||||
from app.services import transcription_service as jobs
|
||||
from app.services.attachment_service import attachment_path
|
||||
|
||||
|
||||
def text_attachment():
|
||||
path = attachment_path("lecture.txt")
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text("原始识别内容", encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def test_idempotency_edit_history_and_event_replay():
|
||||
text_attachment()
|
||||
|
||||
async def scenario():
|
||||
first = await jobs.create_transcription("lecture.txt", idempotency_key="submit-1")
|
||||
repeated = await jobs.create_transcription("lecture.txt", idempotency_key="submit-1")
|
||||
assert first.job_id == repeated.job_id
|
||||
assert first.status == "completed"
|
||||
with pytest.raises(ApiError) as conflict:
|
||||
await jobs.create_transcription("lecture.txt", language="en", idempotency_key="submit-1")
|
||||
assert conflict.value.code == "IDEMPOTENCY_CONFLICT"
|
||||
revised = jobs.edit(first.job_id, TranscriptEditRequest(revision=1, text="校对内容"))
|
||||
assert revised.original_text == "原始识别内容"
|
||||
assert revised.revision == 2
|
||||
with pytest.raises(ApiError) as stale:
|
||||
jobs.edit(first.job_id, TranscriptEditRequest(revision=1, text="覆盖"))
|
||||
assert stale.value.code == "VERSION_CONFLICT"
|
||||
with closing(connect()) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM media_revisions").fetchone()[0] == 1
|
||||
events = jobs.events(first.job_id)
|
||||
assert [e["event"] for e in events] == ["Queued", "TranscriptionStarted", "Completed", "Revised"]
|
||||
assert jobs.events(first.job_id, events[-2]["sequence"]) == events[-1:]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_cancel_before_start_retry_and_restart_recovery():
|
||||
text_attachment()
|
||||
|
||||
async def scenario():
|
||||
job = await jobs.create_transcription("lecture.txt", wait=False)
|
||||
cancelled = await jobs.cancel(job.job_id)
|
||||
assert cancelled.status == "cancelled"
|
||||
next_job = await jobs.retry(job.job_id)
|
||||
assert next_job.previous_job_id == job.job_id
|
||||
assert next_job.job_id != job.job_id
|
||||
await jobs._tasks[jobs.task_key(next_job.job_id)]
|
||||
assert jobs.require_job(next_job.job_id).status == "completed"
|
||||
# Simulate a persisted job left behind by a stopped process.
|
||||
cancelled.status = "running"
|
||||
jobs.save(cancelled, "TranscriptionStarted")
|
||||
jobs.recover_interrupted()
|
||||
assert jobs.require_job(job.job_id).error_code == "TRANSCRIPTION_INTERRUPTED"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_controlled_upload_and_async_http_flow():
|
||||
from app.main import app
|
||||
with TestClient(app) as client:
|
||||
assert client.post("/api/media/attachments?filename=a.wav", content=b"").status_code == 422
|
||||
uploaded = client.post("/api/media/attachments?filename=lecture.txt", content="真实转写文本".encode())
|
||||
assert uploaded.status_code == 201
|
||||
attachment_id = uploaded.json()["attachment_id"]
|
||||
assert client.get(f"/api/media/attachments/{attachment_id}").content == "真实转写文本".encode()
|
||||
response = client.post("/api/media/transcriptions", json={"attachment_id": attachment_id})
|
||||
assert response.status_code == 202 and response.json()["status"] == "queued"
|
||||
job_id = response.json()["job_id"]
|
||||
events = client.get(f"/api/media/transcriptions/{job_id}/events")
|
||||
assert "event: Completed" in events.text
|
||||
assert client.get("/api/media/transcriptions").json()["page"]["total"] == 1
|
||||
assert client.get(f"/api/media/transcriptions/{job_id}").json()["text"] == "真实转写文本"
|
||||
assert client.get(f"/api/media/transcriptions/{job_id}/events", headers={"Last-Event-ID": "bad"}).status_code == 422
|
||||
|
||||
|
||||
def test_terminology_export_and_privacy_cleanup():
|
||||
from app.main import app
|
||||
text_attachment()
|
||||
with TestClient(app) as client:
|
||||
created = client.post('/api/media/transcriptions', json={'attachment_id':'lecture.txt','terminology':{'识别':'校对'}}).json()
|
||||
job_id = created['job_id']
|
||||
client.get(f'/api/media/transcriptions/{job_id}/events')
|
||||
job = client.get(f'/api/media/transcriptions/{job_id}').json()
|
||||
assert job['text'] == '原始校对内容' and job['original_text'] == '原始识别内容'
|
||||
first = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json()
|
||||
again = client.post(f'/api/media/transcriptions/{job_id}/notes', json={'title':'课程'}).json()
|
||||
assert first['note_id'] == again['note_id']
|
||||
response = client.delete('/api/media/attachments/lecture.txt')
|
||||
assert first['note_id'] in response.json()['retained_note_ids']
|
||||
cleaned = client.get(f'/api/media/transcriptions/{job_id}').json()
|
||||
assert cleaned['text'] is None and cleaned['original_text'] is None and cleaned['corrections'] == []
|
||||
assert client.post(f'/api/media/transcriptions/{job_id}/retry').status_code == 409
|
||||
assert client.get('/api/media/attachments/lecture.txt').status_code == 404
|
||||
@@ -641,9 +641,14 @@ def test_api_speech_failure_reports_reason_in_503_and_transcription_job(api):
|
||||
assert match.status_code == 503
|
||||
assert match.json()["error"]["code"] == "LOCAL_MODEL_NOT_INSTALLED"
|
||||
assert match.json()["error"]["details"] == {"fallback_reason": "PROVIDER_UNAVAILABLE"}
|
||||
transcript = api.client.post("/api/media/transcriptions", json={"attachment_id": source.name, "language": "zh"})
|
||||
assert transcript.status_code == 202
|
||||
job = transcript.json()
|
||||
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
|
||||
@@ -661,3 +666,15 @@ def test_out_of_float_range_json_number_is_invalid_remote_and_falls_back(rig, au
|
||||
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
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from contextlib import closing
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.contracts import ModelRequest, ProviderConfig, ProviderType
|
||||
from app.providers.factory import ProviderFactory
|
||||
from app.request_overrides import RequestOverride, apply_overrides
|
||||
from app.services.usage_service import UsageAttempt, aggregate, connection
|
||||
|
||||
|
||||
def summary():
|
||||
now = datetime.now(timezone.utc)
|
||||
return aggregate(now - timedelta(days=1), now + timedelta(days=1))
|
||||
|
||||
|
||||
def test_cumulative_usage_deduplicates_and_missing_is_not_zero():
|
||||
attempt = UsageAttempt("test", "chat", "openai_compatible")
|
||||
attempt.observe({"usage": {"prompt_tokens": 100, "completion_tokens": 2, "prompt_tokens_details": {"cached_tokens": 75}}})
|
||||
attempt.persist()
|
||||
attempt.observe({"usage": {"completion_tokens": 5}})
|
||||
attempt.observe({"usage": {"completion_tokens": 3}})
|
||||
attempt.persist()
|
||||
incomplete = UsageAttempt("test", "chat", "openai_compatible")
|
||||
incomplete.persist()
|
||||
result = summary()
|
||||
assert result["request_count"] == 2
|
||||
assert result["totals"]["input_tokens"] == 100
|
||||
assert result["totals"]["output_tokens"] == 5
|
||||
assert result["totals"]["cache_write_tokens"] is None
|
||||
assert result["cache_hit_rate"] == .75
|
||||
assert result["coverage"]["input_tokens"] == 1
|
||||
|
||||
|
||||
def test_anthropic_cache_is_added_once_and_raw_text_is_not_saved():
|
||||
attempt = UsageAttempt("test", "claude", "anthropic_messages")
|
||||
attempt.observe({"message": {"usage": {"input_tokens": 10, "cache_read_input_tokens": 80,
|
||||
"cache_creation_input_tokens": 20, "output_tokens": 0, "secret": "private text"}}})
|
||||
attempt.observe({"usage": {"output_tokens": 12}})
|
||||
attempt.persist()
|
||||
counts = summary()["totals"]
|
||||
assert counts["input_tokens"] == 110 and counts["total_tokens"] == 122
|
||||
assert counts["cache_miss_tokens"] == 10
|
||||
with closing(connection()) as conn:
|
||||
assert "private text" not in conn.execute("SELECT raw_json FROM model_usage").fetchone()[0]
|
||||
|
||||
|
||||
def test_override_rules_merge_and_respect_capability_and_stream():
|
||||
rules = [RequestOverride(body={"stream_options": {"include_usage": True, "extra": 1}, "stop": ["one"]}),
|
||||
RequestOverride(model="special", stream=True, body={"stream_options": {"extra": 2}, "stop": ["two"], "temperature": None}),
|
||||
RequestOverride(capability="embedding", body={"dimensions": 384})]
|
||||
base = {"model": "special", "messages": [], "stream": True}
|
||||
result = apply_overrides(base, rules, "chat", stream=True)
|
||||
assert result["stream_options"] == {"include_usage": True, "extra": 2}
|
||||
assert result["stop"] == ["two"] and result["temperature"] is None
|
||||
assert "dimensions" not in result and "stop" not in base
|
||||
assert apply_overrides(base, rules, "chat")["stop"] == ["one"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", [{"model":"other"}, {"messages":[]}, {"tools":[]}, {"stream":False},
|
||||
{"metadata":{"api_key":"hidden"}}, {"stream_options":{"include_usage": "false"}}])
|
||||
def test_unsafe_or_invalid_overrides_are_rejected(body):
|
||||
with pytest.raises(ValidationError):
|
||||
RequestOverride(body=body)
|
||||
|
||||
|
||||
def test_real_adapter_body_and_usage_persistence():
|
||||
class Credentials:
|
||||
def resolve(self, key):
|
||||
return None
|
||||
config = ProviderConfig(provider_id="wire", provider_type=ProviderType.openai_compatible, name="Wire", base_url="https://model.invalid/v1",
|
||||
request_overrides=[RequestOverride(stream=True, body={"stream_options":{"include_usage":False},"enable_thinking":False})])
|
||||
adapter = ProviderFactory(Credentials()).build(config)
|
||||
captured = []
|
||||
def respond(request):
|
||||
captured.append(json.loads(request.content))
|
||||
return httpx.Response(200, headers={"content-type":"text/event-stream"}, content=(
|
||||
'data: {"choices":[{"delta":{"content":"ok"},"finish_reason":null}]}\n\n'
|
||||
'data: {"choices":[],"usage":{"prompt_tokens":10,"completion_tokens":1}}\n\n'
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n'
|
||||
'data: [DONE]\n\n'))
|
||||
adapter.transport = httpx.MockTransport(respond)
|
||||
async def consume():
|
||||
return [event async for event in adapter.stream(ModelRequest(provider_id="wire", model="special", messages=[]))]
|
||||
asyncio.run(consume())
|
||||
assert captured[0]["enable_thinking"] is False
|
||||
assert captured[0]["stream_options"]["include_usage"] is False
|
||||
result = summary()
|
||||
assert result["request_count"] == 1 and result["totals"]["input_tokens"] == 10
|
||||
assert result["complete_requests"] == 1
|
||||
Reference in New Issue
Block a user