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
@@ -0,0 +1,127 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from local_translator.benchmark import BenchmarkConfig, ModelManager, ModelSpec, aggregate, profile, resolve_metadata, save_report, summarize_run
|
||||
|
||||
|
||||
def item(identity, source, translation, attempts=1, cached=0, status="completed"):
|
||||
return {"id": identity, "source": source, "translation": translation, "attempts": attempts, "cached": cached, "status": status}
|
||||
|
||||
|
||||
class MetricsTests(unittest.TestCase):
|
||||
def test_count_only_successful_inference_and_keep_missing_usage_unknown(self):
|
||||
job = {"id": "job", "status": "completed_with_errors", "items": [
|
||||
item("model", "Hello", "Привет", attempts=2), item("local", "Q.LOAD", "Загрузка", attempts=0),
|
||||
item("cache", "Yes", "Да", cached=1), item("failed", "Text", None, status="failed"),
|
||||
], "metrics": {"provider_calls": 2, "provider_seconds": 1.5, "completion_tokens": 8, "calls_with_completion_usage": 1}}
|
||||
result = summarize_run(job, 2)
|
||||
self.assertEqual(result["model_lines"], 1)
|
||||
self.assertEqual(result["lines_per_second"], 0.5)
|
||||
self.assertEqual(result["chars_per_second"], 2.5)
|
||||
self.assertEqual(result["failed"], 1)
|
||||
self.assertEqual(result["retry_attempts"], 1)
|
||||
self.assertIsNone(result["completion_tokens_per_request_second"])
|
||||
|
||||
def test_reported_token_speed_uses_full_provider_request_time(self):
|
||||
job = {"id": "job", "status": "completed", "items": [item("1", "AUTO", "AUTO")],
|
||||
"metrics": {"provider_calls": 1, "provider_seconds": 2, "completion_tokens": 40, "calls_with_completion_usage": 1}}
|
||||
result = summarize_run(job, 3)
|
||||
self.assertEqual(result["completion_tokens_per_request_second"], 20)
|
||||
self.assertEqual(result["unchanged_ids"], ["1"])
|
||||
|
||||
def test_failure_is_not_ranked_as_fast_success(self):
|
||||
rows = [
|
||||
{"model": "A", "batch_size": 10, "status": status, "failed": failed, "seconds": seconds,
|
||||
"lines_per_second": rate, "chars_per_second": rate * 5, "retry_attempts": 0, "unchanged_ids": []}
|
||||
for status, failed, seconds, rate in [("completed", 0, 10, 1), ("completed", 0, 20, 0.5), ("failed", 10, 0.01, 0)]
|
||||
]
|
||||
summary = aggregate(rows)[0]
|
||||
self.assertEqual(summary["median_seconds"], 15)
|
||||
self.assertEqual(summary["successful_runs"], 2)
|
||||
self.assertEqual(summary["runs"], 3)
|
||||
self.assertEqual(summary["failed_items"], 10)
|
||||
|
||||
def test_report_escapes_labels_and_preserves_raw_json(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
report = {"dataset": {"source": "<script>alert(1)</script>", "items": 1, "source_chars": 1},
|
||||
"started_at": "test", "runs": [], "errors": ["<b>error</b>"], "restore_errors": []}
|
||||
save_report(Path(directory), report)
|
||||
document = (Path(directory) / "report.html").read_text(encoding="utf-8")
|
||||
self.assertNotIn("<script>", document)
|
||||
self.assertIn("<script>", document)
|
||||
self.assertEqual(json.loads((Path(directory) / "report.json").read_text(encoding="utf-8"))["errors"], report["errors"])
|
||||
|
||||
def test_native_profiles_and_overrides(self):
|
||||
config = BenchmarkConfig()
|
||||
hy = profile(ModelSpec(model="tencent/Hy-MT2-7B-GGUF/file.gguf"), config)
|
||||
self.assertEqual(hy.prompt_style, "hy_mt")
|
||||
self.assertEqual(hy.top_p, 0.6)
|
||||
other = profile(ModelSpec(model="other-model", temperature=0.5), config)
|
||||
self.assertEqual(other.prompt_style, "generic")
|
||||
self.assertEqual(other.temperature, 0.5)
|
||||
self.assertIsNone(other.top_k)
|
||||
|
||||
def test_quantizations_require_unambiguous_model_paths(self):
|
||||
models = [{"modelKey": "same", "path": "q4.gguf"}, {"modelKey": "same", "path": "q6.gguf"}]
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_metadata(ModelSpec(model="same"), models)
|
||||
self.assertEqual(resolve_metadata(ModelSpec(model="q6.gguf"), models)["path"], "q6.gguf")
|
||||
|
||||
|
||||
class FakeLMS:
|
||||
def __init__(self, loaded):
|
||||
self.models = [dict(model) for model in loaded]
|
||||
self.commands = []
|
||||
|
||||
def json(self, command):
|
||||
return [dict(model) for model in self.models]
|
||||
|
||||
def run(self, *args):
|
||||
self.commands.append(args)
|
||||
if args[0] == "unload":
|
||||
self.models = [model for model in self.models if model["identifier"] != args[1]]
|
||||
elif args[0] == "load":
|
||||
self.models.append({"type": "llm", "modelKey": args[1], "path": args[1], "indexedModelIdentifier": args[1],
|
||||
"identifier": args[args.index("--identifier") + 1],
|
||||
"contextLength": int(args[args.index("--context-length") + 1]), "status": "idle", "queued": 0})
|
||||
return ""
|
||||
|
||||
|
||||
class ModelManagementTests(unittest.TestCase):
|
||||
def test_switch_and_restore_only_llms_not_embedding(self):
|
||||
originals = [
|
||||
{"type": "llm", "modelKey": "old", "path": "old.gguf", "indexedModelIdentifier": "old.gguf", "identifier": "original", "contextLength": 4096, "status": "idle", "queued": 0},
|
||||
{"type": "embedding", "identifier": "embedding"},
|
||||
]
|
||||
lms = FakeLMS(originals)
|
||||
manager = ModelManager(lms, BenchmarkConfig())
|
||||
loaded, seconds = manager.activate({"modelKey": "new.gguf", "path": "new.gguf", "indexedModelIdentifier": "new.gguf"})
|
||||
self.assertTrue(loaded["identifier"].startswith("lt-bench-"))
|
||||
self.assertEqual(loaded["contextLength"], 8192)
|
||||
manager.restore()
|
||||
self.assertEqual(manager.restore_errors, [])
|
||||
identifiers = {model["identifier"] for model in lms.models}
|
||||
self.assertEqual(identifiers, {"embedding", "original"})
|
||||
self.assertFalse(any(command == ("unload", "embedding") for command in lms.commands))
|
||||
|
||||
def test_reuse_loaded_matching_model_without_reloading(self):
|
||||
original = {"type": "llm", "modelKey": "old", "path": "old.gguf", "identifier": "original", "contextLength": 8192, "status": "idle", "queued": 0}
|
||||
lms = FakeLMS([original])
|
||||
manager = ModelManager(lms, BenchmarkConfig(reuse_loaded=True))
|
||||
loaded, seconds = manager.activate({"modelKey": "old", "path": "old.gguf"})
|
||||
manager.restore()
|
||||
self.assertEqual(seconds, 0)
|
||||
self.assertEqual(loaded["identifier"], "original")
|
||||
self.assertEqual(lms.commands, [])
|
||||
|
||||
def test_busy_models_are_never_unloaded(self):
|
||||
lms = FakeLMS([{"type": "llm", "identifier": "busy", "status": "generating", "queued": 0}])
|
||||
with self.assertRaises(RuntimeError):
|
||||
ModelManager(lms, BenchmarkConfig())
|
||||
self.assertEqual(lms.commands, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,148 @@
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from local_translator.benchmark import write_json
|
||||
from local_translator.tomie import STATE, append_managed_records, atomic_copy, digest, inside, install, original_sources, patch_catalog, patch_lock, restore, rewrite_dictionary
|
||||
|
||||
|
||||
class DictionaryTests(unittest.TestCase):
|
||||
def test_missing_runtime_graphics_labels_are_added_as_managed_records(self):
|
||||
key = "Naninovel.UI.GameSettingsGraphicsDropdown.GraphicOption5"
|
||||
result = append_managed_records("Key: Value\r\n", {key: "Очень высокое"})
|
||||
self.assertEqual(result, f"Key: Value\r\n{key}: Очень высокое\r\n")
|
||||
with self.assertRaises(ValueError):
|
||||
append_managed_records(result, {key: "Duplicate"})
|
||||
with self.assertRaises(ValueError):
|
||||
append_managed_records("Key: Value", {key: "A\nB"})
|
||||
|
||||
def test_preserves_keys_whitespace_comments_and_crlf(self):
|
||||
source = '; comment\r\nMenu.Load: LOAD\r\nOther: value: extra\r\n'
|
||||
result = rewrite_dictionary(source, {"Menu.Load": "ЗАГРУЗИТЬ"})
|
||||
self.assertEqual(result, '; comment\r\nMenu.Load: ЗАГРУЗИТЬ\r\nOther: value: extra\r\n')
|
||||
|
||||
def test_unknown_duplicate_and_multiline_values_rejected(self):
|
||||
for source, replacements in [("Key: A", {"Other": "B"}), ("Key: A\nKey: B", {"Key": "В"}), ("Key: A", {"Key": "А\nБ"})]:
|
||||
with self.assertRaises(ValueError):
|
||||
rewrite_dictionary(source, replacements)
|
||||
|
||||
def test_catalog_offsets_preserved_and_crc_actually_updated(self):
|
||||
old_hash, new_hash = "a" * 32, "b" * 32
|
||||
record = json.dumps({"m_Hash": old_hash, "m_Crc": 2659166264, "m_BundleSize": 37229646}, separators=(",", ":")).encode("utf-16-le")
|
||||
payload = b"header" + struct.pack("<I", len(record)) + record + b"unrelated-second-record"
|
||||
document = {"m_ExtraDataString": base64.b64encode(payload).decode(), "other": "keep"}
|
||||
result, before, after = patch_catalog(document, old_hash, 12345, 36999999, new_hash)
|
||||
raw = base64.b64decode(result["m_ExtraDataString"])
|
||||
self.assertEqual(len(raw), len(payload))
|
||||
self.assertTrue(raw.endswith(b"unrelated-second-record"))
|
||||
self.assertEqual(before["m_Crc"], 2659166264)
|
||||
self.assertEqual(after["m_Crc"], 12345)
|
||||
self.assertEqual(after["m_Hash"], new_hash)
|
||||
self.assertEqual(result["other"], "keep")
|
||||
with self.assertRaises(ValueError):
|
||||
patch_catalog(document, old_hash, 12345, 999999999, new_hash)
|
||||
|
||||
|
||||
class InstallerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.temp.name)
|
||||
self.game = self.root / "game"
|
||||
self.package = self.root / "package"
|
||||
self.game.mkdir()
|
||||
(self.game / "TomieWGM.exe").write_bytes(b"fake executable, never run")
|
||||
self.records = []
|
||||
for name in ("data.unity3d", "StreamingAssets/aa/catalog.json"):
|
||||
relative = Path("TomieWGM_Data") / name
|
||||
original = self.game / relative
|
||||
payload = self.package / "payload" / relative
|
||||
original.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload.parent.mkdir(parents=True, exist_ok=True)
|
||||
original.write_bytes(("original " + name).encode())
|
||||
payload.write_bytes(("translated " + name).encode())
|
||||
self.records.append({"path": relative.as_posix(), "before": digest(original), "after": digest(payload)})
|
||||
write_json(self.package / "manifest.json", {"id": "patch-1", "files": self.records})
|
||||
|
||||
def tearDown(self):
|
||||
self.temp.cleanup()
|
||||
|
||||
def test_install_is_idempotent_and_restore_is_byte_exact(self):
|
||||
install(self.package, self.game, lambda: False)
|
||||
state = json.loads((self.game / STATE).read_text(encoding="utf-8"))
|
||||
self.assertEqual(state["phase"], "installed")
|
||||
for row in state["files"]:
|
||||
self.assertEqual(digest(self.game / row["path"]), row["after"])
|
||||
self.assertEqual(digest(self.game / row["backup"]), row["before"])
|
||||
install(self.package, self.game, lambda: False)
|
||||
restore(self.game, lambda: False)
|
||||
for row in self.records:
|
||||
self.assertEqual(digest(self.game / row["path"]), row["before"])
|
||||
self.assertEqual(json.loads((self.game / STATE).read_text())["phase"], "restored")
|
||||
|
||||
def test_running_game_blocks_both_actions(self):
|
||||
with self.assertRaises(RuntimeError):
|
||||
install(self.package, self.game, lambda: True)
|
||||
with self.assertRaises(RuntimeError):
|
||||
restore(self.game, lambda: True)
|
||||
self.assertFalse((self.game / STATE).exists())
|
||||
|
||||
def test_foreign_original_and_damaged_payload_are_rejected_before_writes(self):
|
||||
target = self.game / self.records[-1]["path"]
|
||||
target.write_bytes(b"foreign mod")
|
||||
with self.assertRaises(ValueError):
|
||||
install(self.package, self.game, lambda: False)
|
||||
self.assertEqual(digest(self.game / self.records[0]["path"]), self.records[0]["before"])
|
||||
target.write_bytes(b"original StreamingAssets/aa/catalog.json")
|
||||
(self.package / "payload" / self.records[-1]["path"]).write_bytes(b"tampered")
|
||||
with self.assertRaises(ValueError):
|
||||
install(self.package, self.game, lambda: False)
|
||||
self.assertFalse((self.game / STATE).exists())
|
||||
|
||||
def test_partial_install_failure_rolls_back_already_replaced_files(self):
|
||||
def fail_second(source, target, expected):
|
||||
if Path(source).resolve().is_relative_to((self.package / "payload").resolve()) and Path(target).name == "catalog.json":
|
||||
raise OSError("simulated disk failure")
|
||||
return atomic_copy(source, target, expected)
|
||||
with patch("local_translator.tomie.atomic_copy", fail_second), self.assertRaises(OSError):
|
||||
install(self.package, self.game, lambda: False)
|
||||
self.assertEqual(json.loads((self.game / STATE).read_text())["phase"], "restored")
|
||||
for row in self.records:
|
||||
self.assertEqual(digest(self.game / row["path"]), row["before"])
|
||||
|
||||
def test_restore_refuses_to_overwrite_new_foreign_changes(self):
|
||||
install(self.package, self.game, lambda: False)
|
||||
target = self.game / self.records[0]["path"]
|
||||
target.write_bytes(b"new unrelated mod")
|
||||
with self.assertRaises(ValueError):
|
||||
restore(self.game, lambda: False)
|
||||
self.assertEqual(target.read_bytes(), b"new unrelated mod")
|
||||
|
||||
def test_paths_and_concurrent_installers_are_guarded(self):
|
||||
for relative in ("../outside", str(self.root.resolve())):
|
||||
with self.assertRaises(ValueError):
|
||||
inside(self.game, relative)
|
||||
with patch_lock(self.game):
|
||||
with self.assertRaises(RuntimeError):
|
||||
with patch_lock(self.game):
|
||||
self.fail("Two installers acquired one lock")
|
||||
|
||||
def test_rebuild_uses_verified_original_backups_without_changing_installed_game(self):
|
||||
install(self.package, self.game, lambda: False)
|
||||
inputs = [self.game.resolve() / row["path"] for row in self.records]
|
||||
sources, live_hashes = original_sources(self.game.resolve(), inputs)
|
||||
for row, path in zip(self.records, inputs):
|
||||
self.assertEqual(digest(sources[path]), row["before"])
|
||||
self.assertEqual(live_hashes[path], row["after"])
|
||||
self.assertEqual(digest(path), row["after"])
|
||||
inputs[0].write_bytes(b"foreign edit")
|
||||
with self.assertRaises(ValueError):
|
||||
original_sources(self.game.resolve(), inputs)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,95 @@
|
||||
import unittest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import zlib
|
||||
|
||||
from local_translator.tomie_full import chunks, split_text, text_slots
|
||||
from local_translator.tomie_extra import dictionary_segments, lines_with_spans, replace_records
|
||||
|
||||
|
||||
class FullTranslationTests(unittest.TestCase):
|
||||
def test_split_preserves_source_and_syntax(self):
|
||||
text = ('A sentence. ' * 80) + '{' + 'variable' * 160 + '}' + '\n<color=yellow>Yes</color> ' * 30
|
||||
pieces = split_text(text, 200)
|
||||
self.assertEqual(''.join(pieces), text)
|
||||
self.assertTrue(any('{' + 'variable' * 160 + '}' in piece for piece in pieces))
|
||||
for piece in pieces:
|
||||
self.assertEqual(piece.count('<color=yellow>') + piece.count('</color>'), piece.count('<'))
|
||||
|
||||
def test_only_visible_parameters_are_extracted(self):
|
||||
tree = {'references': {'records': [
|
||||
{'type': {'class': 'PrintText'}, 'data': {'Text': {'value': 'Hello', 'hasValue': 1,
|
||||
'dynamicValue': {'ValueText': ''}}, 'AuthorId': {'value': 'Tomie'}}},
|
||||
{'type': {'class': 'AddChoice'}, 'data': {'ChoiceSummary': {'value': '', 'hasValue': 0,
|
||||
'dynamicValue': {'ValueText': 'Pay {price}'}}}},
|
||||
{'type': {'class': 'SetCustomVariable'}, 'data': {'Expression': 'score="Hello"'}}
|
||||
]}}
|
||||
self.assertEqual(list(text_slots(tree)), [(0, 'Text', ('value',), 'Hello'),
|
||||
(1, 'ChoiceSummary', ('dynamicValue', 'ValueText'), 'Pay {price}')])
|
||||
|
||||
def test_parts_cover_each_item_once(self):
|
||||
items = list(range(1001))
|
||||
parts = chunks(items, 400)
|
||||
self.assertEqual([len(p) for p in parts], [400, 400, 201])
|
||||
self.assertEqual([item for part in parts for item in part], items)
|
||||
|
||||
def test_dictionary_duplicate_keys_and_delimiters_are_preserved(self):
|
||||
source = 'Tip1: Hello | category | World\\nAgain\r\nTip1: Duplicate\r\n'
|
||||
slots = list(dictionary_segments(source))
|
||||
self.assertEqual([s[2] for s in slots], [' Hello ', ' category ', ' World\\nAgain', ' Duplicate'])
|
||||
records = [{'kind': 'dictionary', 'start': a, 'end': b, 'text': t, 'parts': [str(i)]}
|
||||
for i, (a, b, t) in enumerate(slots)]
|
||||
values = {str(i): s[2] for i, s in enumerate(slots)}
|
||||
translated = {'0': 'Привет', '1': 'категория', '2': 'Мир\\nСнова', '3': 'Повтор'}
|
||||
result = replace_records(source, records, translated, values)
|
||||
self.assertEqual(result, 'Tip1: Привет | категория | Мир\\nСнова\r\nTip1: Повтор\r\n')
|
||||
|
||||
def test_synonym_keys_and_comma_counts_are_preserved(self):
|
||||
source = '{KEY}=hello,world\r\n'
|
||||
slots = list(lines_with_spans(source, True))
|
||||
records = [{'kind': 'text', 'synonym': True, 'start': a, 'end': b, 'text': t, 'parts': [str(i)]}
|
||||
for i, (a, b, t) in enumerate(slots)]
|
||||
values = {str(i): s[2] for i, s in enumerate(slots)}
|
||||
self.assertEqual(replace_records(source, records, {'0': 'привет', '1': 'мир'}, values), '{KEY}=привет,мир\r\n')
|
||||
with self.assertRaises(ValueError):
|
||||
replace_records(source, records, {'0': 'привет, ага', '1': 'мир'}, values)
|
||||
|
||||
def test_streamed_bundle_is_readable_by_native_and_streamed_readers(self):
|
||||
try:
|
||||
import UnityPy
|
||||
except ImportError:
|
||||
self.skipTest('Install .[unity] for asset tests')
|
||||
from UnityPy.streams import EndianBinaryReader
|
||||
from local_translator.unity_stream import load, resource_hashes, save
|
||||
raw = bytes(range(256)) * 2000
|
||||
reader = EndianBinaryReader(raw)
|
||||
reader.flags = 0
|
||||
bundle = SimpleNamespace(signature='UnityFS', version=7, version_player='5.x.x',
|
||||
version_engine='2019.4.22f1', parse_version=lambda: (2019,4,22,1), files={'test.resS':reader})
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
path = Path(temp)/'test.bundle'
|
||||
crc = save(bundle, path)
|
||||
self.assertEqual(crc,zlib.crc32(raw))
|
||||
standard = next(iter(UnityPy.load(path.read_bytes()).files.values()))
|
||||
streamed = next(iter(load(path).files.values()))
|
||||
self.assertEqual(resource_hashes(standard), resource_hashes(streamed))
|
||||
self.assertEqual(streamed.stream_crc, crc)
|
||||
self.assertEqual(standard.files['test.resS'].bytes,raw)
|
||||
|
||||
def test_catalog_can_be_repatched_using_reserved_numeric_padding(self):
|
||||
import base64
|
||||
import json
|
||||
import struct
|
||||
from local_translator.tomie import patch_catalog
|
||||
original_hash, first_hash, second_hash = 'a'*32, 'b'*32, 'c'*32
|
||||
options = json.dumps({'m_Hash':original_hash,'m_Crc':2659166264,'m_BundleSize':37229646},separators=(',',':')).encode('utf-16-le')
|
||||
catalog = {'m_ExtraDataString':base64.b64encode(struct.pack('<I',len(options))+options).decode()}
|
||||
first,_,_=patch_catalog(catalog,original_hash,12345,37229646,first_hash)
|
||||
second,_,after=patch_catalog(first,first_hash,3456789012,37229646,second_hash)
|
||||
self.assertEqual(after['m_Crc'],3456789012)
|
||||
self.assertEqual(len(base64.b64decode(catalog['m_ExtraDataString'])),len(base64.b64decode(second['m_ExtraDataString'])))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+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