feat: browser access (allowed_origins, CORS with Private Network Access), job cancel, Tomie/Unity adapters, benchmark
Includes earlier uncommitted work: Tomie and Unity adapters, benchmark, structured output, call metrics. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13492929c6
commit
f49ee90477
32 files changed
+2799
-51
No files matched your search
+90
-10
@@ -71,13 +71,13 @@ class APIEngineTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.calls.append(inputs)
|
||||
output = []
|
||||
for item in inputs:
|
||||
if item["id"] in self.fail_ids:
|
||||
if item["text"] in self.fail_ids:
|
||||
continue
|
||||
if item["id"] in self.fail_once:
|
||||
self.fail_once.remove(item["id"])
|
||||
if item["text"] in self.fail_once:
|
||||
self.fail_once.remove(item["text"])
|
||||
continue
|
||||
output.append({"id": item["id"], "text": item["text"].replace("Hello", "Привет")})
|
||||
return httpx.Response(200, json={"choices": [{"finish_reason": "stop", "message": {"content": json.dumps({"items": output}, ensure_ascii=False)}}]})
|
||||
return httpx.Response(200, json={"usage": {"prompt_tokens": 10, "completion_tokens": 4}, "choices": [{"finish_reason": "stop", "message": {"content": json.dumps({"items": output}, ensure_ascii=False)}}]})
|
||||
|
||||
self.transport = httpx.MockTransport(handler)
|
||||
self.factory = lambda config: Provider(config, transport=self.transport)
|
||||
@@ -113,12 +113,19 @@ class APIEngineTests(unittest.IsolatedAsyncioTestCase):
|
||||
job = await self.submit(items)
|
||||
self.assertEqual(job["status"], "completed")
|
||||
self.assertEqual([len(batch) for batch in self.calls], [10, 2])
|
||||
self.assertEqual(job["metrics"]["provider_calls"], 2)
|
||||
self.assertEqual(job["metrics"]["prompt_tokens"], 20)
|
||||
self.assertEqual(job["metrics"]["completion_tokens"], 8)
|
||||
self.assertEqual(job["metrics"]["calls_with_completion_usage"], 2)
|
||||
self.assertGreater(job["metrics"]["provider_seconds"], 0)
|
||||
self.assertEqual(job["items"][0]["translation"], "Привет {player} 0")
|
||||
self.assertNotIn("{player}", self.calls[0][0]["text"])
|
||||
payload = json.loads(self.requests[1].content)
|
||||
self.assertEqual([x["role"] for x in payload["messages"]], ["user"])
|
||||
self.assertIn("Fantasy game", payload["messages"][0]["content"])
|
||||
self.assertEqual(payload["top_p"], 0.6)
|
||||
self.assertEqual(payload["response_format"]["type"], "json_schema")
|
||||
self.assertEqual([item["id"] for item in self.calls[0]], [str(i) for i in range(10)])
|
||||
cached = await self.submit(items)
|
||||
self.assertTrue(all(item["cached"] for item in cached["items"]))
|
||||
self.assertEqual(len(self.calls), 2)
|
||||
@@ -134,14 +141,14 @@ class APIEngineTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(old["items"], job["items"])
|
||||
|
||||
async def test_only_missing_items_are_retried(self):
|
||||
self.fail_once.add("bad")
|
||||
self.fail_once.add("Hello again")
|
||||
job = await self.submit([{"id": "good", "text": "Hello"}, {"id": "bad", "text": "Hello again"}], retries=1)
|
||||
self.assertEqual(job["status"], "completed")
|
||||
self.assertEqual([[item["id"] for item in batch] for batch in self.calls], [["good", "bad"], ["bad"]])
|
||||
self.assertEqual([[item["text"] for item in batch] for batch in self.calls], [["Hello", "Hello again"], ["Hello again"]])
|
||||
self.assertEqual([item["attempts"] for item in job["items"]], [1, 2])
|
||||
|
||||
async def test_failure_is_explicit_and_retry_preserves_successes(self):
|
||||
self.fail_ids.add("bad")
|
||||
self.fail_ids.add("Hello again")
|
||||
job = await self.submit([{"id": "ok", "text": "Hello"}, {"id": "bad", "text": "Hello again"}], retries=0)
|
||||
self.assertEqual(job["status"], "completed_with_errors")
|
||||
self.assertIsNone(job["items"][1]["translation"])
|
||||
@@ -150,7 +157,7 @@ class APIEngineTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(response.status_code, 202)
|
||||
retried = await self.wait_job(job["id"])
|
||||
self.assertEqual(retried["status"], "completed")
|
||||
self.assertEqual([item["id"] for item in self.calls[-1]], ["bad"])
|
||||
self.assertEqual([item["text"] for item in self.calls[-1]], ["Hello again"])
|
||||
self.assertEqual((await self.client.post(f"/jobs/{job['id']}/retry")).status_code, 409)
|
||||
|
||||
async def test_crash_recovery_keeps_completed_items_and_snapshot(self):
|
||||
@@ -165,7 +172,7 @@ class APIEngineTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual(recovered["status"], "completed")
|
||||
self.assertEqual(recovered["model"], "original-model")
|
||||
self.assertEqual(recovered["items"][0]["translation"], "Сохранённый перевод")
|
||||
self.assertEqual([item["id"] for item in self.calls[0]], ["pending"])
|
||||
self.assertEqual([item["text"] for item in self.calls[0]], ["Hello again"])
|
||||
|
||||
async def test_cache_bypass_and_whitespace(self):
|
||||
items = [{"id": "1", "text": "Hello"}, {"id": "2", "text": "\r\n "}]
|
||||
@@ -191,6 +198,19 @@ class APIEngineTests(unittest.IsolatedAsyncioTestCase):
|
||||
self.assertEqual((await self.client.get("/jobs/missing")).status_code, 404)
|
||||
self.assertEqual((await self.client.post("/projects", json={"name": "bad", "provider": "missing"})).status_code, 422)
|
||||
|
||||
async def test_cancel_queued_job_keeps_it_cancelled(self):
|
||||
request = {"items": [{"id": "1", "text": "Hello"}], "batch_size": 1, "max_batch_chars": 1000, "retries": 0, "use_cache": False}
|
||||
job = self.app.state.storage.create_job(self.project, self.settings.providers["lmstudio"].model_dump(), request)
|
||||
response = await self.client.post(f"/jobs/{job['id']}/cancel")
|
||||
self.assertEqual(response.json()["status"], "cancelled")
|
||||
self.assertEqual(response.json()["items"][0]["error"], "Cancelled")
|
||||
self.app.state.engine.wakeup.set()
|
||||
await asyncio.sleep(0.3)
|
||||
self.assertEqual((await self.client.get(f"/jobs/{job['id']}")).json()["status"], "cancelled")
|
||||
self.assertEqual(self.calls, [])
|
||||
self.assertEqual((await self.client.post(f"/jobs/{job['id']}/cancel")).json()["status"], "cancelled")
|
||||
self.assertEqual((await self.client.get("/health")).json()["api_version"], 2)
|
||||
|
||||
async def test_synchronous_translation_and_openapi(self):
|
||||
result = await self.client.post(f"/projects/{self.project['id']}/translate", json={"items": [{"id": "one", "text": "Hello"}]})
|
||||
self.assertEqual(result.status_code, 200)
|
||||
@@ -199,6 +219,42 @@ class APIEngineTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class ProviderTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_short_wire_ids_roundtrip_and_schema_are_explicit(self):
|
||||
seen = []
|
||||
def handler(request):
|
||||
payload = json.loads(request.content)
|
||||
seen.append(payload)
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": json.dumps({"items": [
|
||||
{"id": "1", "text": "Второй"}, {"id": "0", "text": "Первый"},
|
||||
]})}}]})
|
||||
provider = Provider(ProviderConfig(structured_output=True), transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await provider.translate("test", Project(name="demo"), [
|
||||
{"id": "Naninovel.UI.Unusually.Long.Key.First", "text": "First"},
|
||||
{"id": "arbitrary-client-key:second", "text": "Second"},
|
||||
])
|
||||
finally:
|
||||
await provider.close()
|
||||
self.assertEqual(result, {"arbitrary-client-key:second": "Второй", "Naninovel.UI.Unusually.Long.Key.First": "Первый"})
|
||||
self.assertNotIn("Unusually.Long.Key", json.dumps(seen))
|
||||
schema = seen[0]["response_format"]["json_schema"]["schema"]["properties"]["items"]
|
||||
self.assertEqual(schema["minItems"], 2)
|
||||
self.assertEqual(schema["maxItems"], 2)
|
||||
self.assertEqual(schema["items"]["properties"]["id"]["enum"], ["0", "1"])
|
||||
|
||||
async def test_generic_provider_does_not_require_structured_output(self):
|
||||
seen = []
|
||||
def handler(request):
|
||||
seen.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": '{"items":[{"id":"0","text":"Привет"}]}'}}]})
|
||||
provider = Provider(ProviderConfig(), transport=httpx.MockTransport(handler))
|
||||
try:
|
||||
result = await provider.translate("test", Project(name="demo"), [{"id": "original", "text": "Hello"}])
|
||||
finally:
|
||||
await provider.close()
|
||||
self.assertEqual(result, {"original": "Привет"})
|
||||
self.assertNotIn("response_format", seen[0])
|
||||
|
||||
async def test_autostart_reuses_already_available_identifier(self):
|
||||
transport = httpx.MockTransport(lambda request: httpx.Response(200, json={"data": [{"id": "already-loaded"}]}))
|
||||
factory = lambda config: Provider(config, transport=transport)
|
||||
@@ -209,7 +265,7 @@ class ProviderTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_malformed_duplicate_missing_and_truncated_responses(self):
|
||||
cases = [
|
||||
("not json", None, True),
|
||||
(json.dumps({"items": [{"id": "a", "text": "x"}, {"id": "a", "text": "y"}, {"id": "b", "text": "z"}]}), None, False),
|
||||
(json.dumps({"items": [{"id": "0", "text": "x"}, {"id": "0", "text": "y"}, {"id": "1", "text": "z"}]}), None, False),
|
||||
(json.dumps({"items": []}), "length", True),
|
||||
]
|
||||
for text, finish, should_raise in cases:
|
||||
@@ -262,5 +318,29 @@ class StorageConfigTests(unittest.TestCase):
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class CorsTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_only_configured_origins_may_call_from_a_browser(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
settings = Settings(database=str(Path(temp) / "t.sqlite3"), allowed_origins=["https://games.example/"])
|
||||
self.assertEqual(settings.allowed_origins, ["https://games.example"])
|
||||
app = create_app(settings)
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||
preflight = await client.options("/projects", headers={
|
||||
"Origin": "https://games.example", "Access-Control-Request-Method": "POST",
|
||||
"Access-Control-Request-Headers": "content-type", "Access-Control-Request-Private-Network": "true"})
|
||||
self.assertEqual(preflight.headers["access-control-allow-origin"], "https://games.example")
|
||||
self.assertEqual(preflight.headers["access-control-allow-private-network"], "true")
|
||||
allowed = await client.get("/health", headers={"Origin": "https://games.example"})
|
||||
self.assertEqual(allowed.headers["access-control-allow-origin"], "https://games.example")
|
||||
other = await client.get("/health", headers={"Origin": "https://evil.example"})
|
||||
self.assertNotIn("access-control-allow-origin", other.headers)
|
||||
|
||||
def test_wildcard_and_paths_are_rejected(self):
|
||||
for value in ("*", "https://games.example/app", "games.example"):
|
||||
with self.subTest(value=value), self.assertRaises(ValidationError):
|
||||
Settings(allowed_origins=[value])
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user