Skip to content

Commit 60d7c84

Browse files
authored
Merge pull request #1363 from transformerlab/migrate/plugin-sdk-local-provider
Migrate some required functions from plugin sdk to lab facade
2 parents 15a7b4d + ddfd74b commit 60d7c84

5 files changed

Lines changed: 183 additions & 2 deletions

File tree

api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ dependencies = [
3434
"soundfile==0.13.1",
3535
"tensorboardX==2.6.2.2",
3636
"timm==1.0.15",
37-
"transformerlab==0.0.80",
37+
"transformerlab==0.0.81",
3838
"transformerlab-inference==0.2.52",
3939
"transformers==4.57.1",
4040
"wandb==0.23.1",

lab-sdk/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transformerlab"
7-
version = "0.0.80"
7+
version = "0.0.81"
88
description = "Python SDK for Transformer Lab"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -19,6 +19,7 @@ dependencies = [
1919
"s3fs",
2020
"aiofiles",
2121
"gcsfs",
22+
"requests",
2223
]
2324

2425
[project.urls]

lab-sdk/src/lab/generation.py

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import json
5+
import os
6+
from dataclasses import dataclass
7+
from typing import Any, Dict, Optional
8+
9+
import requests
10+
11+
12+
def _run_async(coro):
13+
"""
14+
Helper to run async code from sync context.
15+
Mirrors lab_facade._run_async behavior.
16+
"""
17+
try:
18+
asyncio.get_running_loop()
19+
running = True
20+
except RuntimeError:
21+
running = False
22+
23+
if not running:
24+
return asyncio.run(coro)
25+
26+
loop = asyncio.get_event_loop()
27+
if loop.is_closed():
28+
return asyncio.run(coro)
29+
return loop.run_until_complete(coro)
30+
31+
32+
@dataclass
33+
class GenerationModel:
34+
"""
35+
Simple, library-agnostic text generation interface.
36+
Implementations should provide:
37+
- generate(prompt, system_prompt=None) -> str
38+
- a_generate(prompt, system_prompt=None) -> str (async)
39+
"""
40+
41+
def generate(self, prompt: str, system_prompt: Optional[str] = None) -> str: # pragma: no cover - interface
42+
raise NotImplementedError
43+
44+
async def a_generate(self, prompt: str, system_prompt: Optional[str] = None) -> str:
45+
# Default async implementation delegates to sync in a thread
46+
loop = asyncio.get_running_loop()
47+
return await loop.run_in_executor(None, self.generate, prompt, system_prompt)
48+
49+
50+
class LocalHTTPGenerationModel(GenerationModel):
51+
"""
52+
Generation model that talks to a local HTTP server with an OpenAI-compatible
53+
/v1/chat/completions endpoint (e.g., vLLM, sglang, or TransformerLab inference server).
54+
"""
55+
56+
def __init__(self, base_url: str, model: str, api_key: str = "dummy") -> None:
57+
self.base_url = base_url.rstrip("/")
58+
self.model = model
59+
self.api_key = api_key or "dummy"
60+
61+
def generate(self, prompt: str, system_prompt: Optional[str] = None) -> str:
62+
url = f"{self.base_url}/chat/completions"
63+
messages: list[Dict[str, Any]] = []
64+
if system_prompt:
65+
messages.append({"role": "system", "content": system_prompt})
66+
messages.append({"role": "user", "content": prompt})
67+
68+
headers = {
69+
"Content-Type": "application/json",
70+
"Authorization": f"Bearer {self.api_key}",
71+
}
72+
payload: Dict[str, Any] = {
73+
"model": self.model,
74+
"messages": messages,
75+
}
76+
77+
resp = requests.post(url, headers=headers, data=json.dumps(payload), timeout=60)
78+
resp.raise_for_status()
79+
data = resp.json()
80+
try:
81+
return data["choices"][0]["message"]["content"]
82+
except Exception as exc: # noqa: BLE001
83+
raise RuntimeError(f"Unexpected response from local generation server: {data}") from exc
84+
85+
86+
def load_generation_model(config: Optional[Dict[str, Any] | str] = None) -> GenerationModel:
87+
"""
88+
Load a simple generation model wrapper based on a configuration object.
89+
This function is intentionally library-agnostic (no deepeval, no LangChain).
90+
Current support:
91+
- provider=\"local\": talks to a local HTTP server exposing an OpenAI-style
92+
/v1/chat/completions endpoint (e.g., TransformerLab inference server).
93+
Args:
94+
config: Either:
95+
- dict with \"provider\" and optionally \"base_url\", \"model\", \"api_key\" (e.g. {\"provider\": \"local\", \"model\": \"MyModel\", \"base_url\": \"http://localhost:8338/v1\"})
96+
- JSON string representing such a dict
97+
- simple string provider name (e.g. \"local\")
98+
Returns:
99+
GenerationModel implementation.
100+
"""
101+
# Normalize config to dict
102+
if config is None:
103+
config_dict: Dict[str, Any] = {"provider": "local"}
104+
elif isinstance(config, str):
105+
# Try to parse as JSON first; if that fails, treat as provider name
106+
try:
107+
config_dict = json.loads(config)
108+
if not isinstance(config_dict, dict):
109+
config_dict = {"provider": str(config)}
110+
except json.JSONDecodeError:
111+
config_dict = {"provider": config}
112+
elif isinstance(config, dict):
113+
config_dict = dict(config)
114+
else:
115+
raise TypeError("config must be a dict, JSON string, provider string, or None")
116+
117+
provider = str(config_dict.get("provider", "local")).lower()
118+
119+
if provider == "local":
120+
# Prefer config; fall back to env vars, then defaults
121+
base_url = config_dict.get("base_url") or os.environ.get("TFL_LOCAL_MODEL_BASE_URL", "http://localhost:8338/v1")
122+
model_name = config_dict.get("model") or os.environ.get("TFL_LOCAL_MODEL_NAME", "default")
123+
api_key = config_dict.get("api_key") or os.environ.get("TFL_LOCAL_MODEL_API_KEY", "dummy")
124+
return LocalHTTPGenerationModel(base_url=base_url, model=model_name, api_key=api_key)
125+
126+
raise NotImplementedError(
127+
f"Provider '{provider}' is not yet supported by load_generation_model. "
128+
"For now, only provider='local' is implemented."
129+
)

lab-sdk/src/lab/lab_facade.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from . import storage
1616
from .dataset import Dataset
1717
from .task_template import TaskTemplate
18+
from .generation import GenerationModel, load_generation_model as _load_generation_model
1819

1920

2021
logger = logging.getLogger(__name__)
@@ -1600,6 +1601,15 @@ def experiment(self) -> Experiment:
16001601
self._ensure_initialized()
16011602
return self._experiment # type: ignore[return-value]
16021603

1604+
def set_job_data_field(self, key: str, value: Any) -> None:
1605+
"""
1606+
Set a single key/value pair on this job's job_data (sync version).
1607+
This is a thin, synchronous wrapper around Job.update_job_data_field and is
1608+
intended as a replacement for plugin SDK helpers like add_job_data().
1609+
"""
1610+
self._ensure_initialized()
1611+
_run_async(self._job.update_job_data_field(key, value)) # type: ignore[union-attr]
1612+
16031613
def get_job_data(self) -> Dict[str, Any]:
16041614
"""
16051615
Get the job data dictionary (sync version).
@@ -1722,6 +1732,19 @@ def on_train_end(self, args, state, control, **kwargs):
17221732

17231733
return LabCallback(self)
17241734

1735+
def load_generation_model(self, config: Optional[Dict[str, Any] | str] = None) -> GenerationModel:
1736+
"""
1737+
Convenience wrapper to construct a simple text generation model.
1738+
1739+
This delegates to lab.generation.load_generation_model and is intentionally
1740+
library-agnostic. You can call it directly on the lab facade:
1741+
1742+
from lab import lab
1743+
gen = lab.load_generation_model({"provider": "local", "model": "MyModel"})
1744+
output = gen.generate("Hello")
1745+
"""
1746+
return _load_generation_model(config)
1747+
17251748

17261749
def capture_wandb_url_from_env() -> str | None:
17271750
"""

lab-sdk/tests/test_lab_facade.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -998,3 +998,31 @@ def test_lab_get_model_nonexistent(tmp_path, monkeypatch):
998998
assert False, "Should have raised FileNotFoundError"
999999
except FileNotFoundError:
10001000
pass # Expected
1001+
1002+
1003+
def test_lab_load_generation_model_smoke(tmp_path, monkeypatch):
1004+
_fresh(monkeypatch)
1005+
home = tmp_path / ".tfl_home"
1006+
ws = tmp_path / ".tfl_ws"
1007+
home.mkdir()
1008+
ws.mkdir()
1009+
monkeypatch.setenv("TFL_HOME_DIR", str(home))
1010+
monkeypatch.setenv("TFL_WORKSPACE_DIR", str(ws))
1011+
1012+
from lab.lab_facade import Lab
1013+
from lab.generation import LocalHTTPGenerationModel
1014+
1015+
lab = Lab()
1016+
# load_generation_model should NOT require lab.init; it's stateless config
1017+
model = lab.load_generation_model(
1018+
{
1019+
"provider": "local",
1020+
"base_url": "http://localhost:9999/v1",
1021+
"model": "test-model",
1022+
"api_key": "test-key",
1023+
}
1024+
)
1025+
1026+
assert isinstance(model, LocalHTTPGenerationModel)
1027+
assert model.base_url == "http://localhost:9999/v1"
1028+
assert model.model == "test-model"

0 commit comments

Comments
 (0)