mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
422 lines
13 KiB
Python
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
|