/** * The contract. * * Almost all of this comes straight from the Hub, which is CORS-open. The app has * two endpoints of its own: `POST /api/train` to start a run, and * `POST /api/preflight` to measure a dataset — the one read that needs the model's * real tokenizer and chat template. See `src/hub.ts` for where each shape is * filled in. * * A rule that shapes this file: the user has never trained a model and never * will read a docs page. Nothing here is measured in tokens, steps or anything * else they'd have to look up — budgets are minutes and conversation counts, and * the only structural concept that ever surfaces is "this conversation is too * long and loses its ending", and only when it actually happens. */ /* -------------------------------------------------------------------------- */ /* Account */ /* -------------------------------------------------------------------------- */ export interface HfUser { /** Hub username, and the namespace the trained model lands in. */ name: string; /** * Whether the account has a way to pay. Jobs are pay-as-you-go on the user's * own credit, so without this nothing can run — and it's worth knowing that * before they've picked anything, not after. */ canPay: boolean; } /* -------------------------------------------------------------------------- */ /* Recipes */ /* -------------------------------------------------------------------------- */ /** One of three curated models. Hyperparameters are never exposed. */ export interface Model { /** The Hub id, which is also what `POST /api/train` takes. */ id: string; /** "Qwen3 4B" */ label: string; /** One short line on what picking this one gets you. */ note: string; /** Exactly one model is flagged; it's preselected. */ recommended: boolean; } export type PlanId = "quick" | "balanced" | "full"; /** * How much to train on. The only dial the user touches. * * Just a label here: the budget itself is a token count that lives in * PLAN_TOKENS in server/app.py, because turning it into a number of * conversations needs the dataset measured with a real tokenizer. */ export interface Plan { id: PlanId; /** "Quick" */ label: string; } /* -------------------------------------------------------------------------- */ /* Datasets */ /* -------------------------------------------------------------------------- */ /** * Where a dataset came from. Used only for ordering and the example badge — * there are no tabs, everything lives in one list. */ export type DatasetSource = "example" | "yours" | "hub"; export interface DatasetSummary { /** "qgallouedec/my-chats" */ id: string; private: boolean; /** Gated datasets need terms accepted before the job can read them. */ gated: boolean; rows: number; description: string; source: DatasetSource; /** * What the dataset's own card claims about having a `messages` column. * * "chat" — it declares one, so it can be trained on * "other" — it declares its columns and `messages` isn't among them, which * is the one reliable *negative*: it really can't be trained on * "unknown" — the card says nothing about its columns * * "unknown" used to be lumped in with "other" and hidden, which was wrong and * badly so: of 30 datasets under one real account, 28 declare nothing at all, so * searching your own name returned "nothing usable matched". A card is a weak * signal — plenty of perfectly good datasets never write one — and preflight, * which reads actual rows through the viewer, is the real authority. So unknown * datasets are listed and selectable, and the Check step decides. */ format: "chat" | "other" | "unknown"; } /* -------------------------------------------------------------------------- */ /* Preflight */ /* -------------------------------------------------------------------------- */ /** * `tool` appears in tool-calling datasets: it's the turn carrying what a tool * returned, which the model reads but is not trained to produce. */ export type Role = "system" | "user" | "assistant" | "tool"; /** * One call the assistant is being taught to make. `arguments` arrives with values * already flattened to strings — the server does that so this side only lays them * out, and so a nested object reads as compact JSON rather than "[object Object]". */ export interface ToolCall { name: string; arguments: Record; } export interface Message { role: Role; content: string; /** Set on an assistant turn that calls tools instead of writing a reply. */ toolCalls?: ToolCall[]; /** On a `tool` turn, which tool produced it. */ name?: string; } /** * One conversation, shown to the user as a conversation. * * If the length limit falls inside it, `cutFromMessage` marks the first turn * that gets lost so the preview can fade out at exactly that point. Both cut * fields are null whenever the conversation fits, which is the common case — * the idea of a limit never appears unless it actually bites. */ export interface PreviewSample { messages: Message[]; cutFromMessage: number | null; /** If the cut lands mid-message, how many characters of it survive. */ cutAtChar: number | null; } export type IssueLevel = "error" | "warning" | "info"; /** A preflight finding, phrased for someone who has never trained a model. */ export interface Issue { level: IssueLevel; title: string; detail: string; } /** What one plan works out to for this dataset. */ export interface PlanEstimate { planId: PlanId; conversations: number; estMinutes: number; /** What it costs on the user's own account, at the flavor's hourly rate. */ estCostUsd: number; } export interface PreflightReport { datasetId: string; totalRows: number; /** Conversations that lose their ending at the shared length limit. */ truncatedCount: number; /** Same, as a share of the sample, 0..1. */ truncatedPct: number; issues: Issue[]; samples: PreviewSample[]; perPlan: Record; recommendedPlan: PlanId; } /* -------------------------------------------------------------------------- */ /* Runs */ /* -------------------------------------------------------------------------- */ /** * The stages a run moves through. The first three come from the Hub's own job * stage; the rest are the recipe announcing itself on stdout, which the log * stream carries back. See `[fts] stage` in server/recipe/train.py. */ export type RunStatus = | "queued" | "starting" | "training" | "sampling" | "pushing" | "done" | "error"; export interface LogLine { text: string; level: "info" | "warn" | "error"; } /** Base vs tuned on the same prompt, taken from conversations held back from training. */ export interface SampleComparison { prompt: string; before: string; after: string; } export interface Run { /** The HF Jobs job id — the id every Hub jobs endpoint takes. */ id: string; status: RunStatus; modelId: string; planId: PlanId; datasetId: string; /** Destination repo in the user's namespace. */ outputRepo: string; createdAt: string; /** Overall completion, 0..1. The user never sees a step count. */ progress: number; /** Technical detail, available behind a disclosure but never surfaced by default. */ logs: LogLine[]; /** Seconds remaining, or null when we can't say. */ etaSeconds: number | null; /** Populated once status reaches "done" and the samples file has been read. */ samples?: SampleComparison[]; error?: string; } /** Anything that hasn't finished — what the header lists. */ export function isActive(run: Run) { return run.status !== "done" && run.status !== "error"; }