Skip to content

Commit 32fb539

Browse files
authored
Add progress bar for Fine-tuning jobs (#406)
* Add progress bar * style * Compatibility * style
1 parent 13aca34 commit 32fb539

File tree

4 files changed

+528
-2
lines changed

4 files changed

+528
-2
lines changed

src/together/cli/api/finetune.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,11 @@
99
import click
1010
from click.core import ParameterSource # type: ignore[attr-defined]
1111
from rich import print as rprint
12+
from rich.json import JSON
1213
from tabulate import tabulate
1314

1415
from together import Together
15-
from together.cli.api.utils import BOOL_WITH_AUTO, INT_WITH_MAX
16+
from together.cli.api.utils import BOOL_WITH_AUTO, INT_WITH_MAX, generate_progress_bar
1617
from together.types.finetune import (
1718
DownloadCheckpointType,
1819
FinetuneEventType,
@@ -435,6 +436,9 @@ def list(ctx: click.Context) -> None:
435436
"Price": f"""${
436437
finetune_price_to_dollars(float(str(i.total_price)))
437438
}""", # convert to string for mypy typing
439+
"Progress": generate_progress_bar(
440+
i, datetime.now().astimezone(), use_rich=False
441+
),
438442
}
439443
)
440444
table = tabulate(display_list, headers="keys", tablefmt="grid", showindex=True)
@@ -454,7 +458,15 @@ def retrieve(ctx: click.Context, fine_tune_id: str) -> None:
454458
# remove events from response for cleaner output
455459
response.events = None
456460

457-
click.echo(json.dumps(response.model_dump(exclude_none=True), indent=4))
461+
rprint(JSON.from_data(response.model_dump(exclude_none=True)))
462+
progress_text = generate_progress_bar(
463+
response, datetime.now().astimezone(), use_rich=True
464+
)
465+
status = "Unknown"
466+
if response.status is not None:
467+
status = response.status.value
468+
prefix = f"Status: [bold]{status}[/bold],"
469+
rprint(f"{prefix} {progress_text}")
458470

459471

460472
@fine_tuning.command()

src/together/cli/api/utils.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
from __future__ import annotations
22

3+
import math
4+
import re
35
from gettext import gettext as _
46
from typing import Literal
7+
from datetime import datetime
58

69
import click
710

11+
from together.types.finetune import FinetuneResponse, COMPLETED_STATUSES
12+
13+
_PROGRESS_BAR_WIDTH = 40
14+
815

916
class AutoIntParamType(click.ParamType):
1017
name = "integer_or_max"
@@ -49,3 +56,84 @@ def convert(
4956

5057
INT_WITH_MAX = AutoIntParamType()
5158
BOOL_WITH_AUTO = BooleanWithAutoParamType()
59+
60+
61+
def _human_readable_time(timedelta: float) -> str:
62+
"""Convert a timedelta to a compact human-readble string
63+
Examples:
64+
00:00:10 -> 10s
65+
01:23:45 -> 1h 23min 45s
66+
1 Month 23 days 04:56:07 -> 1month 23d 4h 56min 7s
67+
Args:
68+
timedelta (float): The timedelta in seconds to convert.
69+
Returns:
70+
A string representing the timedelta in a human-readable format.
71+
"""
72+
units = [
73+
(30 * 24 * 60 * 60, "month"), # 30 days
74+
(24 * 60 * 60, "d"),
75+
(60 * 60, "h"),
76+
(60, "min"),
77+
(1, "s"),
78+
]
79+
80+
total_seconds = int(timedelta)
81+
parts = []
82+
83+
for unit_seconds, unit_name in units:
84+
if total_seconds >= unit_seconds:
85+
value = total_seconds // unit_seconds
86+
total_seconds %= unit_seconds
87+
parts.append(f"{value}{unit_name}")
88+
89+
return " ".join(parts) if parts else "0s"
90+
91+
92+
def generate_progress_bar(
93+
finetune_job: FinetuneResponse, current_time: datetime, use_rich: bool = False
94+
) -> str:
95+
"""Generate a progress bar for a finetune job.
96+
Args:
97+
finetune_job: The finetune job to generate a progress bar for.
98+
current_time: The current time.
99+
use_rich: Whether to use rich formatting.
100+
Returns:
101+
A string representing the progress bar.
102+
"""
103+
progress = "Progress: [bold red]unavailable[/bold red]"
104+
if finetune_job.status in COMPLETED_STATUSES:
105+
progress = "Progress: [bold green]completed[/bold green]"
106+
elif finetune_job.updated_at is not None:
107+
# Replace 'Z' with '+00:00' for Python 3.10 compatibility
108+
updated_at_str = finetune_job.updated_at.replace("Z", "+00:00")
109+
update_at = datetime.fromisoformat(updated_at_str).astimezone()
110+
111+
if finetune_job.progress is not None:
112+
if current_time < update_at:
113+
return progress
114+
115+
if not finetune_job.progress.estimate_available:
116+
return progress
117+
118+
if finetune_job.progress.seconds_remaining <= 0:
119+
return progress
120+
121+
elapsed_time = (current_time - update_at).total_seconds()
122+
ratio_filled = min(
123+
elapsed_time / finetune_job.progress.seconds_remaining, 1.0
124+
)
125+
percentage = ratio_filled * 100
126+
filled = math.ceil(ratio_filled * _PROGRESS_BAR_WIDTH)
127+
bar = "█" * filled + "░" * (_PROGRESS_BAR_WIDTH - filled)
128+
time_left = "N/A"
129+
if finetune_job.progress.seconds_remaining > elapsed_time:
130+
time_left = _human_readable_time(
131+
finetune_job.progress.seconds_remaining - elapsed_time
132+
)
133+
time_text = f"{time_left} left"
134+
progress = f"Progress: {bar} [bold]{percentage:>3.0f}%[/bold] [yellow]{time_text}[/yellow]"
135+
136+
if use_rich:
137+
return progress
138+
139+
return re.sub(r"\[/?[^\]]+\]", "", progress)

src/together/types/finetune.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ class FinetuneJobStatus(str, Enum):
2828
STATUS_COMPLETED = "completed"
2929

3030

31+
COMPLETED_STATUSES = [
32+
FinetuneJobStatus.STATUS_ERROR,
33+
FinetuneJobStatus.STATUS_USER_ERROR,
34+
FinetuneJobStatus.STATUS_COMPLETED,
35+
FinetuneJobStatus.STATUS_CANCELLED,
36+
]
37+
38+
3139
class FinetuneEventLevels(str, Enum):
3240
"""
3341
Fine-tune job event status levels
@@ -167,6 +175,15 @@ class TrainingMethodDPO(TrainingMethod):
167175
simpo_gamma: float | None = None
168176

169177

178+
class FinetuneProgress(BaseModel):
179+
"""
180+
Fine-tune job progress
181+
"""
182+
183+
estimate_available: bool = False
184+
seconds_remaining: float = 0
185+
186+
170187
class FinetuneRequest(BaseModel):
171188
"""
172189
Fine-tune request type
@@ -297,6 +314,8 @@ class FinetuneResponse(BaseModel):
297314
train_on_inputs: StrictBool | Literal["auto"] | None = "auto"
298315
from_checkpoint: str | None = None
299316

317+
progress: FinetuneProgress | None = None
318+
300319
@field_validator("training_type")
301320
@classmethod
302321
def validate_training_type(cls, v: TrainingType) -> TrainingType:

0 commit comments

Comments
 (0)