Files

266 lines
11 KiB
Python

# /// script
# dependencies = [
# "openai>=1.0",
# "requests",
# "azure-identity",
# "azure-ai-projects",
# ]
# ///
"""
submit_training.py — Submit SFT, DPO, or RFT training jobs on Microsoft Foundry.
Handles both SDK and REST API submission (REST fallback for OSS models).
Supports /v1/ project endpoint (preferred) and Azure endpoint (fallback).
Usage:
python submit_training.py --base-url https://<resource>.services.ai.azure.com/api/projects/<project>/openai/v1/ \
--api-key KEY --training-file training.jsonl --validation-file validation.jsonl \
--model gpt-4.1-mini --type sft --epochs 2 --lr 1.0
python submit_training.py --endpoint https://<resource>.openai.azure.com --api-key KEY \
--training-file-id file-abc123 --validation-file-id file-def456 \
--model gpt-oss-20b --type sft --epochs 2 --lr 0.5 --use-rest
python submit_training.py --base-url <url> --api-key KEY \
--training-file-id file-abc123 --validation-file-id file-def456 \
--model o4-mini-2025-04-16 --type rft --grader-file grader.py
"""
import json
import os
import sys
try:
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
except (AttributeError, OSError):
pass # Stream not reconfigurable (older Python or non-tty); default encoding is fine
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common import HelpOnErrorParser, get_clients, upload_file
import requests
def submit_sft_sdk(client, model, train_id, val_id, epochs=2, lr=1.0, batch_size=None, suffix=None, training_type="globalStandard"):
"""Submit SFT job using the Python SDK."""
hp = {"n_epochs": epochs, "learning_rate_multiplier": lr}
if batch_size:
hp["batch_size"] = batch_size
kwargs = dict(
model=model,
training_file=train_id,
validation_file=val_id,
method={"type": "supervised"},
hyperparameters=hp,
# Azure-specific: passed via extra_body since the OpenAI SDK has no
# top-level trainingType kwarg.
extra_body={"trainingType": training_type},
)
if suffix:
kwargs["suffix"] = suffix
job = client.fine_tuning.jobs.create(**kwargs)
return {"id": job.id, "status": job.status, "model": model, "method": "sdk"}
def submit_sft_rest(endpoint, api_key, model, train_id, val_id, epochs=2, lr=1.0, batch_size=None, suffix=None, training_type="globalStandard"):
"""Submit SFT job via REST API (fallback for models like gpt-oss-20b)."""
url = f"{endpoint}/openai/fine_tuning/jobs?api-version=2025-04-01-preview"
body = {
"model": model,
"training_file": train_id,
"validation_file": val_id,
"method": {"type": "supervised"},
"hyperparameters": {"n_epochs": epochs, "learning_rate_multiplier": lr},
"trainingType": training_type,
}
if batch_size:
body["hyperparameters"]["batch_size"] = batch_size
if suffix:
body["suffix"] = suffix
resp = requests.post(url, headers={
"Content-Type": "application/json",
"api-key": api_key,
}, json=body, timeout=(10, 60))
if resp.status_code in (200, 201):
try:
data = resp.json()
except ValueError:
raise RuntimeError(
f"REST submission returned {resp.status_code} but body was not JSON: {resp.text[:200]}"
)
if "id" not in data or "status" not in data:
raise RuntimeError(f"REST response missing 'id' or 'status' fields: {data}")
return {"id": data["id"], "status": data["status"], "model": model, "method": "rest"}
else:
try:
err_msg = resp.json().get('error', {}).get('message', 'Unknown error')
except (ValueError, KeyError):
err_msg = resp.text[:200] if resp.text else "Unknown error"
raise RuntimeError(
f"REST submission failed ({resp.status_code}): {err_msg}"
)
def submit_rft(client, model, train_id, val_id, grader_source):
"""Submit RFT job."""
job = client.fine_tuning.jobs.create(
model=model,
training_file=train_id,
validation_file=val_id,
method={
"type": "reinforcement",
"reinforcement": {
"grader": {
"type": "python",
"name": "custom_grader",
"source": grader_source,
},
},
},
)
return {"id": job.id, "status": job.status, "model": model, "method": "sdk-rft"}
def submit_dpo(client, model, train_id, val_id, epochs=2, lr=1.0, beta=0.1, suffix=None):
"""Submit DPO job."""
job = client.fine_tuning.jobs.create(
model=model,
training_file=train_id,
validation_file=val_id,
suffix=suffix or None,
method={
"type": "dpo",
"dpo": {
"hyperparameters": {
"n_epochs": epochs,
"beta": beta,
"learning_rate_multiplier": lr,
},
},
},
)
return {"id": job.id, "status": job.status, "model": model, "method": "sdk-dpo"}
def main():
parser = HelpOnErrorParser(description="Submit fine-tuning jobs on Microsoft Foundry")
parser.add_argument("--base-url", default=os.environ.get("OPENAI_BASE_URL"),
help="Project /v1/ URL (preferred)")
parser.add_argument("--endpoint", default=os.environ.get("AZURE_OPENAI_ENDPOINT"),
help="Azure OpenAI endpoint (fallback)")
parser.add_argument("--project-endpoint", default=os.environ.get("AZURE_AI_PROJECT_ENDPOINT"),
help="Azure AI project endpoint (Foundry SDK)")
parser.add_argument("--api-key", default=os.environ.get("AZURE_OPENAI_API_KEY"),
help="API key")
parser.add_argument("--model", required=True, help="Base model name (e.g., gpt-4.1-mini)")
parser.add_argument("--type", choices=["sft", "dpo", "rft"], default="sft",
help="Training type: sft, dpo, or rft")
# Data files — either paths (will upload) or IDs (already uploaded)
parser.add_argument("--training-file", help="Path to training JSONL file (will upload)")
parser.add_argument("--validation-file", help="Path to validation JSONL file (will upload)")
parser.add_argument("--training-file-id", help="Already-uploaded training file ID")
parser.add_argument("--validation-file-id", help="Already-uploaded validation file ID")
# Hyperparameters
parser.add_argument("--epochs", type=int, default=2)
parser.add_argument("--lr", type=float, default=1.0, help="Learning rate multiplier")
parser.add_argument("--batch-size", type=int, default=None)
parser.add_argument("--suffix", help="Model suffix for identification")
# DPO-specific
parser.add_argument("--beta", type=float, default=0.1, help="DPO beta (alignment strength)")
# RFT-specific
parser.add_argument("--grader-file", help="Path to Python grader file (for RFT)")
# REST fallback
parser.add_argument("--use-rest", action="store_true",
help="Force REST API (needed for gpt-oss-20b and other OSS models)")
parser.add_argument("--training-type", choices=["globalStandard", "developerTier", "standard"],
default="globalStandard",
help="Azure training tier (default: globalStandard). developerTier is ~50%% off "
"globalStandard with lower quotas. OSS models (gpt-oss-20b, Ministral, "
"Llama, Qwen) only support globalStandard.")
args = parser.parse_args()
client, method = get_clients(
base_url=args.base_url, azure_endpoint=args.endpoint,
project_endpoint=args.project_endpoint, api_key=args.api_key
)
# Resolve file IDs
train_id = args.training_file_id
val_id = args.validation_file_id
if args.training_file:
train_id = upload_file(client, args.training_file)
if args.validation_file:
val_id = upload_file(client, args.validation_file)
if not train_id or not val_id:
print("Error: Provide training and validation file paths or IDs")
sys.exit(1)
# Submit
if args.type == "rft":
if not args.grader_file:
print("Error: --grader-file required for RFT")
sys.exit(1)
with open(args.grader_file, encoding="utf-8") as f:
grader_source = f.read()
result = submit_rft(client, args.model, train_id, val_id, grader_source)
elif args.type == "dpo":
result = submit_dpo(client, args.model, train_id, val_id,
args.epochs, args.lr, args.beta, args.suffix)
elif args.use_rest:
if not args.endpoint or not args.api_key:
print("Error: --use-rest requires --endpoint and --api-key (REST does not support DefaultAzureCredential)")
sys.exit(1)
result = submit_sft_rest(args.endpoint, args.api_key, args.model,
train_id, val_id, args.epochs, args.lr, args.batch_size, args.suffix,
args.training_type)
else:
# SFT via SDK with REST fallback for OSS models
try:
result = submit_sft_sdk(client, args.model, train_id, val_id,
args.epochs, args.lr, args.batch_size, args.suffix,
args.training_type)
except Exception as e:
err_str = str(e).lower()
# Match a wider set of "use REST instead" signals than the original
# exact-string comparison: Azure changes error text periodically.
if ("trainingtype" in err_str
or "globalstandard" in err_str
or "global_standard" in err_str
or "does not support fine-tuning" in err_str):
if not args.endpoint or not args.api_key:
print(f"SDK failed for {args.model}. REST fallback requires --endpoint and --api-key.")
sys.exit(1)
print(f"SDK failed for {args.model}, falling back to REST API...")
result = submit_sft_rest(args.endpoint, args.api_key, args.model,
train_id, val_id, args.epochs, args.lr, args.batch_size, args.suffix,
args.training_type)
else:
raise
print(f"\nJob submitted successfully:")
print(json.dumps(result, indent=2))
# Save job info
outfile = f"ft_job_{result['id']}.json"
with open(outfile, "w", encoding="utf-8") as f:
json.dump({**result, "epochs": args.epochs, "lr": args.lr,
"batch_size": args.batch_size, "train_file": train_id,
"val_file": val_id}, f, indent=2)
print(f"Job info saved to {outfile}")
if __name__ == "__main__":
main()