Files

422 lines
13 KiB
Python

"""Unit tests for hanzo_train over httpx.MockTransport -- no network."""
from __future__ import annotations
import json
from collections.abc import Callable
from typing import Any
import httpx
import pytest
from hanzo_train import (
AdamParams,
Datum,
HanzoTrainError,
LoraConfig,
ModelInput,
SamplingParams,
ServiceClient,
TrainingClient,
)
BASE = "http://engine"
Handler = Callable[[httpx.Request], httpx.Response]
def service(handler: Handler, api_key: str | None = None) -> ServiceClient:
return ServiceClient(BASE, api_key=api_key, transport=httpx.MockTransport(handler))
def sent(request: httpx.Request) -> dict[str, Any]:
return json.loads(request.content)
def info_json(**over: Any) -> dict[str, Any]:
data: dict[str, Any] = {
"id": "tc-abc",
"base_model": "m",
"status": "ready",
"lora_config": {"rank": 16, "alpha": 32.0, "target_modules": ["q_proj"]},
"forward_backward_calls": 0,
"optim_steps": 0,
}
data.update(over)
return data
def test_create_defaults_and_no_wait() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["method"] = request.method
seen["path"] = request.url.path
seen["body"] = sent(request)
return httpx.Response(200, json=info_json(status="loading"))
tc = service(handler).create_lora_training_client("m", wait=False)
assert tc.id == "tc-abc"
assert seen["method"] == "POST"
assert seen["path"] == "/v1/train/clients"
assert seen["body"]["base_model"] == "m"
lora = seen["body"]["lora_config"]
assert lora["rank"] == 16
assert lora["alpha"] == 32.0
assert lora["target_modules"] == [
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
]
def test_create_custom_lora_config() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["body"] = sent(request)
return httpx.Response(200, json=info_json(status="loading"))
service(handler).create_lora_training_client(
"m", lora_config=LoraConfig(rank=8, alpha=16.0, target_modules=["q_proj"]), wait=False
)
assert seen["body"]["lora_config"] == {
"rank": 8,
"alpha": 16.0,
"target_modules": ["q_proj"],
}
def test_create_wait_until_ready() -> None:
calls = {"get": 0}
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(200, json=info_json(status="loading"))
calls["get"] += 1
status = "ready" if calls["get"] >= 2 else "loading"
return httpx.Response(200, json=info_json(status=status))
tc = service(handler).create_lora_training_client("m", wait=True, poll_interval=0.0)
assert tc.id == "tc-abc"
assert calls["get"] == 2
def test_create_wait_failed_raises() -> None:
def handler(request: httpx.Request) -> httpx.Response:
if request.method == "POST":
return httpx.Response(200, json=info_json(status="loading"))
return httpx.Response(200, json=info_json(status="failed", error="oom"))
with pytest.raises(HanzoTrainError) as excinfo:
service(handler).create_lora_training_client("m", wait=True, poll_interval=0.0)
assert excinfo.value.status == 409
assert "oom" in excinfo.value.message
def test_forward_backward_prompt_form() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["body"] = sent(request)
return httpx.Response(200, json={"loss": 1.5, "num_tokens": 3, "metrics": {"loss": 1.5}})
tc = TrainingClient(service(handler), "tc-abc")
out = tc.forward_backward([{"prompt": "2+2=", "completion": "4"}]).result()
assert seen["path"] == "/v1/train/clients/tc-abc/forward_backward"
assert seen["body"]["data"] == [{"prompt": "2+2=", "completion": "4"}]
assert out.loss == 1.5
assert out.num_tokens == 3
assert out.metrics == {"loss": 1.5}
def test_forward_backward_datum_form() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["body"] = sent(request)
return httpx.Response(200, json={"loss": 0.5, "num_tokens": 3, "metrics": {"loss": 0.5}})
tc = TrainingClient(service(handler), "tc-abc")
datum = Datum(
model_input=ModelInput(tokens=[1, 2, 3]), target_tokens=[2, 3, 4], weights=[0.0, 1.0, 1.0]
)
out = tc.forward_backward([datum]).result()
assert seen["body"]["data"] == [
{
"model_input": {"tokens": [1, 2, 3]},
"target_tokens": [2, 3, 4],
"weights": [0.0, 1.0, 1.0],
}
]
assert out.loss == 0.5
def test_forward_backward_mixed_forms() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["body"] = sent(request)
return httpx.Response(200, json={"loss": 1.0, "num_tokens": 6, "metrics": {"loss": 1.0}})
tc = TrainingClient(service(handler), "tc-abc")
tc.forward_backward(
[
{"prompt": "2+2=", "completion": "4"},
Datum(model_input=ModelInput(tokens=[1]), target_tokens=[2], weights=[1.0]),
]
).result()
assert seen["body"]["data"] == [
{"prompt": "2+2=", "completion": "4"},
{"model_input": {"tokens": [1]}, "target_tokens": [2], "weights": [1.0]},
]
def test_optim_step_explicit_params() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["body"] = sent(request)
return httpx.Response(200, json={"optim_steps": 1})
tc = TrainingClient(service(handler), "tc-abc")
res = tc.optim_step(AdamParams(lr=1e-4)).result()
assert seen["path"] == "/v1/train/clients/tc-abc/optim_step"
assert seen["body"]["adam_params"] == {
"lr": 1e-4,
"beta1": 0.9,
"beta2": 0.95,
"eps": 1e-8,
"weight_decay": 0.0,
}
assert res.optim_steps == 1
def test_optim_step_defaults() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["body"] = sent(request)
return httpx.Response(200, json={"optim_steps": 7})
tc = TrainingClient(service(handler), "tc-abc")
res = tc.optim_step().result()
assert seen["body"]["adam_params"]["lr"] == 1e-4
assert res.optim_steps == 7
def test_sample_prompt_form() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["body"] = sent(request)
return httpx.Response(200, json={"sequences": [{"tokens": [4], "text": "4"}]})
tc = TrainingClient(service(handler), "tc-abc")
resp = tc.sample(
prompt="2+2=", sampling_params=SamplingParams(max_tokens=8, temperature=0.0), num_samples=1
).result()
assert seen["path"] == "/v1/train/clients/tc-abc/sample"
assert seen["body"]["prompt"] == "2+2="
assert "tokens" not in seen["body"]
assert seen["body"]["num_samples"] == 1
assert seen["body"]["sampling_params"] == {
"max_tokens": 8,
"temperature": 0.0,
"top_k": -1,
"top_p": 1.0,
"seed": 0,
"stop_tokens": [],
}
assert resp.sequences[0].tokens == [4]
assert resp.sequences[0].text == "4"
def test_sample_tokens_form_and_defaults() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["body"] = sent(request)
return httpx.Response(200, json={"sequences": [{"tokens": [9], "text": "x"}]})
tc = TrainingClient(service(handler), "tc-abc")
tc.sample(tokens=[1, 2, 3]).result()
assert seen["body"]["tokens"] == [1, 2, 3]
assert "prompt" not in seen["body"]
assert seen["body"]["sampling_params"]["max_tokens"] == 64
assert seen["body"]["sampling_params"]["temperature"] == 1.0
def test_sample_requires_exactly_one_input() -> None:
tc = TrainingClient(service(lambda r: httpx.Response(200, json={"sequences": []})), "tc-abc")
with pytest.raises(ValueError):
tc.sample()
with pytest.raises(ValueError):
tc.sample(prompt="a", tokens=[1])
def test_save_weights_and_alias() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["path"] = request.url.path
seen["body"] = sent(request)
return httpx.Response(200, json={"path": "/data/adapters/my-adapter", "format": "peft"})
tc = TrainingClient(service(handler), "tc-abc")
saved = tc.save_weights_and_get_sampling_client(name="my-adapter").result()
assert seen["path"] == "/v1/train/clients/tc-abc/save_weights"
assert seen["body"] == {"name": "my-adapter"}
assert saved.path == "/data/adapters/my-adapter"
assert saved.format == "peft"
saved2 = tc.save_weights(name="a", dir="/tmp/x").result()
assert seen["body"] == {"name": "a", "dir": "/tmp/x"}
assert saved2.format == "peft"
def test_get_info() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert request.url.path == "/v1/train/clients/tc-abc"
return httpx.Response(
200,
json=info_json(
status="ready",
forward_backward_calls=3,
optim_steps=2,
trainable_params=1234,
last_loss=0.25,
loss_history=[1.0, 0.5, 0.25],
),
)
info = TrainingClient(service(handler), "tc-abc").get_info()
assert info.status == "ready"
assert info.forward_backward_calls == 3
assert info.optim_steps == 2
assert info.trainable_params == 1234
assert info.last_loss == 0.25
assert info.loss_history == [1.0, 0.5, 0.25]
assert info.lora_config.rank == 16
assert info.lora_config.alpha == 32.0
def test_list_training_clients() -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "GET"
assert request.url.path == "/v1/train/clients"
return httpx.Response(
200,
json={
"clients": [
info_json(id="tc-1", status="ready"),
info_json(id="tc-2", status="loading"),
]
},
)
infos = service(handler).list_training_clients()
assert [i.id for i in infos] == ["tc-1", "tc-2"]
assert infos[0].status == "ready"
assert isinstance(infos[0].lora_config, LoraConfig)
def test_delete() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["method"] = request.method
seen["path"] = request.url.path
return httpx.Response(200, json={"id": "tc-abc", "deleted": True})
result = TrainingClient(service(handler), "tc-abc").delete()
assert result is None
assert seen["method"] == "DELETE"
assert seen["path"] == "/v1/train/clients/tc-abc"
def test_error_404() -> None:
tc = TrainingClient(service(lambda r: httpx.Response(404, text="unknown id")), "tc-missing")
with pytest.raises(HanzoTrainError) as excinfo:
tc.get_info()
assert excinfo.value.status == 404
assert excinfo.value.message == "unknown id"
def test_error_409_forward_backward() -> None:
tc = TrainingClient(
service(lambda r: httpx.Response(409, text="client still loading")), "tc-abc"
)
with pytest.raises(HanzoTrainError) as excinfo:
tc.forward_backward([{"prompt": "a", "completion": "b"}]).result()
assert excinfo.value.status == 409
assert "loading" in excinfo.value.message
def test_error_400_optim_step() -> None:
tc = TrainingClient(service(lambda r: httpx.Response(400, text="bad data")), "tc-abc")
with pytest.raises(HanzoTrainError) as excinfo:
tc.optim_step().result()
assert excinfo.value.status == 400
def test_auth_header_bearer() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["auth"] = request.headers.get("authorization")
return httpx.Response(200, json=info_json())
TrainingClient(service(handler, api_key="secret-token"), "tc-abc").get_info()
assert seen["auth"] == "Bearer secret-token"
def test_no_auth_header_without_key() -> None:
seen: dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["auth"] = request.headers.get("authorization")
return httpx.Response(200, json=info_json())
TrainingClient(service(handler), "tc-abc").get_info()
assert seen["auth"] is None
def test_future_result_returns_value() -> None:
tc = TrainingClient(service(lambda r: httpx.Response(200, json={"optim_steps": 5})), "tc-abc")
fut = tc.optim_step()
assert fut.result(timeout=1.0).optim_steps == 5