项目文件夹

文件
2026-07-13 12:37:47 +08:00

1449 行
61 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
console.log("Starting FineTune Studio MCP Server...");
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import cors from "cors";
import express from "express";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { z } from "zod";
import { Client as GradioClient } from "@gradio/client";
// ── __dirname compat (works on all Node ESM versions) ─────────────────
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const server = new McpServer({ name: "FineTuneStudio", version: "1.0.0" });
const resourceUri = "ui://finetune-studio/widget.html";
const HF_API = "https://huggingface.co/api";
const HF_ROUTER_URL = "https://router.huggingface.co/v1/chat/completions";
// ── Inference Space templates ──────────────────────────────────────────
// A Gradio Space that loads the fine-tuned model and serves it via
// a simple /api/predict endpoint. Deployed automatically after training.
const INFERENCE_APP_PY = `
import gradio as gr
import json
import os
import torch
from transformers import pipeline
MODEL_ID = os.environ.get("MODEL_ID", "")
HF_TOKEN = os.environ.get("HF_TOKEN", None)
print(f"[inference] Loading {MODEL_ID} ...", flush=True)
try:
_pipe = pipeline(
"text-generation",
model=MODEL_ID,
torch_dtype=torch.float32,
token=HF_TOKEN or None,
)
print("[inference] Ready!", flush=True)
except Exception as _e:
print(f"[inference] Load error: {_e}", flush=True)
_pipe = None
def predict(messages_json: str, max_tokens: float = 512, temperature: float = 0.7) -> str:
"""Generate a response given a JSON-encoded messages array."""
if _pipe is None:
return "Error: model failed to load. Check Space logs."
try:
messages = json.loads(messages_json)
temp = float(temperature)
result = _pipe(
messages,
max_new_tokens=int(max_tokens),
temperature=temp if temp > 0.01 else None,
do_sample=temp > 0.01,
return_full_text=False,
)
content = result[0]["generated_text"]
if isinstance(content, list):
return content[-1].get("content", str(content[-1]))
return str(content)
except Exception as e:
return f"Error: {e}"
demo = gr.Interface(
fn=predict,
inputs=[
gr.Textbox(label="messages_json", lines=3),
gr.Number(value=512, label="max_tokens"),
gr.Number(value=0.7, label="temperature"),
],
outputs=gr.Textbox(label="response"),
title=f"Inference: {MODEL_ID}",
flagging_mode="never",
api_name="predict",
)
demo.launch()
`.trim();
const INFERENCE_REQUIREMENTS_TXT = `gradio>=4.0.0
transformers>=4.40.0
torch
accelerate>=0.26.0`.trim();
// Helper: fetch the current HEAD commit SHA of a repo's branch.
// The HF commit API requires parentCommit when the repo already has commits;
// omitting it on a non-empty repo causes a 412 Precondition Failed error.
async function getHeadCommit(
repoType: "spaces" | "models",
owner: string,
name: string,
branch: string,
token: string,
): Promise<string | null> {
try {
const res = await fetch(
`https://huggingface.co/api/${repoType}/${owner}/${name}/commits/${branch}`,
{ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8000) },
);
if (!res.ok) return null;
const data = await res.json() as any[];
return Array.isArray(data) && data[0]?.id ? String(data[0].id) : null;
} catch {
return null;
}
}
// Deploys a Gradio inference Space for a fine-tuned model.
// Returns the Space URL on success, throws on failure (so callers know it failed).
async function deployInferenceSpace(
username: string,
projectName: string,
token: string,
): Promise<{ space_url: string; space_name: string }> {
const spaceName = `inference-${projectName}`;
const modelId = `${username}/${projectName}`;
const spaceUrl = `https://huggingface.co/spaces/${username}/${spaceName}`;
// ── 1. Create Space (idempotent — 409 means already exists) ─────────
const createRes = await fetch("https://huggingface.co/api/repos/create", {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ type: "space", name: spaceName, private: false, sdk: "gradio" }),
signal: AbortSignal.timeout(12000),
});
const isNew = createRes.status === 200 || createRes.status === 201;
if (!createRes.ok && createRes.status !== 409) {
const e = await createRes.text().catch(() => "");
throw new Error(`Space create failed (HTTP ${createRes.status}): ${e.slice(0, 200)}`);
}
console.log(`[inference-space] Space ${isNew ? "created" : "already exists"}: ${username}/${spaceName}`);
// ── 2. Wait for git repo to initialise (only needed on fresh create) ─
// HF backend takes 1-3 s to set up the git repo after creation.
// Committing too early returns 404 on the commit endpoint.
if (isNew) {
console.log("[inference-space] Waiting 4s for repo to initialise…");
await new Promise(r => setTimeout(r, 4000));
}
// ── 3. Get HEAD commit SHA (required as parentCommit) ────────────────
// If we omit parentCommit on a non-empty repo the API returns 412.
// On a brand-new repo it may still be empty (null is fine — omit it).
const parentCommit = await getHeadCommit("spaces", username, spaceName, "main", token);
console.log(`[inference-space] parentCommit=${parentCommit ?? "(none — empty repo)"}`);
// ── 4. Commit app.py + requirements.txt ──────────────────────────────
const toB64 = (s: string) => Buffer.from(s, "utf-8").toString("base64");
const headerValue: Record<string, unknown> = {
summary: `Deploy inference Space for ${modelId}`,
};
if (parentCommit) headerValue.parentCommit = parentCommit;
const ndjson = [
JSON.stringify({ key: "header", value: headerValue }),
JSON.stringify({ key: "file", value: { path: "app.py", encoding: "base64", content: toB64(INFERENCE_APP_PY) } }),
JSON.stringify({ key: "file", value: { path: "requirements.txt", encoding: "base64", content: toB64(INFERENCE_REQUIREMENTS_TXT) } }),
].join("\n");
const commitRes = await fetch(
`https://huggingface.co/api/spaces/${username}/${spaceName}/commit/main`,
{
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/x-ndjson" },
body: ndjson,
signal: AbortSignal.timeout(30000),
},
);
if (!commitRes.ok) {
const e = await commitRes.text().catch(() => "");
throw new Error(`Space commit failed (HTTP ${commitRes.status}): ${e.slice(0, 300)}`);
}
console.log(`[inference-space] ✅ app.py committed to ${username}/${spaceName}`);
// ── 5. Set MODEL_ID and HF_TOKEN secrets ─────────────────────────────
for (const [key, value] of [["MODEL_ID", modelId], ["HF_TOKEN", token]] as const) {
const secretRes = await fetch(
`https://huggingface.co/api/spaces/${username}/${spaceName}/secrets`,
{
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ key, value }),
signal: AbortSignal.timeout(8000),
},
);
if (!secretRes.ok) {
console.warn(`[inference-space] secret ${key} failed: ${secretRes.status}`);
} else {
console.log(`[inference-space] ✅ secret ${key} set`);
}
}
console.log(`[inference-space] ✅ Fully deployed: ${spaceUrl}`);
return { space_url: spaceUrl, space_name: spaceName };
}
// Read token dynamically so it is always current regardless of when the
// env var was injected (e.g. mcp-use dashboard sets it after process start).
function getHFToken(): string {
return process.env.HF_TOKEN || "";
}
// ── Helper: HF API fetch ──────────────────────────────────────────────
async function hfGet(url: string) {
const token = getHFToken();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(url, { headers });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HF API error ${res.status}: ${text.slice(0, 300)}`);
}
return res.json();
}
async function hfPost(url: string, body: unknown) {
const token = getHFToken();
if (!token) throw new Error("HF_TOKEN environment variable is not set. Add it to your environment and restart.");
const res = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HF API error ${res.status}: ${text.slice(0, 300)}`);
}
return res.json();
}
// ── Tool 1: launch_studio ─────────────────────────────────────────────
registerAppTool(
server,
"launch_studio",
{
title: "FineTune Studio",
description:
"Opens the FineTune Studio — an interactive dashboard for fine-tuning any Hugging Face model and chatting with it, all inside this conversation.",
inputSchema: {},
_meta: { ui: { resourceUri } },
},
async () => {
return { content: [{ type: "text" as const, text: "" }] };
}
);
// ── Tool 2: search_models ─────────────────────────────────────────────
registerAppTool(
server,
"search_models",
{
title: "Search HF Models",
description: "Search Hugging Face Hub for text-generation models.",
inputSchema: {
query: z.string().describe("Search query"),
limit: z.number().optional().describe("Max results, default 10"),
},
_meta: {},
},
async ({ query, limit }: { query: string; limit?: number }) => {
try {
const n = limit || 10;
const url = `${HF_API}/models?search=${encodeURIComponent(query)}&limit=${n}&filter=text-generation&sort=downloads`;
const data = await hfGet(url) as any[];
const models = data.map((m: any) => ({
modelId: m.modelId || m.id || "",
author: m.author || (m.modelId || m.id || "").split("/")[0] || "",
downloads: m.downloads || 0,
likes: m.likes || 0,
pipeline_tag: m.pipeline_tag || "text-generation",
tags: (m.tags || []).filter((t: string) => !t.startsWith("arxiv:")).slice(0, 6),
lastModified: m.lastModified || "",
}));
return { content: [{ type: "text" as const, text: JSON.stringify({ models }) }] };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify({ models: [], error: String(err) }) }] };
}
}
);
// ── Tool 3: search_datasets ───────────────────────────────────────────
registerAppTool(
server,
"search_datasets",
{
title: "Search HF Datasets",
description: "Search Hugging Face Hub for datasets.",
inputSchema: {
query: z.string().describe("Search query"),
limit: z.number().optional().describe("Max results, default 10"),
},
_meta: {},
},
async ({ query, limit }: { query: string; limit?: number }) => {
try {
const n = limit || 10;
const url = `${HF_API}/datasets?search=${encodeURIComponent(query)}&limit=${n}&sort=downloads`;
const data = await hfGet(url) as any[];
const datasets = data.map((d: any) => ({
datasetId: d.id || "",
author: d.author || (d.id || "").split("/")[0] || "",
downloads: d.downloads || 0,
likes: d.likes || 0,
description: ((d.description || d.cardData?.description || "")).slice(0, 200),
tags: (d.tags || []).slice(0, 5),
size: d.cardData?.size_categories?.[0] || "",
}));
return { content: [{ type: "text" as const, text: JSON.stringify({ datasets }) }] };
} catch (err: unknown) {
return { content: [{ type: "text" as const, text: JSON.stringify({ datasets: [], error: String(err) }) }] };
}
}
);
// ── Tool 4: start_training ────────────────────────────────────────────
registerAppTool(
server,
"start_training",
{
title: "Start Training",
description: "Start a fine-tuning job on Hugging Face AutoTrain.",
inputSchema: {
base_model: z.string(),
dataset: z.string(),
dataset_split: z.string().optional(),
project_name: z.string(),
task: z.string().optional(),
hardware: z.string().optional(),
training_type: z.string().optional(),
chat_template: z.string().optional(),
hyperparameters: z.object({
epochs: z.number().optional(),
batch_size: z.number().optional(),
learning_rate: z.number().optional(),
block_size: z.number().optional(),
lora_r: z.number().optional(),
lora_alpha: z.number().optional(),
lora_dropout: z.number().optional(),
quantization: z.string().optional(),
gradient_accumulation: z.number().optional(),
mixed_precision: z.string().optional(),
warmup_ratio: z.number().optional(),
weight_decay: z.number().optional(),
target_modules: z.string().optional(),
max_steps: z.number().optional(),
}).optional(),
column_mapping: z.record(z.string()).optional(),
},
_meta: {},
},
async (params: any) => {
const token = getHFToken();
try {
if (!token) throw new Error("HF_TOKEN environment variable is not set.");
// Fetch username
const me = await hfGet("https://huggingface.co/api/whoami-v2") as any;
const username = me.name as string;
const hp = params.hyperparameters || {};
const trainingType = params.training_type || "sft";
// ── Find user's AutoTrain Advanced Space ───────────────────────────
// HF removed the old cloud API. Training now routes through an
// AutoTrain Advanced Space the user runs in their HF account.
// We look for it by the standard name; if missing, return setup instructions.
let spaceSubdomain = "";
const AUTOTRAIN_SPACE_NAME = "autotrain-advanced";
try {
const spaceInfo = await hfGet(`${HF_API}/spaces/${username}/${AUTOTRAIN_SPACE_NAME}`) as any;
// subdomain is the slug used in the .hf.space URL
spaceSubdomain = spaceInfo.subdomain || "";
} catch {
// Space not found — return actionable setup instructions
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: false,
setup_required: true,
error:
`AutoTrain Advanced Space not found in your HF account (@${username}). ` +
`HF retired the old cloud training API. You need a one-time setup: ` +
`go to https://huggingface.co/spaces/autotrain-projects/autotrain-advanced, ` +
`click "Duplicate this Space", name it "${AUTOTRAIN_SPACE_NAME}", ` +
`add your HF_TOKEN as a Space secret, then try again.`,
setup_url: "https://huggingface.co/spaces/autotrain-projects/autotrain-advanced",
}),
}],
};
}
if (!spaceSubdomain) {
throw new Error(`Could not determine the URL for your AutoTrain Space (@${username}/${AUTOTRAIN_SPACE_NAME}).`);
}
const spaceApiBase = `https://${spaceSubdomain}.hf.space`;
// ── Compute effective epochs (honouring max_steps if set) ────────
// AutoTrain's API doesn't expose max_steps as a config field — it is
// silently dropped by Pydantic. We implement it ourselves by fetching the
// dataset split size and converting max_steps → a fractional epoch count.
// effective_epochs = min(max_steps, steps_per_epoch × config_epochs)
// ÷ steps_per_epoch
const configEpochs = hp.epochs ?? 2;
const batchSize = hp.batch_size ?? 1;
const gradAcc = hp.gradient_accumulation ?? 4;
// When max_steps is set, fetch dataset size so we can calculate the minimum
// number of *integer* epochs needed for those steps to be reachable.
// AutoTrain requires epochs to be an integer and passes max_steps directly
// to HF Trainer which stops training at min(max_steps, total_steps).
let maxStepsEpochs: number | null = null; // integer epochs needed
if (hp.max_steps && hp.max_steps > 0 && params.dataset) {
const dsEnc = encodeURIComponent(params.dataset);
const splitKey = params.dataset_split || "train";
let trainRows: number | null = null;
try {
const r = await fetch(
`https://datasets-server.huggingface.co/info?dataset=${dsEnc}`,
{ headers: token ? { Authorization: `Bearer ${token}` } : {}, signal: AbortSignal.timeout(8000) }
);
const d = await r.json() as any;
const info = d?.dataset_info ?? {};
const splits = info.splits ?? info[Object.keys(info)[0]]?.splits ?? {};
trainRows = splits[splitKey]?.num_examples ?? splits.train?.num_examples ?? null;
} catch { /* ignore */ }
if (trainRows && trainRows > 0) {
const stepsPerEpoch = Math.ceil(trainRows / batchSize) / gradAcc;
// Minimum whole epochs needed so max_steps can actually be reached
maxStepsEpochs = Math.max(1, Math.ceil(hp.max_steps / stepsPerEpoch));
console.log(`[max_steps] rows=${trainRows} stepsPerEpoch=${stepsPerEpoch.toFixed(0)} maxStepsEpochs=${maxStepsEpochs}`);
} else {
console.warn(`[max_steps] could not fetch dataset size for "${params.dataset}" — using epochs=${configEpochs}`);
}
}
// ── Build the training request body ───────────────────────────────
// Note: `push_to_hub` must be at the TOP LEVEL — AutoTrain ignores it
// when nested inside `params` (silently dropped by Pydantic). Same pattern
// as `username` and `token` which also live at top level and flow through
// to the training config.
const body: Record<string, unknown> = {
username,
project_name: params.project_name,
task: params.task || "llm:sft",
base_model: params.base_model,
hub_dataset: params.dataset,
train_split: params.dataset_split || "train",
hardware: params.hardware || "spaces-a10g-large",
column_mapping: params.column_mapping || { text_column: "text" },
// push_to_hub must live inside a top-level "hub" object —
// the API ignores it when nested in "params" or bare at the top level.
hub: {
username,
token,
push_to_hub: true,
},
hub_model: `${username}/${params.project_name}`,
token, // also kept at top level for the Space auth header
params: {
epochs: maxStepsEpochs ?? Math.max(1, Math.round(configEpochs)),
batch_size: hp.batch_size ?? 1,
lr: hp.learning_rate ?? 0.0002,
block_size: hp.block_size ?? 1024,
peft: true,
quantization: hp.quantization ?? "int4",
mixed_precision: hp.mixed_precision ?? "bf16",
lora_r: hp.lora_r ?? 16,
lora_alpha: hp.lora_alpha ?? 32,
lora_dropout: hp.lora_dropout ?? 0.05,
gradient_accumulation: hp.gradient_accumulation ?? 4,
warmup_ratio: hp.warmup_ratio ?? 0.1,
weight_decay: hp.weight_decay ?? 0.01,
target_modules: hp.target_modules ?? "all-linear",
// "none" means a plain-text dataset — omit the field so AutoTrain
// skips apply_chat_template (avoids the ast.literal_eval SyntaxError).
...(params.chat_template && params.chat_template !== "none"
? { chat_template: params.chat_template }
: {}),
trainer: trainingType,
merge_adapter: true, // merge LoRA weights into base model before push
...(hp.max_steps && hp.max_steps > 0 ? { max_steps: Math.round(hp.max_steps) } : {}),
},
};
// ── POST to the user's AutoTrain Space API ─────────────────────────
const res = await fetch(`${spaceApiBase}/api/create_project`, {
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = await res.text().catch(() => "");
throw new Error(`AutoTrain Space API error ${res.status}: ${errText.slice(0, 300)}`);
}
const result = await res.json() as any;
// ── Inject push_to_hub=true directly into the training Space secret ──
//
// Root cause (confirmed by reading AutoTrain source):
// SpaceRunner._add_secrets() serialises LLMTrainingParams → JSON and stores
// it as the PARAMS env secret on the training sub-Space
// (e.g. autotrain-{project_name}). LLMTrainingParams.push_to_hub defaults
// to False, and because push_to_hub is in FIELDS_TO_EXCLUDE the API body
// can never override it. The training script reads os.environ["PARAMS"],
// deserialises it, and honours push_to_hub — so updating the secret to
// True is the definitive fix.
//
// Timing: create_project is synchronous — by the time it returns the
// training Space already exists with its secrets set. Updating a secret
// triggers a Space restart (HF behaviour). Since the Space is still in
// its 2-5 min build phase this restart is free; it re-builds and starts
// training with push_to_hub=True.
const trainingSpaceName = `autotrain-${params.project_name}`;
const textCol = (params.column_mapping as Record<string, string>)?.text_column || "text";
const effectiveEpochs = maxStepsEpochs ?? Math.max(1, Math.round(configEpochs));
// Build the LLMTrainingParams-compatible JSON that the training script
// deserialises from the PARAMS secret. Extra/unknown keys are fine —
// LLMTrainingParams(**json) ignores them via Pydantic's default behaviour.
const paramsSecret: Record<string, unknown> = {
model: params.base_model,
project_name: params.project_name,
username,
token,
data_path: params.dataset,
train_split: params.dataset_split || "train",
text_column: textCol,
push_to_hub: true, // ← THE FIX
log: "tensorboard",
epochs: effectiveEpochs,
batch_size: hp.batch_size ?? 1,
lr: hp.learning_rate ?? 0.0002,
block_size: hp.block_size ?? 1024,
peft: true,
quantization: hp.quantization ?? "int4",
mixed_precision: hp.mixed_precision ?? "bf16",
lora_r: hp.lora_r ?? 16,
lora_alpha: hp.lora_alpha ?? 32,
lora_dropout: hp.lora_dropout ?? 0.05,
gradient_accumulation: hp.gradient_accumulation ?? 4,
warmup_ratio: hp.warmup_ratio ?? 0.1,
weight_decay: hp.weight_decay ?? 0.01,
target_modules: hp.target_modules ?? "all-linear",
trainer: trainingType,
merge_adapter: true,
padding: "right",
chat_template: (params.chat_template && params.chat_template !== "none")
? params.chat_template : "none",
...(hp.max_steps && hp.max_steps > 0 ? { max_steps: Math.round(hp.max_steps) } : {}),
};
// POST to HF Hub Secrets API — same endpoint/method used by huggingface_hub
// Python library's add_space_secret().
try {
const secretRes = await fetch(
`https://huggingface.co/api/spaces/${username}/${trainingSpaceName}/secrets`,
{
method: "POST",
headers: {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ key: "PARAMS", value: JSON.stringify(paramsSecret) }),
signal: AbortSignal.timeout(10000),
}
);
if (secretRes.ok) {
console.log(`[push_to_hub] ✅ PARAMS secret updated on ${trainingSpaceName} — push_to_hub=true`);
} else {
const errBody = await secretRes.text().catch(() => "");
console.warn(`[push_to_hub] ⚠️ Secret update HTTP ${secretRes.status}: ${errBody.slice(0, 200)}`);
}
} catch (secretErr) {
// Non-fatal — training will proceed, just may not push to Hub
console.warn(`[push_to_hub] ⚠️ Could not update PARAMS secret: ${secretErr}`);
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
project_id: result.id || result.project_id || params.project_name,
// AutoTrain creates the training Space with "autotrain-" prefix
space_url: `https://huggingface.co/spaces/${username}/autotrain-${params.project_name}`,
autotrain_space_url: `https://huggingface.co/spaces/${username}/${AUTOTRAIN_SPACE_NAME}`,
model_url: `https://huggingface.co/${username}/${params.project_name}`,
username,
status: "starting",
}),
}],
};
} catch (err: unknown) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: false,
error: err instanceof Error ? err.message : String(err),
}),
}],
};
}
}
);
// ── Tool 5: check_training_status ─────────────────────────────────────
registerAppTool(
server,
"check_training_status",
{
title: "Check Training Status",
description: "Poll the status of a running AutoTrain fine-tuning job.",
inputSchema: {
project_name: z.string(),
username: z.string().optional(),
},
_meta: {},
},
async ({ project_name, username }: { project_name: string; username?: string }) => {
try {
let user = username;
if (!user) {
const me = await hfGet("https://huggingface.co/api/whoami-v2") as any;
user = me.name as string;
}
const stageMap: Record<string, string> = {
RUNNING: "training",
BUILDING: "starting",
STOPPED: "completed",
ERROR: "error",
SLEEPING: "starting",
PAUSED: "starting",
NO_APP_FILE: "starting",
CONFIG_ERROR: "error",
APP_STARTING: "starting",
};
// AutoTrain names the training Space "autotrain-{project_name}".
// After training it renames the space to "{project_name}" (drops prefix).
// We poll with the autotrain- prefix while it's running; the JWT in the
// container logs confirms: sub = /spaces/avi81/autotrain-{project_name}.
const trainingSpaceName = `autotrain-${project_name}`;
let rawStage = "BUILDING";
let status = "starting";
let spaceSubdomain = "";
try {
// Fetch the Space info (includes subdomain we need for logs)
const spaceInfo = await hfGet(`${HF_API}/spaces/${user}/${trainingSpaceName}`) as any;
spaceSubdomain = spaceInfo.subdomain || "";
// Also fetch runtime for the live stage
const runtime = await hfGet(`${HF_API}/spaces/${user}/${trainingSpaceName}/runtime`) as any;
rawStage = runtime.stage || "BUILDING";
status = stageMap[rawStage] || "starting";
} catch (runtimeErr: unknown) {
const msg = String(runtimeErr);
// 404 = Space not yet provisioned by AutoTrain — keep as "starting"
if (!msg.includes("404")) {
status = "error";
rawStage = "ERROR";
}
}
// AutoTrain pauses its Space when training finishes, so PAUSED / STOPPED
// alone can't distinguish "not-started-yet" from "just-finished".
// We disambiguate by looking for shutdown / cleanup log lines.
// This is applied AFTER logs are collected below, so we store the
// preliminary status and patch it if needed.
const preliminaryStatus = status;
// ── Fetch container logs via HF Hub SSE endpoint ─────────────────
// Correct endpoint is /logs/run (not /logs).
// Streams SSE events: data: {"type":"stdout","text":"<line>\n"}
let logs = "";
let logsDebug = "";
const token = getHFToken();
const logsUrl = `${HF_API}/spaces/${user}/${trainingSpaceName}/logs/run`;
try {
const logRes = await fetch(logsUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
signal: AbortSignal.timeout(8000),
});
logsDebug = `[logs fetch: HTTP ${logRes.status}]`;
if (logRes.ok && logRes.body) {
const reader = logRes.body.getReader();
const decoder = new TextDecoder();
let raw = "";
// Hard-cancel the reader after 4 s — reader.cancel() causes
// reader.read() to reject, which is our signal to stop.
const cancelTimer = setTimeout(
() => reader.cancel("timeout").catch(() => {}),
4000
);
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
raw += decoder.decode(value, { stream: true });
}
} catch {
// reader.cancel() threw — normal exit path
} finally {
clearTimeout(cancelTimer);
}
logsDebug += ` [raw bytes: ${raw.length}]`;
// Parse SSE lines: "data: {"type":"stdout","text":"log line\n"}"
const ansiRe = /\x1b\[[0-9;]*m/g;
logs = raw
.split("\n")
.filter(l => l.startsWith("data:"))
.map(l => {
const payload = l.slice(5).trim();
if (!payload) return "";
try {
const obj = JSON.parse(payload) as any;
if (obj.type === "run") return "";
return String(obj.text ?? obj.data ?? payload)
.replace(/\n$/, "")
.replace(ansiRe, "");
} catch {
return payload.replace(ansiRe, "");
}
})
.filter(Boolean)
.join("\n");
} else {
const errBody = await logRes.text().catch(() => "");
logsDebug += ` [body: ${errBody.slice(0, 200)}]`;
}
} catch (logErr: unknown) {
logsDebug = `[logs fetch error: ${String(logErr)}]`;
}
// ── Patch status using log content ────────────────────────────────
// AutoTrain pauses the Space after training completes. The PAUSED stage
// alone is ambiguous (could be "not started yet"). If the logs contain
// AutoTrain / HF Trainer shutdown signatures we know training finished.
if (preliminaryStatus !== "training" && preliminaryStatus !== "error") {
const completionRe = /SIGTERM|graceful\.exit|Application\.shutdown|Training complete|model.*pushed|Pausing space|pause_space/i;
if (completionRe.test(logs)) {
status = "completed";
}
}
// Parse metrics from logs — handle both 'key': val and key: val formats.
// AutoTrain logs: {'loss': 9.73, 'learning_rate': 0.0002, 'epoch': 0.02}
// Always use the LAST match so stale early-epoch values don't stick.
const lastMatch = (text: string, ...patterns: RegExp[]) => {
for (const re of patterns) {
const all = [...text.matchAll(new RegExp(re.source, re.flags.replace("g","") + "g"))];
if (all.length > 0) return all[all.length - 1];
}
return null;
};
const lossMatch = lastMatch(logs, /'loss':\s*([0-9]+\.[0-9]+)/, /(?:train_)?loss[:\s=]+([0-9]+\.[0-9]+)/i);
const epochMatch = lastMatch(logs, /'epoch':\s*([0-9]+(?:\.[0-9]*)?)/, /epoch[:\s]+([0-9]+\.?[0-9]*)/i);
const lrMatch = lastMatch(logs, /'learning_rate':\s*([0-9.e+\-]+)/, /(?:learning_rate|lr)[:\s=]+([0-9.e+\-]+)/i);
// tqdm uses \r to overwrite lines, so only the initial 0/N line arrives cleanly.
// Send totalSteps to the client so it can compute current step as:
// step = round((epoch / config.epochs) * totalSteps)
// (epoch goes 0→config.epochs continuously, totalSteps spans all epochs)
const totalStepsMatch = lastMatch(logs, /\d+\/(\d+)\s*\[/);
const totalSteps = totalStepsMatch ? parseInt(totalStepsMatch[1]) : null;
// ── Auto-publish model when training completes ─────────────────────
// AutoTrain hardcodes private=True when creating the model repo.
// We flip it to public so the inference Space can access it.
// This is a one-shot operation: the HF API ignores it if already public.
let madePublic = false;
let inferenceSpaceResult: { space_url: string; space_name: string } | null = null;
if (status === "completed" && token) {
try {
const pubRes = await fetch(
`https://huggingface.co/api/models/${user}/${project_name}/settings`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ private: false }),
signal: AbortSignal.timeout(8000),
}
);
madePublic = pubRes.ok;
if (pubRes.ok) {
console.log(`[visibility] ✅ ${user}/${project_name} set to public`);
} else {
const t = await pubRes.text().catch(() => "");
console.warn(`[visibility] ⚠️ HTTP ${pubRes.status}: ${t.slice(0, 200)}`);
}
} catch (pubErr) {
console.warn(`[visibility] ⚠️ ${pubErr}`);
}
// ── Deploy inference Space ─────────────────────────────────────
// HF serverless inference (inferenceProviderMapping) no longer serves
// arbitrary custom fine-tuned models. We work around this by deploying
// a small Gradio Space that loads the model with transformers directly.
// The Space name is inference-{project_name}.
try {
inferenceSpaceResult = await deployInferenceSpace(user, project_name, token);
} catch (spaceErr) {
console.warn(`[inference-space] deployment failed: ${spaceErr}`);
}
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({
status,
stage: rawStage,
training_space: `https://huggingface.co/spaces/${user}/${trainingSpaceName}`,
logs: (logsDebug ? logsDebug + "\n" : "") + logs.slice(-4000),
metrics: {
loss: lossMatch ? parseFloat(lossMatch[1]) : null,
epoch: epochMatch ? parseFloat(epochMatch[1]) : null,
totalSteps,
learning_rate: lrMatch ? parseFloat(lrMatch[1]) : null,
},
...(status === "completed" ? {
model_made_public: madePublic,
inference_space_url: inferenceSpaceResult?.space_url ?? null,
inference_space_name: inferenceSpaceResult?.space_name ?? null,
} : {}),
}),
}],
};
} catch (err: unknown) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({ status: "error", error: String(err) }),
}],
};
}
}
);
// ── Tool 6: chat_with_model ───────────────────────────────────────────
registerAppTool(
server,
"chat_with_model",
{
title: "Chat with Model",
description: "Send messages to a Hugging Face model via the inference API and get a response.",
inputSchema: {
model_id: z.string().describe("HF model ID, e.g. meta-llama/Llama-3.2-3B-Instruct"),
messages: z.array(z.object({
role: z.enum(["system", "user", "assistant"]),
content: z.string(),
})),
parameters: z.object({
temperature: z.number().optional(),
top_p: z.number().optional(),
max_tokens: z.number().optional(),
}).optional(),
},
_meta: {},
},
async ({ model_id, messages, parameters }: {
model_id: string;
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
parameters?: { temperature?: number; top_p?: number; max_tokens?: number };
}) => {
try {
const token = getHFToken();
if (!token) throw new Error("HF_TOKEN environment variable is not set.");
const chatBody = {
model: model_id,
messages,
temperature: parameters?.temperature ?? 0.7,
max_tokens: parameters?.max_tokens ?? 512,
top_p: parameters?.top_p ?? 0.9,
stream: false,
};
// ── Inference strategy — four attempts in order ────────────────────
//
// 0. Dedicated Inference Space ({owner}/inference-{model_name}).
// We auto-deploy this Gradio Space after training completes.
// It loads the model with transformers directly — works for ANY
// Hub model regardless of HF's inferenceProviderMapping.
// This is the primary path for custom fine-tuned models.
//
// 1. HF Inference Router (router.huggingface.co).
// Works for popular public models supported by providers.
//
// 2. HF per-model Messages API (api-inference.huggingface.co /v1/chat).
// Works for public models indexed by HF's TGI fleet.
//
// 3. HF Legacy Text-Generation API (api-inference.huggingface.co /models).
// Last resort for public models that support the older endpoint.
let responseText = "";
// ── Attempt 0: Dedicated Inference Space (Gradio 4.x raw HTTP API) ──
// For custom fine-tuned models we auto-deploy a Gradio Space post-training
// named {owner}/inference-{model_name}.
//
// Gradio 4.x dropped the old /api/predict sync endpoint. The current API is:
// POST /gradio_api/call/predict → {"event_id": "abc"}
// GET /gradio_api/call/predict/{event_id} → SSE "event: complete\ndata: [...]"
//
// We use raw HTTP so we have full control over errors (the @gradio/client
// package silently swallows connection errors which masked failures).
if (!responseText && model_id.includes("/")) {
const [owner, modelName] = model_id.split("/");
const inferSpaceName = `inference-${modelName}`;
// ── 1. Does the Space exist? Get subdomain while we're at it. ────
const spaceInfoRes = await fetch(
`${HF_API}/spaces/${owner}/${inferSpaceName}`,
{ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8000) },
).catch(() => null);
if (spaceInfoRes?.ok) {
const spaceData = await spaceInfoRes.json() as any;
const subdomain = spaceData.subdomain as string | undefined;
// ── 2. Check runtime stage ──────────────────────────────────
const runtimeRes = await fetch(
`${HF_API}/spaces/${owner}/${inferSpaceName}/runtime`,
{ headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(8000) },
).catch(() => null);
// If the runtime endpoint returns 404, the Space was just created and
// hasn't started its build cycle yet — treat it as BUILDING.
const stage: string = runtimeRes?.ok
? (((await runtimeRes.json()) as any).stage || "UNKNOWN")
: "BUILDING";
console.log(`[chat] inference-space ${owner}/${inferSpaceName} stage=${stage}`);
if (["BUILDING", "APP_STARTING", "STARTING", "NO_APP_FILE", "UNKNOWN"].includes(stage)) {
throw new Error(
`⏳ Your inference Space (${owner}/${inferSpaceName}) is still building — ` +
`takes 2–3 min on first deploy. Please wait and try again.\n` +
`Track progress: https://huggingface.co/spaces/${owner}/${inferSpaceName}`,
);
}
if (stage === "SLEEPING") {
throw new Error(
`😴 Your inference Space (${owner}/${inferSpaceName}) was sleeping and is now waking up. ` +
`Please wait ~1 minute and try again.`,
);
}
if (stage === "RUNNING" && subdomain) {
// ── 3. POST to Gradio 4.x queue endpoint ─────────────────
const gradioBase = `https://${subdomain}.hf.space/gradio_api/call/predict`;
const submitRes = await fetch(gradioBase, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
// gr.Interface uses positional parameters — same order as fn signature:
// predict(messages_json, max_tokens, temperature)
data: [
JSON.stringify(messages),
chatBody.max_tokens,
chatBody.temperature,
],
}),
signal: AbortSignal.timeout(30000),
});
if (!submitRes.ok) {
const b = await submitRes.text().catch(() => "");
throw new Error(
`Inference Space submit failed (HTTP ${submitRes.status}): ${b.slice(0, 200)}`,
);
}
const { event_id } = await submitRes.json() as { event_id: string };
if (!event_id) throw new Error("Inference Space did not return an event_id");
// ── 4. Stream SSE result ───────────────────────────────────
const sseRes = await fetch(`${gradioBase}/${event_id}`, {
signal: AbortSignal.timeout(180_000), // 3 min — first inference on CPU is slow
});
if (!sseRes.ok) {
throw new Error(`Inference Space SSE failed (HTTP ${sseRes.status})`);
}
const reader = sseRes.body!.getReader();
const decoder = new TextDecoder();
let buf = "";
parseSSE: while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
for (const chunk of buf.split("\n\n")) {
const eventLine = chunk.split("\n").find(l => l.startsWith("event:"))?.slice(7).trim() ?? "";
const dataLine = chunk.split("\n").find(l => l.startsWith("data:"))?.slice(6).trim() ?? "";
if (eventLine === "error") {
throw new Error(`Inference Space error: ${dataLine.slice(0, 200)}`);
}
if (eventLine === "complete" && dataLine) {
const parsed = JSON.parse(dataLine);
const resp = String(parsed?.[0] ?? "");
if (resp.startsWith("Error:")) {
throw new Error(`Inference Space returned: ${resp}`);
}
responseText = resp;
reader.cancel().catch(() => {});
break parseSSE;
}
}
// Keep only the last incomplete chunk
buf = buf.includes("\n\n") ? buf.slice(buf.lastIndexOf("\n\n") + 2) : buf;
}
} else if (!["STOPPED", "PAUSED", "ERROR", "CONFIG_ERROR"].includes(stage)) {
// Unexpected stage — tell the user rather than silently failing
throw new Error(
`Inference Space (${owner}/${inferSpaceName}) is in an unexpected state: ${stage}. ` +
`Check https://huggingface.co/spaces/${owner}/${inferSpaceName}`,
);
}
// STOPPED / PAUSED / ERROR / CONFIG_ERROR → fall through to HF inference attempts
}
// Space doesn't exist (404) → fall through silently to HF inference attempts
}
// ── Attempt 1: HF Inference Router ────────────────────────────────
if (!responseText) {
const routerModel = model_id.includes(":") ? model_id : `${model_id}:fastest`;
const res1 = await fetch(HF_ROUTER_URL, {
method: "POST",
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ ...chatBody, model: routerModel }),
});
if (res1.ok) {
const d = await res1.json() as any;
responseText = d.choices?.[0]?.message?.content || "";
} else {
if (res1.status === 401 || res1.status === 403) {
throw new Error(`Access denied for "${model_id}". Check your HF_TOKEN and model license.`);
}
// 404 / "not supported by any provider" → try next endpoint
}
}
// ── Attempt 2: Messages API (chat-completions style) ──────────────
if (!responseText) {
const url2 = `https://api-inference.huggingface.co/models/${model_id}/v1/chat/completions`;
const res2 = await fetch(url2, {
method: "POST",
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ ...chatBody, model: model_id }),
});
if (res2.ok) {
const d2 = await res2.json() as any;
responseText = d2.choices?.[0]?.message?.content || "";
}
// 404 or 503 → fall through to attempt 3
}
// ── Attempt 3: Legacy Text-Generation API ─────────────────────────
if (!responseText) {
const promptParts: string[] = [];
for (const msg of messages) {
if (msg.role === "system") {
promptParts.push(`<|im_start|>system\n${msg.content}<|im_end|>`);
} else if (msg.role === "user") {
promptParts.push(`<|im_start|>user\n${msg.content}<|im_end|>`);
} else {
promptParts.push(`<|im_start|>assistant\n${msg.content}<|im_end|>`);
}
}
promptParts.push("<|im_start|>assistant\n");
const prompt = promptParts.join("\n");
const url3 = `https://api-inference.huggingface.co/models/${model_id}`;
const res3 = await fetch(url3, {
method: "POST",
headers: { "Authorization": `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({
inputs: prompt,
parameters: {
max_new_tokens: chatBody.max_tokens,
temperature: chatBody.temperature,
top_p: chatBody.top_p,
return_full_text: false,
do_sample: true,
},
}),
});
if (!res3.ok) {
const b3 = await res3.text().catch(() => "");
if (res3.status === 404) {
// Check whether we deployed an inference Space for this model
const hasInferenceSpace = model_id.includes("/");
const [owner2, modelName2] = hasInferenceSpace ? model_id.split("/") : ["", ""];
throw new Error(
hasInferenceSpace
? `Model "${model_id}" is not yet available via HF serverless inference. ` +
`An inference Space (${owner2}/inference-${modelName2}) was deployed for you — ` +
`it takes 2–3 min to start. Check https://huggingface.co/spaces/${owner2}/inference-${modelName2}`
: `Model "${model_id}" was not found on the Hugging Face Hub. ` +
`Verify it exists at https://huggingface.co/${model_id}`
);
}
if (res3.status === 503 || b3.includes("loading") || b3.includes("estimated_time")) {
const eta = (() => { try { return JSON.parse(b3).estimated_time; } catch { return null; } })();
throw new Error(
`Model "${model_id}" is warming up${eta ? ` (~${Math.ceil(eta)}s)` : ""}. ` +
`Please try again in a moment.`
);
}
throw new Error(`Inference failed (HTTP ${res3.status}): ${b3.slice(0, 300)}`);
}
const d3 = await res3.json() as any;
const raw = Array.isArray(d3) ? d3[0]?.generated_text : d3?.generated_text;
responseText = (raw || "").replace(/^<\|im_start\|>assistant\n?/, "").trim();
}
if (!responseText) {
throw new Error(`No response from "${model_id}". The model may not support text generation via the HF Inference API.`);
}
return {
content: [{
type: "text" as const,
text: JSON.stringify({ text: responseText, model_id }),
}],
};
} catch (err: unknown) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }),
}],
};
}
}
);
// ── Tool 7: deploy_inference_space ───────────────────────────────────────
registerAppTool(
server,
"deploy_inference_space",
{
title: "Deploy Inference Space",
description:
"Manually deploy (or redeploy) a Gradio inference Space for a fine-tuned model. " +
"The Space loads the model with transformers and exposes an API endpoint, " +
"bypassing the HF serverless inference provider limitation.",
inputSchema: {
project_name: z.string().describe("The project / model name (without username prefix)"),
},
_meta: {},
},
async ({ project_name }: { project_name: string }) => {
try {
const token = getHFToken();
if (!token) throw new Error("HF_TOKEN is not set.");
const me = await hfGet("https://huggingface.co/api/whoami-v2") as any;
const username = me.name as string;
const result = await deployInferenceSpace(username, project_name, token);
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
message: "✅ Inference Space deployed! It takes 2–3 min to build and load the model.",
space_url: result.space_url,
space_name: result.space_name,
}),
}],
};
} catch (err: unknown) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) }),
}],
};
}
}
);
// ── Tool 8: patch_autotrain_space ─────────────────────────────────────
// Fixes the push_to_hub=False bug present in autotrain-advanced ≤ 0.8.36.
// Strategy: add a sitecustomize.py monkey-patch to the user's AutoTrain Space
// repo so Python auto-imports it at startup and forces push_to_hub=True before
// any training job runs.
registerAppTool(
server,
"patch_autotrain_space",
{
title: "Patch AutoTrain Space (Fix Push to Hub)",
description:
"One-click fix for the autotrain-advanced push_to_hub=False bug. " +
"Commits a sitecustomize.py monkey-patch and updated Dockerfile to your " +
"AutoTrain Space so future training jobs automatically push to the Hub.",
inputSchema: {},
_meta: {},
},
async () => {
try {
const token = getHFToken();
if (!token) throw new Error("HF_TOKEN is not set.");
const me = await hfGet("https://huggingface.co/api/whoami-v2") as any;
const username = me.name as string;
const AUTOTRAIN_SPACE_NAME = "autotrain-advanced";
const spaceRepo = `${username}/${AUTOTRAIN_SPACE_NAME}`;
// ── Why the previous sitecustomize.py approach failed ─────────────
// Python's site.getsitepackages()[0] often points to a dist-packages
// directory the autotrain interpreter doesn't check first. The reliable
// alternative is to DIRECTLY REWRITE the installed params.py source file
// on disk after each `pip install`, then also clear the .pyc cache so
// Python recompiles from the patched source.
// ── 1. fix_push_to_hub.py — run once after pip install ─────────────
const fixScript = `"""
Patch autotrain-advanced so that every training job pushes to the Hub.
Root cause (all versions <= 0.8.36):
PARAMS["llm"] is built from LLMTrainingParams().model_dump() which always
includes push_to_hub=False. The guard in _munge_common_params:
if "push_to_hub" not in _params:
_params["push_to_hub"] = True
never fires because the key IS already present (as False).
Fix: rewrite the installed params.py so the guard becomes unconditional.
"""
import importlib, inspect, os, re, sys
# ── locate params.py ──────────────────────────────────────────────────
import autotrain.app.params as _m
src = inspect.getfile(_m)
print(f"[push_to_hub patch] target: {src}", flush=True)
with open(src) as fh:
code = fh.read()
# ── apply patch ───────────────────────────────────────────────────────
# Match the conditional block regardless of exact indentation.
PATTERN = r'([ \\t]+)if ["\\'\\']push_to_hub["\\'\\'] not in _params:[\\s\\S]*?_params["\\'\\']["\\'\\']push_to_hub["\\'\\']["\\'\\'] = True'
REPLACEMENT = r'\\1_params["push_to_hub"] = True # patched: force hub push'
patched_code, n = re.subn(PATTERN, REPLACEMENT, code)
if n > 0:
with open(src, "w") as fh:
fh.write(patched_code)
# Bust the bytecode cache so the interpreter picks up the new source.
cache_dir = os.path.join(os.path.dirname(src), "__pycache__")
if os.path.isdir(cache_dir):
removed = [
os.remove(os.path.join(cache_dir, f))
for f in os.listdir(cache_dir)
if f.startswith("params") and f.endswith(".pyc")
]
print(f"[push_to_hub patch] ✅ params.py patched ({n} substitution(s))", flush=True)
elif "# patched: force hub push" in code:
print("[push_to_hub patch] ✅ params.py already patched", flush=True)
else:
# Fallback: try a simpler literal replacement (handles minor whitespace diffs).
SIMPLE_OLD = ' if "push_to_hub" not in _params:\\n _params["push_to_hub"] = True'
SIMPLE_NEW = ' _params["push_to_hub"] = True # patched: force hub push'
if SIMPLE_OLD in code:
with open(src, "w") as fh:
fh.write(code.replace(SIMPLE_OLD, SIMPLE_NEW))
print("[push_to_hub patch] ✅ params.py patched (literal fallback)", flush=True)
else:
print(f"[push_to_hub patch] ⚠️ Could not locate patch target in {src}.", file=sys.stderr, flush=True)
print("[push_to_hub patch] Inspecting file…", file=sys.stderr, flush=True)
for i, line in enumerate(code.splitlines(), 1):
if "push_to_hub" in line:
print(f" L{i}: {line!r}", file=sys.stderr, flush=True)
`;
// ── 2. Dockerfile ───────────────────────────────────────────────────
const dockerfileContent = `FROM huggingface/autotrain-advanced:latest
COPY fix_push_to_hub.py /tmp/fix_push_to_hub.py
CMD pip uninstall -y autotrain-advanced && \\
pip install -U autotrain-advanced && \\
python /tmp/fix_push_to_hub.py && \\
autotrain app --host 0.0.0.0 --port 7860 --workers 1
`;
// ── 3. Commit both files to the Space repo ─────────────────────────
const toB64 = (s: string) => Buffer.from(s, "utf-8").toString("base64");
const ndjson = [
JSON.stringify({
key: "header",
value: {
summary: "Fix push_to_hub=False: rewrite params.py after pip install",
description:
"Replace fragile sitecustomize.py approach with direct source-file " +
"rewrite of autotrain/app/params.py after each pip install. " +
"Clears .pyc cache so the patched source is used immediately.",
},
}),
JSON.stringify({
key: "file",
value: { path: "fix_push_to_hub.py", encoding: "base64", content: toB64(fixScript) },
}),
JSON.stringify({
key: "file",
value: { path: "Dockerfile", encoding: "base64", content: toB64(dockerfileContent) },
}),
].join("\n");
const commitRes = await fetch(
`https://huggingface.co/api/spaces/${spaceRepo}/commit/main`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/x-ndjson",
},
body: ndjson,
}
);
if (!commitRes.ok) {
const errText = await commitRes.text().catch(() => "");
throw new Error(`HF Hub commit failed (HTTP ${commitRes.status}): ${errText.slice(0, 400)}`);
}
const commitData = await commitRes.json() as any;
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: true,
message:
"✅ Patch committed! Your AutoTrain Space is rebuilding (~2-3 min). " +
"Once it's back online, new training jobs will automatically push " +
"the fine-tuned model to the Hub.",
space_url: `https://huggingface.co/spaces/${spaceRepo}`,
commit_url: commitData.commitUrl || `https://huggingface.co/spaces/${spaceRepo}/commit/main`,
next_step:
"Wait for the Space status to go green, then start a new training run.",
}),
}],
};
} catch (err: unknown) {
return {
content: [{
type: "text" as const,
text: JSON.stringify({
success: false,
error: err instanceof Error ? err.message : String(err),
}),
}],
};
}
}
);
// ── Resource ──────────────────────────────────────────────────────────
registerAppResource(
server,
resourceUri,
resourceUri,
{ mimeType: RESOURCE_MIME_TYPE, _meta: { ui: { csp: { resourceDomains: [] }, permissions: { openLinks: {} } } } },
async () => {
const html = await fs.readFile(
path.join(__dirname, "dist", "widget.html"),
"utf-8"
);
return { contents: [{ uri: resourceUri, mimeType: RESOURCE_MIME_TYPE, text: html }] };
}
);
// ── Express server ────────────────────────────────────────────────────
const expressApp = express();
expressApp.use(cors());
expressApp.use(express.json({ limit: "10mb" }));
// Health check — required by many cloud platforms and MCP hosts
expressApp.get("/", (_req, res) => {
res.json({ name: "FineTune Studio MCP", status: "ok", version: "1.0.0" });
});
// Some MCP hosts probe GET /mcp before connecting
expressApp.get("/mcp", (_req, res) => {
res.json({ name: "FineTune Studio MCP", status: "ok" });
});
expressApp.post("/mcp", async (req, res) => {
try {
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
res.on("close", () => transport.close());
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error("MCP handler error:", err);
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}
}
});
const PORT = process.env.PORT || 3002;
expressApp.listen(PORT, () =>
console.log(`FineTune Studio MCP Server running at http://localhost:${PORT}/mcp`)
);