unsloth/studio/frontend/src/features/canvas-lab/utils/payload.ts
2026-02-05 21:55:50 +01:00

612 lines
18 KiB
TypeScript

import type { Edge } from "@xyflow/react";
import type {
CategoryConditionalParams,
CanvasNode,
ExpressionConfig,
LlmConfig,
ModelConfig,
ModelProviderConfig,
NodeConfig,
SamplerConfig,
} from "../types";
import { getConfigErrors } from "./index";
type CanvasPayload = {
recipe: {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: Record<string, unknown>[];
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: Record<string, unknown>[];
columns: Record<string, unknown>[];
processors: Record<string, unknown>[];
};
run: {
rows: number;
preview: boolean;
// biome-ignore lint/style/useNamingConvention: api schema
output_formats: string[];
};
ui: {
nodes: { id: string; x: number; y: number }[];
edges: { from: string; to: string; type?: string }[];
};
};
export type CanvasPayloadResult = {
errors: string[];
payload: CanvasPayload;
};
function isSemanticRelation(
source: NodeConfig,
target: NodeConfig,
): boolean {
if (source.kind === "model_provider" && target.kind === "model_config") {
return true;
}
return source.kind === "model_config" && target.kind === "llm";
}
function parseNumber(value?: string): number | null {
if (!value) {
return null;
}
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function parseAgeRange(value?: string): [number, number] | null {
if (!value) {
return null;
}
const parts = value.split(/[^0-9.]+/).filter(Boolean);
if (parts.length !== 2) {
return null;
}
const min = Number(parts[0]);
const max = Number(parts[1]);
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return null;
}
return [min, max];
}
function parseJsonObject(
value: string | undefined,
label: string,
errors: string[],
): Record<string, unknown> | undefined {
if (!value || !value.trim()) {
return undefined;
}
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
} catch {
errors.push(`${label}: invalid JSON.`);
return undefined;
}
errors.push(`${label}: must be a JSON object.`);
return undefined;
}
function buildCategoryConditionalParams(
config: SamplerConfig,
errors: string[],
): Record<string, CategoryConditionalParams> | undefined {
const conditional = config.conditional_params ?? {};
const output: Record<string, CategoryConditionalParams> = {};
for (const [rawCondition, params] of Object.entries(conditional)) {
const condition = rawCondition.trim();
if (!condition) {
errors.push(`Sampler ${config.name}: conditional rule needs condition text.`);
continue;
}
const values = (params.values ?? [])
.map((value) => value.trim())
.filter(Boolean);
if (values.length === 0) {
errors.push(`Sampler ${config.name}: conditional '${condition}' needs values.`);
continue;
}
const weights = params.weights ?? [];
const hasWeights = weights.some((weight) => weight !== null);
if (
hasWeights &&
(weights.length !== values.length || weights.some((weight) => weight === null))
) {
errors.push(`Sampler ${config.name}: conditional '${condition}' weights invalid.`);
continue;
}
output[condition] = {
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: "category",
values,
weights: hasWeights
? weights.filter((weight): weight is number => weight !== null)
: undefined,
};
}
return Object.keys(output).length > 0 ? output : undefined;
}
function buildModelProvider(
config: ModelProviderConfig,
errors: string[],
): Record<string, unknown> {
const extraHeaders = parseJsonObject(
config.extra_headers,
`Provider ${config.name} extra_headers`,
errors,
);
const extraBody = parseJsonObject(
config.extra_body,
`Provider ${config.name} extra_body`,
errors,
);
return {
name: config.name,
endpoint: config.endpoint,
// biome-ignore lint/style/useNamingConvention: api schema
provider_type: config.provider_type,
// biome-ignore lint/style/useNamingConvention: api schema
api_key_env: config.api_key_env?.trim() || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
api_key: config.api_key?.trim() || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
extra_headers: extraHeaders ?? {},
// biome-ignore lint/style/useNamingConvention: api schema
extra_body: extraBody ?? {},
};
}
function buildModelConfig(config: ModelConfig): Record<string, unknown> {
const inference: Record<string, unknown> = {};
const temp = config.inference_temperature?.trim();
const topP = config.inference_top_p?.trim();
const maxTokens = config.inference_max_tokens?.trim();
if (temp) {
const parsed = Number(temp);
if (Number.isFinite(parsed)) {
inference.temperature = parsed;
}
}
if (topP) {
const parsed = Number(topP);
if (Number.isFinite(parsed)) {
// biome-ignore lint/style/useNamingConvention: api schema
inference.top_p = parsed;
}
}
if (maxTokens) {
const parsed = Number(maxTokens);
if (Number.isFinite(parsed)) {
// biome-ignore lint/style/useNamingConvention: api schema
inference.max_tokens = parsed;
}
}
return {
alias: config.name,
model: config.model,
provider: config.provider || undefined,
// biome-ignore lint/style/useNamingConvention: api schema
inference_parameters:
Object.keys(inference).length > 0 ? inference : undefined,
// biome-ignore lint/style/useNamingConvention: api schema
skip_health_check: config.skip_health_check || undefined,
};
}
function isValidSex(value?: string): value is "Male" | "Female" {
if (!value) {
return false;
}
return value === "Male" || value === "Female";
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per type logic
function buildSamplerParams(
config: SamplerConfig,
errors: string[],
): Record<string, unknown> {
if (config.sampler_type === "category") {
const values = config.values ?? [];
const params: Record<string, unknown> = { values };
const weights = config.weights ?? [];
const hasWeights = weights.some((weight) => weight !== null);
if (hasWeights && weights.some((weight) => weight === null)) {
errors.push(`Sampler ${config.name}: weights missing values.`);
} else if (hasWeights) {
params.weights = weights.filter((weight) => weight !== null);
}
return params;
}
if (config.sampler_type === "subcategory") {
const mapping = config.subcategory_mapping ?? {};
for (const [key, values] of Object.entries(mapping)) {
if (!values || values.length === 0) {
errors.push(
`Subcategory ${config.name}: '${key}' needs at least 1 subcategory.`,
);
}
}
return {
category: config.subcategory_parent,
values: mapping,
};
}
if (config.sampler_type === "uniform") {
return {
low: parseNumber(config.low),
high: parseNumber(config.high),
};
}
if (config.sampler_type === "gaussian") {
return {
mean: parseNumber(config.mean),
std: parseNumber(config.std),
};
}
if (config.sampler_type === "bernoulli") {
return {
p: parseNumber(config.p),
};
}
if (config.sampler_type === "datetime") {
return {
start: config.datetime_start ?? undefined,
end: config.datetime_end ?? undefined,
unit: config.datetime_unit ?? undefined,
};
}
if (config.sampler_type === "timedelta") {
return {
// biome-ignore lint/style/useNamingConvention: api schema
dt_min: parseNumber(config.dt_min),
// biome-ignore lint/style/useNamingConvention: api schema
dt_max: parseNumber(config.dt_max),
// biome-ignore lint/style/useNamingConvention: api schema
reference_column_name: config.reference_column_name || undefined,
unit: config.timedelta_unit || undefined,
};
}
if (config.sampler_type === "uuid") {
return {
format: config.uuid_format ?? undefined,
};
}
const params: Record<string, unknown> = {};
if (config.person_locale?.trim()) {
params.locale = config.person_locale.trim();
}
if (config.sampler_type === "person") {
if (isValidSex(config.person_sex?.trim())) {
params.sex = config.person_sex?.trim();
} else if (config.person_sex?.trim()) {
errors.push(`Person ${config.name}: sex must be Male or Female.`);
}
} else if (config.person_sex?.trim()) {
params.sex = config.person_sex.trim();
}
if (config.person_city?.trim()) {
params.city = config.person_city.trim();
}
if (config.person_age_range?.trim()) {
const parsed = parseAgeRange(config.person_age_range);
if (parsed) {
// biome-ignore lint/style/useNamingConvention: api schema
params.age_range = parsed;
} else {
errors.push(`Person ${config.name}: age range must be like 18-70.`);
}
}
if (config.sampler_type === "person") {
// biome-ignore lint/style/useNamingConvention: api schema
params.with_synthetic_personas =
config.person_with_synthetic_personas ?? undefined;
}
return params;
}
function buildLlmColumn(
config: LlmConfig,
errors: string[],
): Record<string, unknown> {
const base = {
name: config.name,
// biome-ignore lint/style/useNamingConvention: api schema
model_alias: config.model_alias,
prompt: config.prompt,
// biome-ignore lint/style/useNamingConvention: api schema
system_prompt: config.system_prompt || undefined,
};
if (config.llm_type === "code") {
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-code",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
code_lang: config.code_lang || "python",
};
}
if (config.llm_type === "structured") {
let outputFormat: unknown = config.output_format || undefined;
if (typeof outputFormat === "string" && outputFormat.trim()) {
try {
outputFormat = JSON.parse(outputFormat);
} catch {
errors.push(`LLM ${config.name}: output_format is not valid JSON.`);
}
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-structured",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
output_format: outputFormat,
};
}
if (config.llm_type === "judge") {
const scores = (config.scores ?? [])
.map((score) => {
const options: Record<string, string> = {};
for (const option of score.options ?? []) {
const key = option.value.trim();
const value = option.description.trim();
if (!key || !value) {
continue;
}
options[key] = value;
}
return {
name: score.name.trim(),
description: score.description.trim(),
options,
};
})
.filter(
(score) =>
score.name && score.description && Object.keys(score.options).length > 0,
);
if (scores.length === 0) {
errors.push(`LLM ${config.name}: scores required for LLM Judge.`);
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-judge",
...base,
scores,
};
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "llm-text",
...base,
// biome-ignore lint/style/useNamingConvention: api schema
with_trace: false,
};
}
function buildExpressionColumn(
config: ExpressionConfig,
errors: string[],
): Record<string, unknown> {
if (!config.expr.trim()) {
errors.push(`Expression ${config.name}: expr required.`);
}
return {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "expression",
name: config.name,
expr: config.expr,
dtype: config.dtype,
};
}
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: payload build
export function buildCanvasPayload(
configs: Record<string, NodeConfig>,
nodes: CanvasNode[],
edges: Edge[],
): CanvasPayloadResult {
const errors: string[] = [];
const columns: Record<string, unknown>[] = [];
const modelAliases = new Set<string>();
const modelProviderNames = new Set<string>();
const modelProviders: Record<string, unknown>[] = [];
const modelConfigs: Record<string, unknown>[] = [];
const modelProviderConfigs: ModelProviderConfig[] = [];
const modelConfigConfigs: ModelConfig[] = [];
const nameSet = new Set<string>();
const nameToConfig = new Map<string, NodeConfig>();
for (const node of nodes) {
const config = configs[node.id];
if (!config) {
continue;
}
for (const error of getConfigErrors(config)) {
errors.push(`${config.name}: ${error}`);
}
if (nameSet.has(config.name)) {
errors.push(`Duplicate node name: ${config.name}.`);
}
nameSet.add(config.name);
if (config.kind === "sampler") {
nameToConfig.set(config.name, config);
const samplerColumn: Record<string, unknown> = {
// biome-ignore lint/style/useNamingConvention: api schema
column_type: "sampler",
name: config.name,
// biome-ignore lint/style/useNamingConvention: api schema
sampler_type: config.sampler_type,
params: buildSamplerParams(config, errors),
// biome-ignore lint/style/useNamingConvention: api schema
convert_to: config.convert_to ?? undefined,
};
if (config.sampler_type === "category") {
const conditionalParams = buildCategoryConditionalParams(config, errors);
if (conditionalParams) {
// biome-ignore lint/style/useNamingConvention: api schema
samplerColumn.conditional_params = conditionalParams;
}
}
columns.push(samplerColumn);
} else if (config.kind === "llm") {
columns.push(buildLlmColumn(config, errors));
if (config.model_alias) {
modelAliases.add(config.model_alias);
}
nameToConfig.set(config.name, config);
} else if (config.kind === "expression") {
columns.push(buildExpressionColumn(config, errors));
nameToConfig.set(config.name, config);
} else if (config.kind === "model_provider") {
modelProviderNames.add(config.name);
modelProviders.push(buildModelProvider(config, errors));
modelProviderConfigs.push(config);
} else if (config.kind === "model_config") {
modelConfigs.push(buildModelConfig(config));
modelConfigConfigs.push(config);
}
}
for (const config of Object.values(configs)) {
if (config.kind !== "sampler" || config.sampler_type !== "subcategory") {
continue;
}
const parentName = config.subcategory_parent;
if (!parentName) {
errors.push(`Subcategory ${config.name}: parent category required.`);
continue;
}
const parent = nameToConfig.get(parentName);
const parentValues =
parent && parent.kind === "sampler" && parent.sampler_type === "category"
? (parent.values ?? [])
: [];
const mapping = config.subcategory_mapping ?? {};
for (const value of parentValues) {
const list = mapping[value];
if (!list || list.length === 0) {
errors.push(
`Subcategory ${config.name}: '${value}' needs at least 1 subcategory.`,
);
}
}
}
for (const config of Object.values(configs)) {
if (config.kind !== "sampler" || config.sampler_type !== "timedelta") {
continue;
}
const reference = config.reference_column_name?.trim() ?? "";
if (!reference) {
errors.push(`Timedelta ${config.name}: reference datetime column required.`);
continue;
}
const parent = nameToConfig.get(reference);
if (
!parent ||
parent.kind !== "sampler" ||
parent.sampler_type !== "datetime"
) {
errors.push(`Timedelta ${config.name}: reference '${reference}' must be datetime.`);
}
}
for (const alias of modelAliases) {
if (
!modelConfigs.some(
(config) => (config.alias as string | undefined) === alias,
)
) {
errors.push(`LLM model_alias ${alias}: missing model config.`);
}
}
for (const config of modelConfigConfigs) {
const provider = config.provider.trim();
const alias = config.name;
if (modelAliases.has(alias) && !config.model.trim()) {
errors.push(`Model config ${alias}: model is required.`);
}
if (provider && !modelProviderNames.has(provider)) {
errors.push(`Model config ${alias}: provider ${provider} not found.`);
}
}
const usedProviders = new Set(
modelConfigConfigs.map((config) => config.provider.trim()).filter(Boolean),
);
for (const provider of modelProviderConfigs) {
if (!usedProviders.has(provider.name)) {
continue;
}
if (!provider.endpoint.trim()) {
errors.push(`Model provider ${provider.name}: endpoint is required.`);
}
if (!provider.provider_type.trim()) {
errors.push(`Model provider ${provider.name}: provider_type is required.`);
}
}
const uiNodes = nodes.flatMap((node) => {
const config = configs[node.id];
if (!config) {
return [];
}
return [
{
id: config.name,
x: node.position.x,
y: node.position.y,
},
];
});
const uiEdges = edges.flatMap((edge) => {
const source = edge.source ? configs[edge.source] : null;
const target = edge.target ? configs[edge.target] : null;
if (!(source && target)) {
return [];
}
return [
{
from: source.name,
to: target.name,
type:
edge.type === "semantic" || isSemanticRelation(source, target)
? "semantic"
: "canvas",
},
];
});
return {
errors,
payload: {
recipe: {
// biome-ignore lint/style/useNamingConvention: api schema
model_providers: modelProviders,
// biome-ignore lint/style/useNamingConvention: api schema
model_configs: modelConfigs,
columns,
processors: [],
},
run: {
rows: 5,
preview: true,
// biome-ignore lint/style/useNamingConvention: api schema
output_formats: ["jsonl"],
},
ui: {
nodes: uiNodes,
edges: uiEdges,
},
},
};
}