1449 lines
61 KiB
TypeScript
1449 lines
61 KiB
TypeScript
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`)
|
||
);
|